Skip to content

Add support for CSS modules with SSR consistency #103606

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: trunk
Choose a base branch
from

Conversation

aduth
Copy link
Member

@aduth aduth commented May 21, 2025

Part of ARC-85
Related to / builds upon #103089

Proposed Changes

  • Extends default CSS modules support to ensure consistent class name generation between client-side and server-side builds
  • Converts an example component as demonstration of server-rendered behavior (MarketplaceFooter)

Our default Webpack configuration already has support for CSS modules because it is enabled by default in css-loader, but it requires a few additions:

  • css-loader generates a unique salt that can produce different results between builds. Therefore, we override getLocalIdent to produce a stable result
  • We only want the server to generate an object mapping of original -> transformed class names and not fully embed the CSS, so we use exportOnlyLocals and disable MiniCssExtractPlugin
  • Our current server Webpack configuration explicitly ignores Sass/CSS files, so we need to exempt CSS module files from that ignore pattern

Why are these changes being made?

  • Support adoption of CSS modules within paths leveraging server rendering
  • Benefits of component conversion:
    • Reference demonstration of using CSS modules
    • Includes some workarounds for common use-cases (conditional styles, styles overriding descendent components)
    • Migrate away from Emotion styles
    • Clearer separation of MarketplaceFooter from EducationFooter: the previous location had two component definitions serving two distinct purposes

Testing Instructions

Verify no visual difference in the marketplace footer

  1. Go to a plugin details page
  2. Observe the footer at the bottom of the page is consistent between environments
  3. Repeat steps while logged out (e.g. using a private browsing window). The visual appearance is slightly different between logged-in and logged-out users (vertical padding, background color)
Logged In Logged Out
image image

Ensure that generated class names are consistent:

  1. Run yarn build-server
  2. Run yarn build-client
  3. Search for generated server class name of one of the components:
    • grep marketplace-footer__three-column build/server.js
    • Example: "marketplace-footer__three-column": _02216b3f2e``
  4. Search for generated client class name of the same component:
    • grep -R marketplace-footer__three-column public/evergreen
    • Example: \"marketplace-footer__three-column\":\"_02216b3f2e\"
  5. Observe that the class names are identical

Pre-merge Checklist

  • Has the general commit checklist been followed? (PCYsg-hS-p2)
  • Have you written new tests for your changes?
  • Have you tested the feature in Simple (P9HQHe-k8-p2), Atomic (P9HQHe-jW-p2), and self-hosted Jetpack sites (PCYsg-g6b-p2)?
  • Have you checked for TypeScript, React or other console errors?
  • Have you used memoizing on expensive computations? More info in Memoizing with create-selector and Using memoizing selectors and Our Approach to Data
  • Have we added the "[Status] String Freeze" label as soon as any new strings were ready for translation (p4TIVU-5Jq-p2)?
    • For UI changes, have we tested the change in various languages (for example, ES, PT, FR, or DE)? The length of text and words vary significantly between languages.
  • For changes affecting Jetpack: Have we added the "[Status] Needs Privacy Updates" label if this pull request changes what data or activity we track or use (p4TIVU-aUh-p2)?

Copy link

github-actions bot commented May 21, 2025

@matticbot
Copy link
Contributor

matticbot commented May 21, 2025

Here is how your PR affects size of JS and CSS bundles shipped to the user's browser:

App Entrypoints (~286 bytes removed 📉 [gzipped])

name                    parsed_size           gzip_size
entry-reauth-required         -44 B  (-0.0%)     -148 B  (-0.0%)
entry-main                    -44 B  (-0.0%)     -148 B  (-0.0%)
entry-login                   -44 B  (-0.0%)     -148 B  (-0.0%)
entry-dashboard-dotcom        -44 B  (-0.0%)     -145 B  (-0.0%)
entry-dashboard-a4a           -44 B  (-0.0%)     -145 B  (-0.0%)

Common code that is always downloaded and parsed every time the app is loaded, no matter which route is used.

Sections (~46 bytes added 📈 [gzipped])

name                             parsed_size           gzip_size
jetpack-cloud-plugin-management        -28 B  (-0.0%)      +48 B  (+0.0%)
a8c-for-agencies-plugins               -28 B  (-0.0%)      +48 B  (+0.0%)
plugins                                -26 B  (-0.0%)      +46 B  (+0.0%)

Sections contain code specific for a given set of routes. Is downloaded and parsed only when a particular route is navigated to.

Async-loaded Components (~4 bytes added 📈 [gzipped])

name               parsed_size           gzip_size
async-load-design        +24 B  (+0.0%)       +4 B  (+0.0%)

React components that are loaded lazily, when a certain part of UI is displayed for the first time.

Legend

What is parsed and gzip size?

Parsed Size: Uncompressed size of the JS and CSS files. This much code needs to be parsed and stored in memory.
Gzip Size: Compressed size of the JS and CSS files. This much data needs to be downloaded over network.

Generated by performance advisor bot at iscalypsofastyet.com.

@aduth aduth requested a review from a team May 21, 2025 19:27
@matticbot matticbot added the [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. label May 21, 2025
Copy link
Member

@mirka mirka left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great, and I've confirmed that the output is stable as expected.

Didn't mean to nitpick the CSS — I don't think we necessarily need to clean up every possible thing when converting existing code, but just left some thoughts in case it adds to the pile of patterns that can be simplified when converted to modules. In fact, for "quick" conversions, it may actually be safer and efficient to maintain the existing cascade/specificity as much as possible.

{ __( 'Get Started' ) }
</Button>
) }
<ThreeColumnContainer>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was almost about to say we should keep the ThreeColumnContainer in sync for the EducationFooter and MarketplaceFooter, but looking at the actual components they look pretty unrelated. Probably better that they're separated now.

