Skip to content

StepContainerV2: Migrate to CSS modules #103089

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 4 commits into
base: trunk
Choose a base branch
from

Conversation

zaguiini
Copy link
Contributor

@zaguiini zaguiini commented May 2, 2025

Proposed Changes

Migrates StepContainerV2 to CSS modules instead of static CSS class names to prevent overrides.

On several occasions, we observed PRs trying to achieve visual goals by modifying the wireframe or container styles. This isn't supposed to be like that. If we let these changes in, then the whole purpose of StepContainerV2 (consistency, predictability, composability) goes down the drain.

This PR changes from static CSS to CSS modules to prevent class names and avoid such scenarios. It does so by modifying the Webpack configuration so it supports .module.scss files and generates class names, even on the server, as some wireframes (Loading and, very soon, CenteredContentLayout) are rendering styles on the server.

This will also make #102503 irrelevant, as the problem this PR solves will solve that one as a side effect.

Reviewing this PR

@Automattic/team-calypso, you can disregard all changes within packages/onboarding. The meat of this PR is within packages/calypso-build/webpack/sass.js and client.

Before, the server bundle processing completely ignored CSS. Now, both SSR and CSR modes include CSS processing and extraction. Why were we not doing that before? Is there anything we should do differently?

Testing Instructions

Ensure you see no layout shifts and that the steps are properly applied when entering /setup, including the centered loading bar and the WordPress logo at the top-left corner.

Copy link

github-actions bot commented May 2, 2025

@matticbot
Copy link
Contributor

matticbot commented May 2, 2025

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

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

name                    parsed_size           gzip_size
entry-stepper               +3595 B  (+0.2%)     +395 B  (+0.1%)
entry-login                 +1358 B  (+0.1%)     -886 B  (-0.1%)
entry-subscriptions          -101 B  (-0.0%)      -22 B  (-0.0%)
entry-domains-landing        -101 B  (-0.0%)      -22 B  (-0.0%)
entry-dashboard-dotcom       -101 B  (-0.0%)      -22 B  (-0.0%)
entry-dashboard-a4a          -101 B  (-0.0%)      -22 B  (-0.0%)
entry-browsehappy            -101 B  (-0.1%)      -22 B  (-0.0%)
entry-reauth-required         -38 B  (-0.0%)    -1154 B  (-0.2%)
entry-main                    -38 B  (-0.0%)    -1152 B  (-0.2%)

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

Sections (~4310 bytes added 📈 [gzipped])

name                        parsed_size           gzip_size
checkout                        +4211 B  (+0.2%)     +638 B  (+0.1%)
a8c-for-agencies-client         +4211 B  (+0.2%)     +622 B  (+0.1%)
async-step-unified-domains      +3620 B  (+0.2%)     +420 B  (+0.1%)
async-step-unified-plans        +3064 B  (+0.3%)     +150 B  (+0.1%)
marketplace                     +2136 B  (+0.3%)     +366 B  (+0.2%)
stepper-user-step               +1810 B  (+0.2%)     +355 B  (+0.2%)
async-step-use-my-domain        +1691 B  (+0.3%)     +322 B  (+0.2%)
signup                          +1648 B  (+0.6%)      +21 B  (+0.0%)
gutenberg-editor                 +108 B  (+0.0%)      +44 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 (~9082 bytes removed 📉 [gzipped])

name                                             parsed_size           gzip_size
async-load-calypso-blocks-editor-checkout-modal      +4108 B  (+0.3%)     +582 B  (+0.2%)
async-load-signup-steps-domains                      +3632 B  (+0.3%)     +694 B  (+0.2%)
async-load-signup-steps-plans-theme-preselected      +3579 B  (+0.7%)     +833 B  (+0.5%)
async-load-signup-steps-plans                        +3444 B  (+0.7%)     +660 B  (+0.4%)

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.

@zaguiini zaguiini self-assigned this May 5, 2025
@zaguiini zaguiini requested a review from a team May 5, 2025 17:10
@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 5, 2025
@zaguiini zaguiini marked this pull request as ready for review May 5, 2025 17:10
@zaguiini zaguiini requested a review from a team as a code owner May 5, 2025 17:10
@zaguiini zaguiini force-pushed the step-container-v2-css-modules branch from 002a88c to 6c7a323 Compare May 5, 2025 17:14
@aduth
Copy link
Member

aduth commented May 5, 2025

Related effort to introduce CSS modules: #103004 (cc @mirka)

Comment on lines -130 to +133
{
test: /\.(sc|sa|c)ss$/,
loader: 'ignore-loader',
},
scssConfig.loader,
Copy link
Member

Choose a reason for hiding this comment

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

Could/should we still ignore everything except files suffixed with .module.scss ?

Why were we not doing [server-side CSS processing and extraction] before? Is there anything we should do differently?

I'm not sure exactly why we would have needed to do it previously. Am I correct in understanding that it's required now so that the server can generate the computed class names in the server-rendered markup? Since we would have previously had static class names, I would suppose that's the reason we wouldn't need to process the stylesheets. I'd imagine there's also a performance overhead in doing so, at least in contrast with ignoring them altogether.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Could/should we still ignore everything except files suffixed with .module.scss ?

Interesting. I think that would help with performance.

Am I correct in understanding that it's required now so that the server can generate the computed class names in the server-rendered markup?

Yes, that is exactly why. The class names must match both in the client and the server-generated markup.

@@ -18,6 +18,29 @@ module.exports.loader = ( { includePaths, prelude, postCssOptions } ) => ( {
loader: require.resolve( 'css-loader' ),
options: {
importLoaders: 2,
modules: {
auto: /\.module\.scss$/, // Only enable CSS modules for .module.scss files
getLocalIdent: ( context, localIdentName, localName ) => {
Copy link
Member

Choose a reason for hiding this comment

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

What are we trying to achieve with this behavior?

Copy link
Contributor Author

@zaguiini zaguiini May 6, 2025

Choose a reason for hiding this comment

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

This is needed for deterministic class name generation between the server and the client, since each Webpack entry processes its own chunk.

Copy link
Member

Choose a reason for hiding this comment

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

This is needed for deterministic class name generation between the server and the client, since each Webpack entry processes its own chunk.

Interesting. That makes sense to me, though I would have thought the default behavior would produce a deterministic identifier as well.

One of the concerns I had is whether this could enable someone to continue targeting external CSS styles at the generated name, and if that's something we'd want to be discouraging.

e.g.

[class^="StepContainerV2_style-module-scss__"] {
  // ...some override
}

Since the default behavior for CSS module class name generation doesn't have anything static to target, it would not be possible.


// Custom class name for modules
return (
context.resourcePath.split( '/' ).slice( -2 ).join( '_' ) +
Copy link
Member

Choose a reason for hiding this comment

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

Do we have any guarantees that this will produce safe CSS names?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

resourcePath are valid file names, so I'm guessing so.

However, I think we can go with the simpler solution following the hashing in this file:

require( 'crypto' )
    .createHash( 'md5' )
    .update( context.resourcePath + localName )
    .digest( 'hex' )

This seems to be enough.

Copy link
Member

Choose a reason for hiding this comment

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

There are some weird gotchas like not allowing to start with a number which could happen with a file name or a hash value.

Looks like the default behavior has some extra checks to handle this:

https://github.com/webpack-contrib/css-loader/blob/9d030151bb221888ecfb0163b10910a7a918fe31/src/utils.js#L349-L356

@jsnajdr
Copy link
Member

jsnajdr commented May 7, 2025

Before, the server bundle processing completely ignored CSS. Now, both SSR and CSR modes include CSS processing and extraction. Why were we not doing that before?

The server doesn't need to know any CSS because it's not a part of the markup it outputs. The markup is only HTML/JSX elements, with class names.

The server only needs to know which CSS/JS assets are associated with a certain page (section) like /log-in or /themes. So that it can send the links in <link> and <script> tags in the generated markup. These assets are stored in the build/assets.json file, generated by webpack during the client build. That's where the server finds them when serving a page.

The only reason why the server needs to start caring about CSS now is that the CSS modules export the actual class names. The class name in the server-generated markup is no longer a hardcoded step-container-v2__content-row, but it's styles[ 'step-container-v2__content-row' ] where styles is the CSS module export.

The server continues to not care about the actual content of the CSS rules, it's interested only in the class names.

@zaguiini
Copy link
Contributor Author

zaguiini commented May 7, 2025

The server continues to not care about the actual content of the CSS rules, it's interested only in the class names.

@jsnajdr so you're saying we should be fine with loading the CSS and generating the class names, but instead of extracting the CSS (MiniCssExtractPlugin.loader), just ignore the output (ignore-loader)?

@jsnajdr
Copy link
Member

jsnajdr commented May 8, 2025

instead of extracting the CSS (MiniCssExtractPlugin.loader), just ignore the output (ignore-loader)?

Yes, we certainly don't want the server build to output any CSS assets because it won't use them. CSS is built as part of the client build (which is isomorphic to server). And the server then embeds the client CSS assets as <link rel="stylesheet" href="client/url"> elements.

We want webpack to compile CSS modules into something like:

// this is the `style.scss` module transformed into JS:
const styles = {
  'step-container-v2__content-row': 'abcd123',
  'left': 'efgh456',
  'right': 'ijkl789',
}
export default styles;

I.e., export the classnames, but nothing else, particularly not the content of the CSS rules. I don't know right now which configuration of the various CSS loaders achieves this. ignore-loader is maybe too much, we don't want to ignore everything.

We also need to make sure that the generated class names (abdc123) are the same in the server and client build. These are two separate webpack configs and two webpack runs. The hashes and the seeds and whatever else is used to generate the names must be the same in both.

@ciampo
Copy link
Contributor

ciampo commented May 8, 2025

We also need to make sure that the generated class names (abdc123) are the same in the server and client build.

I guess that would imply:

  • using same exact webpack loader configs
  • using same exact versions of webpack, loader, and node.js, same env variables
  • using webpack deterministic hashing algorithms (ie. using something like [contenthash:base64:8], docs)

@jsnajdr
Copy link
Member

jsnajdr commented May 12, 2025

I guess that would imply:

@ciampo The hashing algorithm in the css-loader uses three inputs:

  • a hashSalt value which is either specified as css-loader option or uses the webpack's "global" value. We'll probably have to set this value explicitly for the server and client builds. Needs to be the same for both.
  • a resourcePath value, i.e., the path of the CSS file. We need to ensure the same resource path in both builds. If there is any prefix, for example, specific for one of the builds, it needs to be fixed.
  • the local CSS class name.

That means that with little care, we should be able to guarantee identical scrambling both for client and server. We don't need to replicate the entire environment to the last detail. Also, the css-loader hashing is heavily customizable, that works in our favor.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[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