--color-accent-60: var( --studio-blue-60 );
margin-bottom: -32px;

& .marketplace-footer__section::before {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The .marketplace-footer__section selectors don't need to be nested anymore since we have our own unique class.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The .marketplace-footer__section selectors don't need to be nested anymore since we have our own unique class.

While that's true, we have an issue that these styles are meant to override the default styles applied by the Section component. If we un-nest this, the selectors will have equal specificity, which may mean that the override doesn't take effect.

A few thoughts on how we could address this:

  • Normally I might want to do something like .marketplace-footer__section.section::before to both explicitly make it clear that we're overriding styles from .section and increase the specificity, but since Section uses Emotion, we don't have a stable selector to use.
  • Ideally we wouldn't be overriding through styles like this in the first place, and Section would allow (or not allow) what types of customizations it expects to be permitted for the background color. In a way, it could be argued that it's "by design" that the background isn't allowed to be overridden through styles like this, and this is part of what we hope to achieve with using CSS modules.
  • Absent the other options, we could leave this as-is.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'm inclined to leave this as-is for the purpose of this pull request, and follow-up with an enhancement to the <Section /> component to better support additional variations of background color.

background-color: #f6f7f7;
}

&:not(.is-logged-in) .marketplace-footer__section {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like the modifier could've been set directly on marketplace-footer__section and not on marketplace-footer.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like the modifier could've been set directly on marketplace-footer__section and not on marketplace-footer.

This sent me down a rabbit hole on trying to determine if modifier classes are valid on BEM Elements 😂 Since I usually only see them paired with Blocks. But it seems to be perfectly valid!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like the modifier could've been set directly on marketplace-footer__section and not on marketplace-footer.

Updated in fd65ebf.

aduth added a commit that referenced this pull request May 28, 2025
aduth added a commit that referenced this pull request May 28, 2025
@aduth aduth force-pushed the add/css-modules-ssr-support branch from 9a99e61 to fd65ebf Compare May 28, 2025 20:30
@matticbot
Copy link
Contributor

This PR modifies the release build for the following Calypso Apps:

For info about this notification, see here: PCYsg-OT6-p2

  • notifications
  • wpcom-block-editor

To test WordPress.com changes, run install-plugin.sh $pluginSlug add/css-modules-ssr-support on your sandbox.

createHash( 'md5' )
.update( context.resourcePath + localName )
.digest( 'hex' )
.substr( 0, 10 ),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This currently generates completely opaque class names that begin with _:

const style_module = ({
  "marketplace-footer": `_5df97c7b40`,
  "marketplace-footer__section": `_3c3498323c`,
  "is-logged-in": `_d93f9bf602`,
  "marketplace-footer__cta": `_92b887b139`,
  "marketplace-footer__three-column": `_bf9c1e28f8`
});

Could we make the styles (and the DOM markup that the React app generates) more human readable by adding the hash only as a suffix to the original name? The class names then would be like

marketplace-footer_5df97c7b40

Without this, inspecting DOM markup in devtools would be almost impossible: everything would be a div with unreadable class names.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To some extent, the opaqueness is the point, since I believe that one of the goals of using CSS modules is to close components for external extension in order to avoid CSS (class names, etc.) being considered as part of a component's public API for backwards-compatibility.

Using the recommendation you suggested, it would open up the possibility of people doing something like...

.my-component [class^="marketplace-footer_"] {
  /* ...overrides */
}

But I agree it does have some trade-offs, and I've even caught myself being confused by the DevTools DOM inspector structure trying to find where styles are applied.

A couple thoughts:

  • Maybe we don't need to be so strictly rigid here, and someone overriding like above is either unlikely or requires some tacit understanding of the overrides being fragile
  • We could change the behavior to fully mangle in production builds and have more-helpful names in development. This could be error-prone however, since code like above would work in development and fail when deployed to production. Additionally, it could be helpful to maintain some debuggability in production.
  • If mangling names is beneficial for backwards-compatibility, maybe we limit it to applying to situations where we need to more carefully consider backwards-compatibility (packages/components, or packages more broadly).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another idea is that we could have Stylelint check and lint against the user of [class^=...]. It's pretty uncommon and almost always a code smell to at least lint by default and require explicit override to disable.

I think I'll incorporate the component basename.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated in 9ff1d89 to incorporate the original identifier, so now we get something like:

const style_module = ({
	"marketplace-footer": `marketplace-footer_de608`,
	"marketplace-footer__section": `marketplace-footer__section_f95a5`,
	"is-logged-in": `is-logged-in_91412`,
	"marketplace-footer__cta": `marketplace-footer__cta_12292`,
	"marketplace-footer__three-column": `marketplace-footer__three-column_d7cfb`
});

One other thing I noticed is that we want the string to be deterministic so that it's the same between client and server builds, but not too deterministic that the class name will include a hash that is relatively stable over time (e.g. allowing someone to just target .marketplace-footer_de608 for overrides).

Open to suggestions, but I opted to try to detect the current git HEAD SHA value and use that as part of the generated hash. If it's unavailable for any reason (e.g. static file copy of the project), it'll gracefully fall back to creating a hash like hash.update( 'marketplace-footerundefined' ).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sounds like a good compromise. We can always revisit at a later point (especially given that it's not meant to be a public API contract).

I also like the idea of a Stylelint check, although we need to make sure not to create too many false positives.

@aduth aduth force-pushed the add/css-modules-ssr-support branch from fd65ebf to 9ff1d89 Compare May 30, 2025 17:04
@mirka mirka mentioned this pull request Jun 6, 2025
8 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Framework [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants