Skip to content

Fix CSS module class name divergence with rspack SSR#2489

Open
justin808 wants to merge 10 commits intomasterfrom
jg/2488-fix-rspack-install-docs
Open

Fix CSS module class name divergence with rspack SSR#2489
justin808 wants to merge 10 commits intomasterfrom
jg/2488-fix-rspack-install-docs

Conversation

@justin808
Copy link
Member

@justin808 justin808 commented Feb 26, 2026

Summary

  • Fix server webpack config generator template to properly handle rspack's cssExtractLoader path when filtering CSS extract loaders for SSR
  • Fix server webpack config to preserve existing CSS modules options (spread instead of overwrite) when adding exportOnlyLocals: true
  • Add documentation to rspack migration guide warning about [contenthash] divergence and providing a getLocalIdent workaround

Details

The generated serverWebpackConfig.js had two bugs when used with rspack:

  1. Missing rspack loader filter: The CSS extract loader filter only matched mini-css-extract-plugin (webpack) but not cssExtractLoader (rspack), leaving CSS extraction in the server bundle
  2. CSS modules options overwritten: cssLoader.options.modules = { exportOnlyLocals: true } destroyed all existing settings like namedExport, localIdentName, and getLocalIdent. Changed to use spread: { ...cssLoader.options.modules, exportOnlyLocals: true }

The documentation adds a new section explaining why [contenthash] in localIdentName causes silent style breakage with rspack SSR, and provides a stable getLocalIdent function as the fix.

Fixes #2488

Test plan

  • Verify the generator produces correct serverWebpackConfig.js with --rspack flag
  • Verify existing webpack (non-rspack) behavior is unchanged
  • Review migration docs render correctly on the documentation site

🤖 Generated with Claude Code


Note

Medium Risk
Touches SSR webpack generator logic and CSS module configuration, which can impact production rendering/styling if the loader filtering or module option merge behaves unexpectedly across bundler versions.

Overview
Fixes the generated serverWebpackConfig.js template for Rspack SSR by filtering out Rspack’s cssExtractLoader (in addition to mini-css-extract-plugin/style-loader) so CSS extraction doesn’t leak into the server bundle.

Adjusts SSR CSS Modules handling to preserve existing css-loader module options when enabling exportOnlyLocals: true (spreading instead of overwriting), and expands the Rspack migration guide with a warning that [contenthash]-based localIdentName can break production SSR styles plus a stable getLocalIdent workaround.

Written by Cursor Bugbot for commit 2714bc0. Configure here.

Summary by CodeRabbit

  • Documentation

    • Expanded migration guidance for CSS Modules with SSR, including Rspack-specific notes, deterministic stable class-name approaches, expanded troubleshooting for production-only style breakage, CSS-in-JS collision and virtual-module considerations, and resourceQuery hashing effects.
  • Configuration

    • Improved server-side CSS handling and filtering to preserve module options and ensure SSR-compatible exports; safer guarded updates to server build config with post-change validation and a new optional parameter for server bundle builds.

…h rspack

The server webpack config template had two issues causing CSS module styles
to silently break in production with rspack + SSR:

1. CSS extract loader filter didn't handle rspack's cssExtractLoader path,
   leaving extraction in the server bundle
2. CSS modules options were overwritten instead of merged, losing settings
   like namedExport, localIdentName, and getLocalIdent

Also adds documentation to the rspack migration guide warning about
contenthash divergence and providing a getLocalIdent workaround.

Fixes #2488

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 26, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds deterministic CSS Modules class-name generation for SSR with Rspack (including resourceQuery in hashing), expands server-side CSS loader/plugin filtering for Rspack paths, preserves existing css-loader modules options while enabling exportOnlyLocals, and relaxes generator insertion logic and tests to handle merged/spread module option forms.

Changes

Cohort / File(s) Summary
Docs
docs/migrating/migrating-from-webpack-to-rspack.md
Adds "CSS Modules with SSR: Stable Class Names" guidance, documents production-only divergence when using contenthash, introduces deterministic getLocalIdent (includes resourceQuery), updates SSR troubleshooting and notes on CSS-in-JS virtual modules and resourceQuery effects.
Server Webpack Template
react_on_rails/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt
Broadened loader/plugin filtering to detect mini-css-extract-plugin, loaders containing cssExtractLoader (Rspack), and style-loader; supports string/object loader entries; preserves and merges existing css-loader.options.modules and sets exportOnlyLocals: true; configureServer gains optional rscBundle parameter.
Generator Insertion Logic
react_on_rails/lib/generators/react_on_rails/pro_setup.rb
Relaxes insertion regex to match legacy and spread/merged cssLoader.options.modules shapes and adds post-insertion validation that warns if the expected babelLoader.options.caller insertion did not occur.
Generator Tests / Helpers
react_on_rails/spec/react_on_rails/support/generator_spec_helper.rb
Strengthens guard to require cssLoader.options.modules presence in test helper; changes helper to merge existing cssLoader.options.modules (guarded spread) and add exportOnlyLocals: true rather than overwriting.

Sequence Diagram(s)

mermaid
sequenceDiagram
participant Dev as Developer
participant Client as Client build (Rspack)
participant Server as Server build (Rspack SSR)
participant CSSLoader as css-loader (getLocalIdent)
participant Extract as CssExtractRspackPlugin / MiniCssExtractPlugin

Dev->>Client: trigger client build with css-loader modules + getLocalIdent
Client->>CSSLoader: request class name (context.resourcePath + resourceQuery + localName)
CSSLoader-->>Client: deterministic class name
Client->>Extract: extract CSS with hashed selectors

Dev->>Server: trigger server build with css-loader modules + exportOnlyLocals + same getLocalIdent
Server->>CSSLoader: request class name (context.resourcePath + resourceQuery + localName)
CSSLoader-->>Server: same deterministic class name (exports only)
Server-->>App: SSR HTML uses matching class names

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I hopped through hashes, added a clue,
I tucked resourceQuery into the brew,
Client and server now sing the same name,
No collisions, no styling shame,
Thump — the stylesheet's true!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title 'Fix CSS module class name divergence with rspack SSR' accurately and specifically describes the main change: addressing CSS module class name collisions in rspack SSR builds.
Linked Issues check ✅ Passed The pull request comprehensively addresses all coding objectives from issue #2488: provides stable getLocalIdent implementation with resourceQuery, updates server webpack config to filter rspack extract loaders while preserving modules options, adds rspack migration documentation, and includes test fixtures with proper merging of css-loader modules configuration.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing CSS module class name divergence: documentation additions for SSR stable class names, server webpack config filtering improvements, test fixture updates, and generation template modifications. No unrelated refactoring or out-of-scope changes detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch jg/2488-fix-rspack-install-docs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
react_on_rails/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt (1)

51-60: Consider unifying CSS loader filtering logic.

There are two loops that filter CSS-related loaders (lines 51-60 and 141-156). The first loop only checks string items and excludes mini-css-extract-plugin and cssExtractLoader, while the second loop also handles object items and additionally excludes style-loader. Consider consolidating or adding style-loader filtering to the first loop for consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@react_on_rails/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt`
around lines 51 - 60, The two filtering loops for CSS loaders are inconsistent;
update the first loop (serverWebpackConfig.module.rules.forEach /
loader.use.filter) to also remove style-loader and handle object-form loader
entries the same way as the second loop: treat items that are strings or objects
with a loader property, and filter out entries where the string or item.loader
matches /mini-css-extract-plugin/, includes('cssExtractLoader') or
includes('style-loader'); alternatively consolidate both loops into a single
helper that performs this unified filter and call it for
serverWebpackConfig.module.rules to keep behavior consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@react_on_rails/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt`:
- Around line 151-155: The code in serverWebpackConfig.js.tt sets testValue
conditionally and then calls testValue.match(...), which can throw if testValue
is undefined; update the logic in the function/block that computes testValue
(the branch handling item as string/object loader) to ensure testValue is always
a string (e.g., initialize testValue = ''), or use safe guarding like optional
chaining before calling match, so the return expression safely checks
mini-css-extract-plugin and cssExtractLoader/style-loader without risking a
TypeError.

---

Nitpick comments:
In
`@react_on_rails/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt`:
- Around line 51-60: The two filtering loops for CSS loaders are inconsistent;
update the first loop (serverWebpackConfig.module.rules.forEach /
loader.use.filter) to also remove style-loader and handle object-form loader
entries the same way as the second loop: treat items that are strings or objects
with a loader property, and filter out entries where the string or item.loader
matches /mini-css-extract-plugin/, includes('cssExtractLoader') or
includes('style-loader'); alternatively consolidate both loops into a single
helper that performs this unified filter and call it for
serverWebpackConfig.module.rules to keep behavior consistent.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8c5f4d4 and 2714bc0.

📒 Files selected for processing (2)
  • docs/migrating/migrating-from-webpack-to-rspack.md
  • react_on_rails/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt

@greptile-apps
Copy link

greptile-apps bot commented Feb 26, 2026

Greptile Summary

This PR fixes two bugs in the generated serverWebpackConfig.js template that caused CSS module breakage when using Rspack for SSR:

  • Rspack CSS extract loader filtering: The template now filters both mini-css-extract-plugin (Webpack) and cssExtractLoader (Rspack) paths in two separate filter locations, preventing CSS extraction from leaking into the server bundle.
  • CSS modules options preserved via spread: Changed from overwriting cssLoader.options.modules to spreading existing options before adding exportOnlyLocals: true, preserving settings like namedExport, localIdentName, and getLocalIdent.
  • Documentation: Adds a new migration guide section explaining why [contenthash] in localIdentName causes silent style breakage with Rspack SSR, with a getLocalIdent workaround and a troubleshooting entry.

The template changes are minimal and well-targeted. One documentation inconsistency was found: the "correct" code example for preserving CSS modules config uses a stricter guard than the actual generated template.

Confidence Score: 4/5

  • This PR is safe to merge — the template and documentation changes are correct and address real SSR bugs with Rspack.
  • Score of 4: The two template fixes are straightforward and correct. The spread operator change and the additional cssExtractLoader filter are low-risk, targeted changes. The documentation is mostly good but has a minor inconsistency between the example and the actual template behavior. No test changes but the template changes are simple enough that the existing generator specs provide adequate coverage.
  • docs/migrating/migrating-from-webpack-to-rspack.md — the "correct" code example on line 194 has a stricter guard than the actual generated template, which could confuse users who copy the doc example.

Important Files Changed

Filename Overview
react_on_rails/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt Adds rspack cssExtractLoader filtering in two places and switches from overwriting to spreading CSS modules options. Both changes are correct and targeted. Minor pre-existing risk: testValue can be undefined if a loader item is an object without a loader property.
docs/migrating/migrating-from-webpack-to-rspack.md Adds documentation for CSS class name divergence with [contenthash] and a getLocalIdent workaround. The "correct" code example in the preserve section has a stricter guard than the actual template, which could mislead users.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[serverWebpackConfig.js.tt<br>configureServer] --> B[First filter pass<br>Lines 51-60]
    B --> |"Filter string loaders"| B1{Is loader string?}
    B1 --> |Yes| B2{Matches mini-css-extract-plugin<br>OR cssExtractLoader?}
    B2 --> |Yes| B3[Remove loader]
    B2 --> |No| B4[Keep loader]
    B1 --> |No| B4

    A --> C[Second filter pass<br>Lines 141-156]
    C --> |"Filter string & object loaders"| C1{Get testValue from<br>item or item.loader}
    C1 --> C2{Matches mini-css-extract-plugin<br>OR cssExtractLoader<br>OR style-loader?}
    C2 --> |Yes| C3[Remove loader]
    C2 --> |No| C4[Keep loader]

    C --> D[CSS modules config<br>Lines 168-173]
    D --> D1{cssLoader &&<br>cssLoader.options?}
    D1 --> |Yes| D2["Spread existing modules opts<br>+ exportOnlyLocals: true"]
    D1 --> |No| D3[Skip]

    style B3 fill:#f66,color:#fff
    style C3 fill:#f66,color:#fff
    style D2 fill:#6f6,color:#000
Loading

Last reviewed commit: 2714bc0

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

2 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

@greptile-apps
Copy link

greptile-apps bot commented Feb 26, 2026

Additional Comments (1)

docs/migrating/migrating-from-webpack-to-rspack.md
Doc example diverges from template

The "correct" example here adds an extra guard (&& cssLoader.options.modules) that the actual generated template at serverWebpackConfig.js.tt:168 does not use. The template checks only if (cssLoader && cssLoader.options), which means it will set modules even when it wasn't previously configured — that's arguably the right behavior for SSR (ensuring exportOnlyLocals always gets set). However, a user copying this doc example would skip the assignment when modules is falsy, potentially missing the exportOnlyLocals: true setting on rules where modules weren't explicitly configured.

Consider aligning the doc with the template:

if (cssLoader && cssLoader.options) {
  cssLoader.options.modules = {
    ...cssLoader.options.modules, // Preserve namedExport: false
    exportOnlyLocals: true,
  };
}

@claude
Copy link

claude bot commented Feb 26, 2026

test comment

@claude
Copy link

claude bot commented Feb 26, 2026

PR Review: Fix CSS module class name divergence with rspack SSR

The two template bug fixes are correct and necessary. The documentation additions are clear and well-structured. A few issues worth addressing:

Issues

1. Potential TypeError in existing filter logic (not introduced here, but worth noting)

In the second rule.use.filter() callback (the one that also handles style-loader), testValue can be undefined if a loader item is neither a bare string nor an object with a string .loader property. Both the old testValue.match(...) and the new testValue.includes(...) will throw TypeError: Cannot read properties of undefined. The docs example wisely uses testValue?.match(...) and testValue?.includes(...) with optional chaining -- the template should match that to be safe.

2. Guard condition gap in CSS modules spread fix

The guard in the template is if (cssLoader and cssLoader.options), but it does not check whether cssLoader.options.modules is actually truthy. When CSS modules are disabled via modules: false, the spread produces { exportOnlyLocals: true }, which could unexpectedly re-enable CSS modules for loaders that had them disabled. The docs example correctly uses the stricter guard: if (cssLoader and cssLoader.options and cssLoader.options.modules).

3. Hash collision risk in the getLocalIdent example

.digest(base64url).slice(0, 3) is only 18 bits of entropy (64^3 = 262,144 values). With a large application CSS module classes, birthday-paradox collisions become likely (~2% with 100 classes, ~40% with 500 classes). A collision means two different CSS classes silently share the same generated identifier - the exact silent breakage this PR is trying to prevent. Increasing to .slice(0, 5) (30 bits, ~1 billion values) would make collisions practically impossible.

4. No separator in hash input

relativePath + localName concatenates without a separator. The string for path foo/bar plus class Baz produces the same bytes as path foo/barB plus class az. Adding a null-byte separator eliminates this theoretical edge case.

5. Hardcoded directory depth for project root

path.resolve(__dirname, ../..) assumes getLocalIdent.js will always live exactly 2 levels deep from the project root (i.e., config/webpack/). Apps with non-standard config layouts will silently compute wrong relative paths, producing different hashes between client and server. A comment noting this assumption would help users who copy the snippet.

Summary

  • The template fixes (rspack loader filter + CSS modules spread) are correct.
  • The docs are helpful and well-organized.
  • The getLocalIdent example needs a larger hash slice and a separator to be production-safe.
  • The guard condition in the template should match the stricter version shown in the docs.

.createHash('md5')
.update(relativePath + localName)
.digest('base64url')
.slice(0, 3);
Copy link

Choose a reason for hiding this comment

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

Hash collision risk: .slice(0, 3) gives only 18 bits of entropy (262,144 possible values). With ~500 CSS module classes across a project, birthday-paradox collision probability approaches 40%, causing different class names to silently share the same generated identifier. Consider .slice(0, 5) (30 bits, ~1 billion values) which makes collisions negligible in practice.

Copy link
Member Author

Choose a reason for hiding this comment

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

Addressed in 44b2642 — increased hash to .slice(0, 8) (48 bits of entropy) which makes collisions negligible even for large projects.


const hash = crypto
.createHash('md5')
.update(relativePath + localName)
Copy link

Choose a reason for hiding this comment

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

No separator in hash input: relativePath + localName concatenates without a separator. The string for path "foo/bar" + class "Baz" produces the same bytes as path "foo/barB" + class "az". Adding a null-byte separator — e.g., relativePath + '\0' + localName — eliminates this theoretical collision edge case.

Copy link
Member Author

Choose a reason for hiding this comment

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

Addressed in 44b2642 — added null-byte separators between all hash input components: relativePath + '\0' + resourceQuery + '\0' + localName.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is ON, but it could not run because Privacy Mode (Legacy) is turned on. To enable Bugbot Autofix, switch your privacy mode in the Cursor dashboard.

cssLoader.options.modules = {
...cssLoader.options.modules, // Preserve existing settings (namedExport, localIdentName, getLocalIdent, etc.)
exportOnlyLocals: true,
};
Copy link

Choose a reason for hiding this comment

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

Template change breaks Pro upgrade regex pattern match

High Severity

The template now generates a multi-line spread syntax for cssLoader.options.modules, but pro_setup.rb's add_babel_ssr_caller_to_server_config still uses a regex matching the old single-line pattern cssLoader.options.modules = { exportOnlyLocals: true };. When a user generates a non-Pro config and later runs the standalone Pro upgrade, the regex silently fails to match, so the Babel SSR caller code is never inserted. The verify_pro_webpack_transforms method doesn't check for this transform either, making the failure completely silent.

Fix in Cursor Fix in Web

Copy link
Member Author

Choose a reason for hiding this comment

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

Addressed across multiple commits:

  1. 74d5cd6: Fixed regex to use [\s\S]*? (lazy multiline) instead of [^}]*, handling nested braces correctly
  2. 99d30fe: Hardened the template with typeof cssLoader.options.modules === 'object' guard
  3. Added post-insertion verification with say_status :warning fallback so failures are no longer silent

@@ -159,7 +166,10 @@ const configureServer = () => {
return testValue.includes('css-loader');
});
if (cssLoader && cssLoader.options) {
Copy link

Choose a reason for hiding this comment

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

Guard condition gap: This guard checks cssLoader.options but not cssLoader.options.modules. When CSS modules are disabled via modules: false, the spread { ...false, exportOnlyLocals: true } evaluates to { exportOnlyLocals: true }, which could unexpectedly re-enable CSS modules for loaders that had them explicitly disabled. The docs example uses the stricter guard: if (cssLoader && cssLoader.options && cssLoader.options.modules) — the template should match.

Copy link
Member Author

Choose a reason for hiding this comment

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

Addressed in 99d30fe — the template now uses typeof cssLoader.options.modules === 'object' ? cssLoader.options.modules : {} in the spread, which safely handles false, true, and string values without re-enabling modules when disabled.

if (cssLoader && cssLoader.options) {
cssLoader.options.modules = { exportOnlyLocals: true };
cssLoader.options.modules = {
...cssLoader.options.modules, // Preserve existing settings (namedExport, localIdentName, getLocalIdent, etc.)
Copy link

Choose a reason for hiding this comment

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

This spread is correct for the common case, but if cssLoader.options.modules is false (CSS modules explicitly disabled), { ...false } spreads to {}, and the result { exportOnlyLocals: true } could unexpectedly re-enable CSS modules. The guard on line 168 should also check cssLoader.options.modules is truthy before spreading, consistent with the docs example.

Copy link
Member Author

Choose a reason for hiding this comment

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

Addressed in 99d30fe — the spread now uses typeof cssLoader.options.modules === 'object' ? cssLoader.options.modules : {} which correctly handles false, true, and string values.

- Initialize testValue to '' to prevent TypeError on unexpected loader types
- Add cssLoader.options.modules truthiness guard to avoid re-enabling CSS
  modules when explicitly disabled via modules: false
- Update pro_setup.rb regex to match both old single-line and new spread
  syntax for cssLoader.options.modules
- Update generator test fixture to match new template output
- Fix getLocalIdent example: increase hash from 3 to 5 chars to reduce
  collision risk, add null separator between path and name, add comment
  about directory depth assumption

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link

claude bot commented Feb 27, 2026

PR Review: Fix CSS module class name divergence with rspack SSR

Good fixes overall — the spread-instead-of-overwrite change for cssLoader.options.modules is the right fix for the most impactful bug, and adding rspack's cssExtractLoader to the filter list is clearly correct.

A few issues worth addressing before merging:

Critical: Behavior Regression in serverWebpackConfig.js.tt

The condition changed from if (cssLoader && cssLoader.options) to if (cssLoader && cssLoader.options && cssLoader.options.modules). This silently breaks users who have cssLoader.options without an explicit modules: key. css-loader detects CSS Modules automatically via filename pattern (*.module.css) — many projects never write modules: { ... } explicitly but still rely on SSR exporting only locals. In those cases, exportOnlyLocals: true will no longer be applied to the server bundle.

Inconsistency Between Two Filter Blocks

The first filter block (~line 53 of the template) only removes loaders when the item is a plain string. If rspack passes cssExtractLoader as an object ({ loader: '...cssExtractLoader.js' }), it slips through. The second filter block correctly handles both strings and objects — the first block should too.

Regex Fragility in pro_setup.rb

The [^}]* pattern in the updated regex stops at the first } character. If a user has customized their serverWebpackConfig.js with an inline getLocalIdent function or any nested braces inside the modules object, the regex won't match and Babel SSR caller insertion will silently fail.

Hash Collision Risk in getLocalIdent Snippet

Truncating to 5 characters of base64url gives ~30 bits of entropy. Birthday probability of at least one collision reaches ~5% with 10,000 CSS class names — non-trivial for larger apps. 8 characters (48 bits) drops this to ~0.001%.

Invalid CSS Class Names from Digit-Leading File Names

The getLocalIdent function builds the class name as name-localName_hash. If a CSS file starts with a digit (e.g. 123-card.module.css), the generated class name starts with a digit, which is invalid CSS and causes silent style breakage. A prefix letter or guard would make the function safe for all file names.


See inline comments for specific line references.

});
if (cssLoader && cssLoader.options) {
cssLoader.options.modules = { exportOnlyLocals: true };
if (cssLoader && cssLoader.options && cssLoader.options.modules) {
Copy link

Choose a reason for hiding this comment

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

Behavior regression: Adding && cssLoader.options.modules to the guard means exportOnlyLocals: true is never set when a project uses css-loader's automatic CSS Modules detection (via *.module.css filename pattern) without an explicit modules: { ... } options object.

The original code correctly applied exportOnlyLocals: true whenever a CSS loader with options was found. The spread can be made safe without the extra guard:

Suggested change
if (cssLoader && cssLoader.options && cssLoader.options.modules) {
if (cssLoader && cssLoader.options) {
cssLoader.options.modules = {
...(typeof cssLoader.options.modules === 'object' ? cssLoader.options.modules : {}),
exportOnlyLocals: true,
};

This preserves existing module options when they're an object, while still applying exportOnlyLocals: true even when modules wasn't previously configured.

Copy link
Member Author

Choose a reason for hiding this comment

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

Addressed in 99d30fe — adopted exactly this approach. The guard checks cssLoader.options.modules (truthy), and the spread uses typeof cssLoader.options.modules === 'object' ? cssLoader.options.modules : {} to safely handle all value types while still applying exportOnlyLocals: true when modules is enabled.

if (loader.use && loader.use.filter) {
loader.use = loader.use.filter(
(item) => !(typeof item === 'string' && item.match(/mini-css-extract-plugin/)),
(item) => !(typeof item === 'string' && (
Copy link

Choose a reason for hiding this comment

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

This first filter block only removes loaders when the item is a plain string. If rspack passes cssExtractLoader as an object { loader: '...cssExtractLoader.js' }, it will slip through here. The second filter block at ~line 144 correctly handles both strings and objects — this one should do the same for consistency:

Suggested change
(item) => !(typeof item === 'string' && (
loader.use = loader.use.filter((item) => {
let testValue = '';
if (typeof item === 'string') {
testValue = item;
} else if (item && typeof item.loader === 'string') {
testValue = item.loader;
}
return !(
testValue.match(/mini-css-extract-plugin/) ||
testValue.includes('cssExtractLoader')
);

Copy link
Member Author

Choose a reason for hiding this comment

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

Addressed in 99d30fe — both filter blocks now handle string and object loader forms consistently. The first block (line 53-65) initializes let testValue = '' and checks typeof item.loader === 'string'.

gsub_file(
webpack_config,
/(cssLoader\.options\.modules = \{ exportOnlyLocals: true \};\s*\n\s*\})/,
/(cssLoader\.options\.modules = \{[^}]*exportOnlyLocals: true[^}]*\};\s*\n\s*\})/,
Copy link

Choose a reason for hiding this comment

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

The [^}]* pattern stops at the first } character, so it won't match if the modules object contains nested braces (e.g., a user who has added an inline getLocalIdent function or any other nested value). In that case, gsub_file silently does nothing and the Babel SSR caller is never inserted.

Consider using a lazy multiline match with a line-counting approach, or matching the entire block with a known structure. At minimum, add a post-call check that the expected string was actually inserted — currently there's no way to know if this substitution succeeded or was a no-op:

        original = File.read(File.join(destination_root, webpack_config))
        gsub_file(
          webpack_config,
          /(cssLoader\.options\.modules = \{[^}]*exportOnlyLocals: true[^}]*\};\s*\n\s*\})/m,
          "\\1#{babel_ssr_code}"
        )
        new_content = File.read(File.join(destination_root, webpack_config))
        unless new_content.include?("babelLoader.options.caller")
          say_status :warning, "Could not insert Babel SSR caller — manual edit of #{webpack_config} required", :yellow
        end

Note the /m flag (Regexp::MULTILINE) which makes . match newlines and allows the pattern to span lines. Though [^}] already matches newlines in Ruby, making intent explicit helps future maintainers.

Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed in 74d5cd6. Changes made:

  1. Regex: Replaced [^}]* with [\s\S]*? (lazy multiline) so the pattern correctly handles nested braces (e.g., inline getLocalIdent functions)
  2. /m flag: Added Regexp::MULTILINE for explicit intent
  3. Post-call check: Added guard clause that warns if the substitution was a no-op, so users know manual editing is needed

RuboCop preferred guard clause style (return if ... include?) over the unless...end block, which works well here.

Copy link
Member Author

Choose a reason for hiding this comment

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

Addressed in 74d5cd6 — regex updated to [\s\S]*? with post-insertion verification and say_status :warning fallback. Also removed the unnecessary /m flag in 8f9b793 per later review feedback.

.createHash('md5')
.update(`${relativePath}\0${localName}`) // Null separator prevents path/name collisions
.digest('base64url')
.slice(0, 5);
Copy link

Choose a reason for hiding this comment

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

5 characters of base64url encoding gives 30 bits of entropy (~1 billion combinations). The birthday-attack collision probability climbs to roughly 5% once a project accumulates ~10,000 distinct CSS class names — a realistic scale for a large production app.

Suggested change
.slice(0, 5);
.slice(0, 8);

8 characters (48 bits) brings the same 10,000-class collision probability down to ~0.001%.

Copy link
Member Author

Choose a reason for hiding this comment

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

Addressed in 44b2642 — increased to .slice(0, 8) (48 bits) as suggested, bringing collision probability down to ~0.001% even at 10,000 classes.

Copy link
Member Author

Choose a reason for hiding this comment

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

Addressed in 44b2642 — increased to .slice(0, 8) (48 bits) as suggested.

justin808 and others added 4 commits February 26, 2026 15:09
Update getLocalIdent to include context.resourceQuery in the hash,
which differentiates virtual CSS modules created by CSS-in-JS tools
like astroturf via matchResource (!=!) syntax. In rspack, resourcePath
points to the source file rather than the virtual path, so without the
query string all virtual modules from the same file collide silently.

Add new section explaining the CSS-in-JS virtual module collision issue
and a troubleshooting entry with detection commands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add null-byte separators between hash input components to prevent
  ambiguous concatenation (e.g., path "foo/bar" + class "Baz" vs
  path "foo/barB" + class "az")
- Increase hash slice from 5 to 8 characters (48 bits of entropy)
  to keep collision probability negligible even for large projects
  with ~10,000+ CSS class names

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The [^}]* pattern stopped at the first } character, failing silently
when the modules object contained nested braces (e.g., an inline
getLocalIdent function). Switch to [\s\S]*? lazy multiline matching
and add a post-call check that warns if the substitution was a no-op.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@justin808 justin808 force-pushed the jg/2488-fix-rspack-install-docs branch from 91a2a83 to 74d5cd6 Compare February 27, 2026 01:12
@claude
Copy link

claude bot commented Feb 27, 2026

PR Review: Fix CSS module class name divergence with rspack SSR

This PR correctly addresses two real bugs in rspack SSR support. The fixes are well-targeted.

Positive Highlights

  • Correct spread merge for CSS modules options — preserving namedExport, localIdentName, and getLocalIdent instead of overwriting them is the right approach.
  • Both filter loops updated — the cssExtractLoader check was added to both the early filter (lines 51–59) and the comprehensive rule loop (lines 140–156), keeping them consistent.
  • Documentation is thorough — the new sections on [contenthash] divergence and CSS-in-JS virtual module collisions clearly explain the root cause and provide actionable solutions.
  • Test fixture kept in sync with the generator template.

Issues Found

Medium: Unsafe spread when modules is a non-object truthy value (serverWebpackConfig.js.tt)

The guard cssLoader.options.modules passes for any truthy value, including true (modules enabled with defaults) or string modes like 'local', 'pure'. When modules is a string, { ...cssLoader.options.modules } spreads the string's character indices ({0:'l',1:'o',...}) — silently destroying the config.

A safer guard:

if (cssLoader && cssLoader.options && cssLoader.options.modules && typeof cssLoader.options.modules === 'object') {

Minor: grep -oP (Perl regex) not available on macOS (migrating-from-webpack-to-rspack.md)

The collision detection snippet uses grep -oP, which requires Perl regex support. macOS ships BSD grep, which doesn't support -P. Developers on macOS will see grep: illegal option -- P. Worth adding a note to use ggrep (Homebrew grep) on macOS.

Minor: Collision-detection pattern is project-specific

The grep pattern '\.[A-Za-z]+-cls2_[A-Za-z0-9_-]+' hardcodes cls2, which appears to be a project-specific naming artifact. A more generic alternative that works without knowing the name prefix in advance would be more useful in a general migration guide.

});
if (cssLoader && cssLoader.options) {
cssLoader.options.modules = { exportOnlyLocals: true };
if (cssLoader && cssLoader.options && cssLoader.options.modules) {
Copy link

Choose a reason for hiding this comment

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

The cssLoader.options.modules guard is truthy for any non-falsy value — including true (modules with defaults) and string modes like 'local' or 'pure'. When modules is a string, { ...cssLoader.options.modules } spreads its character indices ({0:'l', 1:'o', ...}) and silently destroys the config.

Consider:

Suggested change
if (cssLoader && cssLoader.options && cssLoader.options.modules) {
if (cssLoader && cssLoader.options && cssLoader.options.modules && typeof cssLoader.options.modules === 'object') {

This ensures the spread only runs when there's an actual options object to merge into.

Copy link
Member Author

Choose a reason for hiding this comment

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

Addressed in 99d30fe — the spread now uses typeof cssLoader.options.modules === 'object' ? cssLoader.options.modules : {} which is functionally equivalent and handles string values like 'local' safely without needing an extra guard clause.

@claude
Copy link

claude bot commented Feb 27, 2026

PR Review: Fix CSS module class name divergence with rspack SSR

Overall this is a solid, well-motivated fix. The two core code changes (loader filtering and options spread) are correct and the documentation is thorough. A few issues worth addressing:

Bug: collision-detection grep pattern does not match getLocalIdent output

The detection command uses the pattern -cls2_ which does not match the naming format produced by the getLocalIdent function documented in the same PR (which generates name-localName_hash). The cls2 fragment appears to be a project-specific artifact, not a generic pattern. Readers following the guide would get zero results even with real collisions.

grep -oP is GNU-only, fails on macOS

The -P flag (Perl-compatible regex) requires GNU grep. macOS ships with BSD grep which does not support -P, so the command errors out for macOS users. Either use -E with a POSIX ERE pattern, or add a note that this requires GNU grep (ggrep on macOS via Homebrew).

pro_setup.rb regex fragility for customised configs

The updated regex [^}]* matches correctly for the generated template output, but will silently fail to match if a user has already customised their cssLoader.options.modules to include nested braces -- for example a getLocalIdent arrow function defined inline inside the modules object. The first } inside the arrow function body terminates the [^}]* match, causing the regex not to find the block and the Babel SSR caller transform to be silently skipped. The existing verify_pro_webpack_transforms warning would surface this, but it could still confuse users.

Minor: first loader-filter block has an inconsistent string-only guard

Lines 51-59 of the template only filter typeof item === 'string' loaders (pre-existing behaviour for mini-css-extract-plugin, kept consistent here). The second, more complete pass at lines 142-156 handles both strings and objects correctly, so in practice this should not cause problems, but it is a latent inconsistency worth cleaning up.


The core bug fixes are correct and well-tested. The documentation improvements are a net positive even with the issues noted above.


**Cause:** Rspack sets `context.resourcePath` to the actual source file instead of the virtual matchResource path. All virtual CSS modules from the same source file produce the same hash.

**Solution:** Include `context.resourceQuery` in the `getLocalIdent` hash. See the [CSS-in-JS Virtual Module Collisions](#css-in-js-virtual-module-collisions-astroturf-etc) section above.
Copy link

Choose a reason for hiding this comment

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

Broken anchor link: #css-in-js-virtual-module-collisions-astroturf-etc doesn't exist in this document. There is no section heading matching that slug. Based on the intent, this should point to #css-modules-with-ssr-stable-class-names (the getLocalIdent code example section) or remain self-referential to the troubleshooting entry that already explains it.

Suggested change
**Solution:** Include `context.resourceQuery` in the `getLocalIdent` hash. See the [CSS-in-JS Virtual Module Collisions](#css-in-js-virtual-module-collisions-astroturf-etc) section above.
**Solution:** Include `context.resourceQuery` in the `getLocalIdent` hash. See the [CSS Modules with SSR: Stable Class Names](#css-modules-with-ssr-stable-class-names) section above.

Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed in 8f9b793 — updated the anchor to #css-in-js-styled-components-all-share-the-same-class-name which matches the actual heading.

});
if (cssLoader && cssLoader.options) {
cssLoader.options.modules = { exportOnlyLocals: true };
if (cssLoader && cssLoader.options && cssLoader.options.modules) {
Copy link

Choose a reason for hiding this comment

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

Edge case: if cssLoader.options.modules is a truthy non-object value (e.g., the string 'local' or 'global'), spreading it will produce incorrect results. { ...'local' } yields { 0: 'l', 1: 'o', 2: 'c', 3: 'a', 4: 'l' } rather than an options object.

While modules: 'local' is uncommon in practice, adding a typeof guard makes the intent explicit and prevents silent breakage:

Suggested change
if (cssLoader && cssLoader.options && cssLoader.options.modules) {
if (cssLoader && cssLoader.options && cssLoader.options.modules && typeof cssLoader.options.modules === 'object') {

Copy link
Member Author

Choose a reason for hiding this comment

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

Addressed in 99d30fe — the spread uses typeof cssLoader.options.modules === 'object' ? cssLoader.options.modules : {} which safely handles string values like 'local' by spreading {} instead.

@@ -51,7 +51,10 @@ const configureServer = () => {
serverWebpackConfig.module.rules.forEach((loader) => {
if (loader.use && loader.use.filter) {
loader.use = loader.use.filter(
Copy link

Choose a reason for hiding this comment

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

This first filter loop (lines 51–60) only tests typeof item === 'string', so it silently skips object-form loader entries like { loader: '...cssExtractLoader...', options: {} }. The second, more complete filter further down (lines 144–156) handles both string and object loaders and is the one that actually does the heavy lifting.

This asymmetry is pre-existing, but since the cssExtractLoader addition targets Rspack and rspack may express that loader as an object (depends on shakapacker's config generation), it's worth verifying empirically that the first loop doesn't need the same object-form check. If it's always a string in rspack's rule set, a comment saying so would prevent future confusion.

Copy link
Member Author

Choose a reason for hiding this comment

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

Addressed in 99d30fe — the first filter block now handles both string and object loader forms, matching the second block's behavior. Both initialize let testValue = '' and check for object loaders.

@claude
Copy link

claude bot commented Feb 27, 2026

Review: Fix CSS module class name divergence with rspack SSR

Overall this is a solid, well-motivated fix. The core logic changes are correct and the documentation additions are genuinely useful. A few things to address:

Bugs / Correctness

1. Broken anchor link in docs (inline comment) — The troubleshooting entry at line 311 references #css-in-js-virtual-module-collisions-astroturf-etc which does not exist in the document. The note at line 185 correctly points to #css-in-js-styled-components-all-share-the-same-class-name, but the troubleshooting solution paragraph uses the wrong slug. See inline comment.

2. Spread of non-object modules value (inline comment) — The new spread in the template assumes cssLoader.options.modules is an object. If it is a truthy string ('local', 'global', 'icss'), { ...modules } will splat the string's indexed characters into the options object rather than preserving the intent. Adding typeof cssLoader.options.modules === 'object' to the guard eliminates the silent failure path. See inline comment.

Minor observations

3. Asymmetric filter loops (inline comment) — There are two separate filter passes over module.rules: the first (lines 51–60) only tests string-typed items; the second (lines 144–156) tests both strings and objects. Adding cssExtractLoader only to the string path of the first loop is consistent with the existing pattern, but if rspack ever surfaces that loader as an object entry in shakapacker's generated config, the first pass will silently miss it. Worth a quick empirical check or a comment asserting it's always a string.

4. MD5 in the getLocalIdent example — MD5 is fine for a non-security hash (class name stability), but Node.js docs mark it as legacy and it is not available in all FIPS environments. sha256 is an easy drop-in and would future-proof the snippet. Not a blocker.

5. pro_setup.rb regex correctness — The updated lazy multiline regex /(cssLoader\.options\.modules = \{[\s\S]*?exportOnlyLocals: true[\s\S]*?\};\s*\n\s*\})/m looks correct for the current template shape, and the post-check with say_status :warning is a good safety net. One thing to verify: if a user's getLocalIdent inline function itself contains the string exportOnlyLocals: true (unlikely but possible), the lazy match would stop early at a nested occurrence. This is extremely unlikely in practice, but worth being aware of.

6. Documentation code snippet uses direct mutation — The commonWebpackConfig.js snippet sets cssLoader.options.modules.namedExport = false by direct mutation immediately after explaining spread-is-correct. It's consistent (that section is about client config, not server), but a brief comment noting the difference in context would help readers not copy the mutation pattern into their server config.

Summary

The two bugs (broken anchor and spread-type guard) are worth fixing before merge. The rest are minor polish items.

@claude
Copy link

claude bot commented Feb 27, 2026

Review: Fix CSS module class name divergence with rspack SSR

The fixes in this PR are correct and address real bugs. Two issues worth addressing before merge:

Bug: Broken internal anchor link (docs)

In the troubleshooting section, line 311 links to #css-in-js-virtual-module-collisions-astroturf-etc but no heading with that slug exists in the document. The actual heading at line 301 is ### CSS-in-JS Styled Components All Share the Same Class Name, whose generated anchor is #css-in-js-styled-components-all-share-the-same-class-name. The link silently goes nowhere.

Edge case: modules: true (boolean) loses its value after spread

When a user sets css-loader options as { modules: true }, the new spread:

cssLoader.options.modules = { ...true, exportOnlyLocals: true };
// → { exportOnlyLocals: true }   (the boolean 'true' is lost)

JavaScript silently drops the primitive — spreading a boolean gives {}. In practice { exportOnlyLocals: true } is still enough to enable CSS Modules on the server, so there is no functional breakage, but it is worth a defensive guard or a comment so the next reader isn't surprised.

Minor style nit

In both filter loops, the existing testValue.match(/mini-css-extract-plugin/) could be written as testValue.includes('mini-css-extract-plugin') to be consistent with the new testValue.includes('cssExtractLoader') on the very next line.


The core changes (filtering cssExtractLoader, spread-preserving CSS modules options, updated regex in pro_setup.rb, documentation additions) are all correct and well-reasoned.


**Cause:** Rspack sets `context.resourcePath` to the actual source file instead of the virtual matchResource path. All virtual CSS modules from the same source file produce the same hash.

**Solution:** Include `context.resourceQuery` in the `getLocalIdent` hash. See the [CSS-in-JS Virtual Module Collisions](#css-in-js-virtual-module-collisions-astroturf-etc) section above.
Copy link

Choose a reason for hiding this comment

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

Broken anchor — #css-in-js-virtual-module-collisions-astroturf-etc does not exist in this document. The heading that covers this topic is ### CSS-in-JS Styled Components All Share the Same Class Name (a few lines above), which generates the anchor #css-in-js-styled-components-all-share-the-same-class-name. The link silently goes nowhere.

Suggested change
**Solution:** Include `context.resourceQuery` in the `getLocalIdent` hash. See the [CSS-in-JS Virtual Module Collisions](#css-in-js-virtual-module-collisions-astroturf-etc) section above.
**Solution:** Include `context.resourceQuery` in the `getLocalIdent` hash. See the [CSS-in-JS Styled Components All Share the Same Class Name](#css-in-js-styled-components-all-share-the-same-class-name) section above.

Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed in 8f9b793 — anchor corrected to #css-in-js-styled-components-all-share-the-same-class-name.

});
if (cssLoader && cssLoader.options) {
cssLoader.options.modules = { exportOnlyLocals: true };
if (cssLoader && cssLoader.options && cssLoader.options.modules) {
Copy link

Choose a reason for hiding this comment

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

When cssLoader.options.modules is a boolean true (the shorthand for enabling CSS Modules with all defaults), the spread silently drops it: { ...true, exportOnlyLocals: true }{ exportOnlyLocals: true }. Functionally fine for SSR since exportOnlyLocals implies modules are active, but worth an explicit guard so the intent is clear to future readers:

Suggested change
if (cssLoader && cssLoader.options && cssLoader.options.modules) {
if (cssLoader && cssLoader.options && cssLoader.options.modules) {
// When modules is a plain boolean `true`, spreading it gives {} — that's fine
// because { exportOnlyLocals: true } still enables CSS Modules on the server.
cssLoader.options.modules = {

Copy link
Member Author

Choose a reason for hiding this comment

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

Addressed in 99d30fe — the typeof check in the spread (typeof cssLoader.options.modules === 'object' ? cssLoader.options.modules : {}) handles true correctly by spreading {}, and { exportOnlyLocals: true } correctly enables CSS Modules for SSR.

}
return !(testValue.match(/mini-css-extract-plugin/) || testValue === 'style-loader');
return !(
testValue.match(/mini-css-extract-plugin/) ||
Copy link

Choose a reason for hiding this comment

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

Minor style nit: testValue.match(/mini-css-extract-plugin/) is inconsistent with the new testValue.includes('cssExtractLoader') on the very next line. Consider making them uniform:

Suggested change
testValue.match(/mini-css-extract-plugin/) ||
testValue.includes('mini-css-extract-plugin') ||
testValue.includes('cssExtractLoader') || // Rspack uses this path

Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed in 8f9b793 — both filter blocks now use .includes() consistently instead of mixing .match() and .includes().

# and new spread syntax patterns). Use [\s\S]*? for lazy multiline matching
# to support nested braces (e.g., inline getLocalIdent functions).
gsub_file(
webpack_config,
Copy link

Choose a reason for hiding this comment

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

The updated regex uses [\s\S]*? (lazy dotall) which handles multiline and nested-brace content — a clear improvement over the old [^}]* approach. The subsequent verification step and explicit say_status :warning fallback also give users a clear signal when the transform doesn't apply, which is exactly the right approach for this kind of code-surgery pattern.

One minor note: the /m flag changes ^/$ to match line boundaries, but since the pattern uses [\s\S]*? (not .) the flag doesn't actually change the matching behaviour here. It's harmless, but slightly misleading — could be removed without any functional change.

Copy link
Member Author

Choose a reason for hiding this comment

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

Good catch — removed the unnecessary /m flag in 8f9b793 since [\s\S]*? already handles multiline matching without it.

if (loader.use && loader.use.filter) {
loader.use = loader.use.filter(
(item) => !(typeof item === 'string' && item.match(/mini-css-extract-plugin/)),
(item) => !(typeof item === 'string' && (
Copy link

Choose a reason for hiding this comment

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

This first filter block (lines 51–59) keeps the pre-existing typeof item === 'string' guard, so it only removes string-typed loader references. If rspack's cssExtractLoader ever appears as an object { loader: 'path/to/cssExtractLoader.js' }, it would slip through here.

The second, more comprehensive pass at lines 142–156 already handles both string and object forms correctly, so there's no functional regression. But having two passes with different coverage for the same loader is a latent inconsistency — worth unifying at some point, even if it's pre-existing.

Copy link
Member Author

Choose a reason for hiding this comment

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

Addressed in 99d30fe — unified both filter blocks to handle string and object loader forms consistently.

});
if (cssLoader && cssLoader.options) {
cssLoader.options.modules = { exportOnlyLocals: true };
if (cssLoader && cssLoader.options && cssLoader.options.modules) {
Copy link

Choose a reason for hiding this comment

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

The tightened guard (cssLoader.options.modules truthy rather than just cssLoader.options) is the right call — it correctly skips re-enabling CSS modules when they're explicitly disabled via modules: false.

One edge case worth documenting: if cssLoader.options.modules === true (boolean, enabling CSS modules with defaults), spreading it produces { ...true, exportOnlyLocals: true }{ exportOnlyLocals: true }. This is functionally correct (module defaults still apply), but the type change from true to an object is a subtle mutation that could confuse someone reading the resulting config. A comment here would help.

Copy link
Member Author

Choose a reason for hiding this comment

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

Addressed in 99d30fe — the guard checks cssLoader.options.modules (truthy), and the typeof check in the spread makes the boolean true behavior explicit and safe. The code is self-documenting with the ternary.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/migrating/migrating-from-webpack-to-rspack.md`:
- Line 311: The in-page anchor fragment
"#css-in-js-virtual-module-collisions-astroturf-etc" is dead; update the link in
the sentence "See the [CSS-in-JS Virtual Module
Collisions](`#css-in-js-virtual-module-collisions-astroturf-etc`) section above."
to point to the actual heading slug used in this document (or rename the target
heading to match this slug). Locate the link text and either replace the
fragment with the correct heading slug for the "CSS-in-JS Virtual Module
Collisions" section or adjust that section's heading so its generated anchor
matches this fragment (ensure the string in the link and the heading slug are
identical).

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5247b9f and 74d5cd6.

📒 Files selected for processing (2)
  • docs/migrating/migrating-from-webpack-to-rspack.md
  • react_on_rails/lib/generators/react_on_rails/pro_setup.rb

- Make first CSS extract filter handle both string and object loader
  items, consistent with the second filter block (fixes rspack object
  loader items slipping through)
- Use typeof guard on cssLoader.options.modules spread to safely handle
  non-object values (true, 'local', 'global') that would produce
  garbage when spread
- Update generator spec helper to match template changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (3)
react_on_rails/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt (3)

151-162: Add item && guard for consistency with line 57.

Line 154 accesses item.loader without first checking if item is truthy. While the earlier filtering block (line 57) defensively uses item && typeof item.loader === 'string', this block doesn't. For consistency and defensive coding:

♻️ Proposed fix
       rule.use = rule.use.filter((item) => {
         let testValue = '';
         if (typeof item === 'string') {
           testValue = item;
-        } else if (typeof item.loader === 'string') {
+        } else if (item && typeof item.loader === 'string') {
           testValue = item.loader;
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@react_on_rails/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt`
around lines 151 - 162, The filter callback that sets testValue currently checks
typeof item.loader without guarding item; update the conditional in the block
that assigns testValue (inside the filter callback where testValue is
initialized) to use "item && typeof item.loader === 'string'" so it mirrors the
earlier defensive check and avoids accessing item.loader when item is falsy.

163-173: Same inconsistency: add item && guard at line 168.

For the same reason as above, line 168 should defensively check item before accessing item.loader:

♻️ Proposed fix
       const cssLoader = rule.use.find((item) => {
         let testValue = '';

         if (typeof item === 'string') {
           testValue = item;
-        } else if (typeof item.loader === 'string') {
+        } else if (item && typeof item.loader === 'string') {
           testValue = item.loader;
         }

         return testValue.includes('css-loader');
       });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@react_on_rails/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt`
around lines 163 - 173, The null/undefined access risk in the rule.use.find
callback should be fixed by guarding the second branch before reading
item.loader: inside the cssLoader lookup (the rule.use.find callback that
currently checks "if (typeof item === 'string') ... else if (typeof item.loader
=== 'string')"), change the conditional to verify item exists (e.g., "else if
(item && typeof item.loader === 'string')") so you don't access loader on a
null/undefined item; adjust only the check in the cssLoader finder (the
anonymous function passed to rule.use.find) to add the item && guard.

51-66: Consider consolidating duplicate filtering logic.

The filtering at lines 51-66 removes mini-css-extract-plugin and cssExtractLoader, while lines 147-162 removes the same loaders plus style-loader. Since both iterate over the same serverWebpackConfig.module.rules array, this could be consolidated into a single pass at lines 147-162 (which already handles the superset of cases).

If there's a specific reason for the two-phase filtering (e.g., plugin removal at line 135 needs to happen between them), a clarifying comment would help future maintainers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@react_on_rails/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt`
around lines 51 - 66, The duplicate filtering of loaders on
serverWebpackConfig.module.rules (the earlier
serverWebpackConfig.module.rules.forEach block that removes
mini-css-extract-plugin and cssExtractLoader) should be consolidated into the
later single pass that already removes the superset (mini-css-extract-plugin,
cssExtractLoader and style-loader); remove the earlier forEach or merge its
logic into the later pass to avoid iterating twice, and if the intermediate
plugin removal (the code that removes the mini-css-extract-plugin instance) must
occur between these steps, add a short clarifying comment referencing that
plugin-removal block and why a two-phase approach is required.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@react_on_rails/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt`:
- Around line 151-162: The filter callback that sets testValue currently checks
typeof item.loader without guarding item; update the conditional in the block
that assigns testValue (inside the filter callback where testValue is
initialized) to use "item && typeof item.loader === 'string'" so it mirrors the
earlier defensive check and avoids accessing item.loader when item is falsy.
- Around line 163-173: The null/undefined access risk in the rule.use.find
callback should be fixed by guarding the second branch before reading
item.loader: inside the cssLoader lookup (the rule.use.find callback that
currently checks "if (typeof item === 'string') ... else if (typeof item.loader
=== 'string')"), change the conditional to verify item exists (e.g., "else if
(item && typeof item.loader === 'string')") so you don't access loader on a
null/undefined item; adjust only the check in the cssLoader finder (the
anonymous function passed to rule.use.find) to add the item && guard.
- Around line 51-66: The duplicate filtering of loaders on
serverWebpackConfig.module.rules (the earlier
serverWebpackConfig.module.rules.forEach block that removes
mini-css-extract-plugin and cssExtractLoader) should be consolidated into the
later single pass that already removes the superset (mini-css-extract-plugin,
cssExtractLoader and style-loader); remove the earlier forEach or merge its
logic into the later pass to avoid iterating twice, and if the intermediate
plugin removal (the code that removes the mini-css-extract-plugin instance) must
occur between these steps, add a short clarifying comment referencing that
plugin-removal block and why a two-phase approach is required.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 74d5cd6 and 99d30fe.

📒 Files selected for processing (2)
  • react_on_rails/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt
  • react_on_rails/spec/react_on_rails/support/generator_spec_helper.rb
🚧 Files skipped from review as they are similar to previous changes (1)
  • react_on_rails/spec/react_on_rails/support/generator_spec_helper.rb

@claude
Copy link

claude bot commented Feb 27, 2026

Code Review

Overall, this PR correctly addresses two real bugs affecting Rspack + SSR setups:

  1. The CSS extract loader filter was webpack-only, leaving Rspack's loader in the server bundle
  2. The CSS modules options were overwritten instead of spread, silently destroying namedExport, localIdentName, and getLocalIdent settings

The fixes are sound and the documentation additions are valuable. A few things worth reviewing:


1. Plugin filter still misses Rspack's CSS extract plugin

The loader filter (the focus of this PR) is now correctly handling both bundlers. But the plugin filter in the same file still only removes MiniCssExtractPlugin:

serverWebpackConfig.plugins = serverWebpackConfig.plugins.filter(
  (plugin) =>
    plugin.constructor.name \!== 'WebpackAssetsManifest' &&
    plugin.constructor.name \!== 'MiniCssExtractPlugin' &&   // webpack only
    plugin.constructor.name \!== 'ForkTsCheckerWebpackPlugin',
    // missing: 'CssExtractRspackPlugin' for Rspack
);

In Rspack + Shakapacker, the CSS extraction plugin registers as CssExtractRspackPlugin. Without filtering it, the plugin stays in the server bundle even after this PR's loader fix. Worth verifying the exact constructor name and adding it alongside MiniCssExtractPlugin.


2. Behavior change: exportOnlyLocals no longer applied when modules is not configured

The old guard was if (cssLoader && cssLoader.options) — it would set exportOnlyLocals: true for any css-loader entry, even one without modules configured. The new guard adds && cssLoader.options.modules, so nothing happens when modules are absent.

This is actually the correct behavior — the old code was erroneously enabling CSS Modules by injecting exportOnlyLocals: true on non-modules files. But it is a user-visible behavioral change and worth calling out explicitly in the PR description.


3. getLocalIdent docs: missing .less and .styl extensions

The extension-stripping regex in the docs example does not include .less or .styl/.stylus. Users with Less or Stylus files would get names like Button.less-primary_a1b2c3d4 instead of Button-primary_a1b2c3d4. Since this is copy-paste documentation, consider adding less|styl to the alternation.


4. pro_setup.rb regex: lazy match edge case with nested }; patterns

The lazy [\s\S]*? after exportOnlyLocals: true stops at the first }; followed by a newline and }. For the generated template this is correct. However, if a user's getLocalIdent contains an early }; (e.g., const x = { foo: 1 };), the regex could match a partial block. The post-insertion check with say_status :warning is a good fallback for this edge case.


Minor: redundant /m flag

In Ruby, /m makes . match newlines — but the regex uses [\s\S] throughout, not ., so the flag has no effect. Harmless, but could be dropped.

});
if (cssLoader && cssLoader.options) {
cssLoader.options.modules = { exportOnlyLocals: true };
if (cssLoader && cssLoader.options && cssLoader.options.modules) {
Copy link

Choose a reason for hiding this comment

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

This guard change is correct — the old if (cssLoader && cssLoader.options) would inject exportOnlyLocals: true on any css-loader entry, erroneously enabling CSS Modules for plain (non-modules) CSS files. The new check is safe.

One edge case worth confirming: when cssLoader.options.modules is the boolean true (rather than an object), the spread ...(typeof cssLoader.options.modules === 'object' ? cssLoader.options.modules : {}) correctly spreads {} and then adds exportOnlyLocals: true. That path looks good.

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks for confirming — the typeof check in the spread handles the boolean true edge case correctly as you noted.

gsub_file(
webpack_config,
/(cssLoader\.options\.modules = \{ exportOnlyLocals: true \};\s*\n\s*\})/,
/(cssLoader\.options\.modules = \{[\s\S]*?exportOnlyLocals: true[\s\S]*?\};\s*\n\s*\})/m,
Copy link

Choose a reason for hiding this comment

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

The /m flag in Ruby makes . match newlines, but this pattern only uses [\s\S] (not .), so the flag is a no-op. It can be safely removed.

Also a theoretical edge case: the lazy [\s\S]*? after exportOnlyLocals: true stops at the earliest };\n }. If a user's config has an inline variable with }; before the object closes (e.g., const x = { foo: 1 }; inside getLocalIdent), the match could terminate early and produce a partial replacement. The post-insertion check with say_status :warning is a good fallback — just flagging for awareness.

Suggested change
/(cssLoader\.options\.modules = \{[\s\S]*?exportOnlyLocals: true[\s\S]*?\};\s*\n\s*\})/m,
/(cssLoader\.options\.modules = \{[\s\S]*?exportOnlyLocals: true[\s\S]*?\};\s*\n\s*\})/,

Copy link
Member Author

Choose a reason for hiding this comment

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

Removed the /m flag in 8f9b793. Good point about the theoretical early-termination edge case — the post-insertion check with say_status :warning provides the safety net for that scenario.

.slice(0, 8);

const basename = path.basename(resourcePath);
const name = basename.replace(/\.(module\.)?(scss|sass|css|tsx?|jsx?)$/, '').replace(/-styles$/, '');
Copy link

Choose a reason for hiding this comment

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

The extension-stripping regex doesn't cover .less or .styl/.stylus. Users with Less or Stylus CSS modules would get class names like Button.less-primary_a1b2c3d4 instead of Button-primary_a1b2c3d4.

Suggested change
const name = basename.replace(/\.(module\.)?(scss|sass|css|tsx?|jsx?)$/, '').replace(/-styles$/, '');
const name = basename.replace(/\.(module\.)?(scss|sass|css|less|styl|tsx?|jsx?)$/, '').replace(/-styles$/, '');

Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed in 8f9b793 — added .less and .styl to the extension-stripping regex.

… consistency

- Fix broken anchor link #css-in-js-virtual-module-collisions-astroturf-etc
  to point to actual heading #css-in-js-styled-components-all-share-the-same-class-name
- Add .less and .styl extensions to getLocalIdent basename regex
- Use .includes() consistently instead of mixing .match() and .includes()
- Remove unnecessary /m flag from pro_setup.rb regex ([\s\S] already matches newlines)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link

claude bot commented Feb 27, 2026

Code Review

Overall, the fixes here are sound — the core logic for spreading CSS modules options instead of overwriting and detecting Rspack's cssExtractLoader are correct. A few issues worth addressing:

Bugs

1. Self-referencing documentation link (line 313)
The troubleshooting entry for "CSS-in-JS Styled Components All Share the Same Class Name" links back to itself:

See the [CSS-in-JS Styled Components All Share the Same Class Name](#css-in-js-styled-components-all-share-the-same-class-name) section above.

The anchor should point to #css-modules-with-ssr-stable-class-names where the getLocalIdent solution actually lives. As written it's a dead loop — readers get no actionable help.

2. Inconsistent null guard in loader filter (template lines 154, 168)
The first filter block (line 57) correctly guards item before accessing item.loader:

} else if (item && typeof item.loader === 'string') {

But the second filter (line 154) and the cssLoader find (line 168) skip the item && guard:

} else if (typeof item.loader === 'string') { // crashes if item is null

If any loader entry is null or a non-plain-object (e.g. a class instance without a loader property), the second block will throw. It's a latent crash waiting for an unusual bundler config.

Test Gap

3. Spec helper doesn't cover the new cssExtractLoader filter
generator_spec_helper.rb was updated for the CSS modules condition change but the base_server_webpack_content helper still doesn't include the rule.use = rule.use.filter(...) block for the second loop. That means the new rspack filter for cssExtractLoader has no snapshot/unit coverage in the generator specs.

Minor Notes

  • Fragile projectRoot in docs (line 153): path.resolve(__dirname, '../..') hardcodes depth from the config dir to project root. process.cwd() (or a package.json walk) would be more robust, and the comment nudging users to "adjust if config lives at a different depth" is easy to miss.
  • Regex in pro_setup.rb (line 262): The updated /[\s\S]*?exportOnlyLocals: true[\s\S]*?/ correctly handles multi-line blocks. One thing to watch: the closing \};\s*\n\s*\} relies on exact indentation of the outer if brace — if a user's file has unusual whitespace this could silently fail to insert the Babel SSR caller (the new fallback warning helps, but a test covering that path would be reassuring).


**Cause:** Rspack sets `context.resourcePath` to the actual source file instead of the virtual matchResource path. All virtual CSS modules from the same source file produce the same hash.

**Solution:** Include `context.resourceQuery` in the `getLocalIdent` hash. See the [CSS-in-JS Styled Components All Share the Same Class Name](#css-in-js-styled-components-all-share-the-same-class-name) section above.
Copy link

Choose a reason for hiding this comment

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

Self-referencing link — this anchor points back to the current section, so clicking it does nothing useful.

Suggested change
**Solution:** Include `context.resourceQuery` in the `getLocalIdent` hash. See the [CSS-in-JS Styled Components All Share the Same Class Name](#css-in-js-styled-components-all-share-the-same-class-name) section above.
**Solution:** Include `context.resourceQuery` in the `getLocalIdent` hash. See the [CSS Modules with SSR: Stable Class Names](#css-modules-with-ssr-stable-class-names) section above for the full `getLocalIdent` implementation.

let testValue = '';
if (typeof item === 'string') {
testValue = item;
} else if (typeof item.loader === 'string') {
Copy link

Choose a reason for hiding this comment

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

Missing item && guard here. The first filter block at line 57 correctly uses item && typeof item.loader, but this one and the find below (line 168) do not. If a loader entry is null or an object without a loader key, item.loader will throw.

Suggested change
} else if (typeof item.loader === 'string') {
} else if (item && typeof item.loader === 'string') {

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
react_on_rails/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt (1)

51-66: Consider consolidating duplicate CSS loader filtering.

The same loaders (mini-css-extract-plugin, cssExtractLoader) are filtered in two separate loops — lines 51-66 filter rules early, then lines 147-162 filter again (with style-loader added). This duplication could be consolidated into the second, more comprehensive filter block.

Not blocking since this is a generator template where robustness may be preferred over DRY.

Also applies to: 147-162

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@react_on_rails/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt`
around lines 51 - 66, The duplicate CSS loader-filtering logic should be
consolidated: remove or replace the earlier
serverWebpackConfig.module.rules.forEach(...) loop that filters loader.use based
on testValue.includes('mini-css-extract-plugin') ||
testValue.includes('cssExtractLoader') and rely on the later, comprehensive
filter block (the one that also filters 'style-loader'); alternatively, merge
the conditions so there is a single loop that checks loader.use.filter and
excludes 'mini-css-extract-plugin', 'cssExtractLoader', and 'style-loader' by
examining item strings or item.loader (the same testValue logic) to avoid
redundant processing while preserving the more comprehensive filtering behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/migrating/migrating-from-webpack-to-rspack.md`:
- Around line 303-313: The self-referential anchor currently pointing to
"#css-in-js-styled-components-all-share-the-same-class-name" should be changed
to the CSS Modules section that contains the getLocalIdent example so readers
are directed to the actual solution; update the link target to the anchor for
the CSS Modules/getLocalIdent section (the section that demonstrates including
context.resourceQuery and the getLocalIdent snippet) so the reference correctly
points to the getLocalIdent solution.

---

Nitpick comments:
In
`@react_on_rails/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt`:
- Around line 51-66: The duplicate CSS loader-filtering logic should be
consolidated: remove or replace the earlier
serverWebpackConfig.module.rules.forEach(...) loop that filters loader.use based
on testValue.includes('mini-css-extract-plugin') ||
testValue.includes('cssExtractLoader') and rely on the later, comprehensive
filter block (the one that also filters 'style-loader'); alternatively, merge
the conditions so there is a single loop that checks loader.use.filter and
excludes 'mini-css-extract-plugin', 'cssExtractLoader', and 'style-loader' by
examining item strings or item.loader (the same testValue logic) to avoid
redundant processing while preserving the more comprehensive filtering behavior.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 99d30fe and 8f9b793.

📒 Files selected for processing (3)
  • docs/migrating/migrating-from-webpack-to-rspack.md
  • react_on_rails/lib/generators/react_on_rails/pro_setup.rb
  • react_on_rails/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt

Comment on lines +303 to +313
### CSS-in-JS Styled Components All Share the Same Class Name

**Symptoms:**

- Multiple astroturf (or similar CSS-in-JS) styled components in the same file all get identical CSS class names
- Styles look correct in development but are broken in production
- Only one component's styles survive; others are overwritten

**Cause:** Rspack sets `context.resourcePath` to the actual source file instead of the virtual matchResource path. All virtual CSS modules from the same source file produce the same hash.

**Solution:** Include `context.resourceQuery` in the `getLocalIdent` hash. See the [CSS-in-JS Styled Components All Share the Same Class Name](#css-in-js-styled-components-all-share-the-same-class-name) section above.
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Self-referential link — should point to the solution section.

Line 313 links to #css-in-js-styled-components-all-share-the-same-class-name, which is the current section itself. The link should point to the CSS Modules section where the getLocalIdent code is provided.

📝 Suggested fix
-**Solution:** Include `context.resourceQuery` in the `getLocalIdent` hash. See the [CSS-in-JS Styled Components All Share the Same Class Name](`#css-in-js-styled-components-all-share-the-same-class-name`) section above.
+**Solution:** Include `context.resourceQuery` in the `getLocalIdent` hash. See the [CSS Modules with SSR: Stable Class Names](`#css-modules-with-ssr-stable-class-names`) section above.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/migrating/migrating-from-webpack-to-rspack.md` around lines 303 - 313,
The self-referential anchor currently pointing to
"#css-in-js-styled-components-all-share-the-same-class-name" should be changed
to the CSS Modules section that contains the getLocalIdent example so readers
are directed to the actual solution; update the link target to the anchor for
the CSS Modules/getLocalIdent section (the section that demonstrates including
context.resourceQuery and the getLocalIdent snippet) so the reference correctly
points to the getLocalIdent solution.

if (cssLoader && cssLoader.options) {
cssLoader.options.modules = { exportOnlyLocals: true };
if (cssLoader && cssLoader.options && cssLoader.options.modules) {
cssLoader.options.modules = {
Copy link

Choose a reason for hiding this comment

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

The find callback a few lines above (the cssLoader one) also uses typeof item.loader === 'string' without a prior item && guard, which is inconsistent with the first filter block at line 57 that does include item &&. If rule.use ever contains a null entry the callback will throw. Worth applying the same null guard there for consistency and safety.

…ial link

- Add `item &&` null guard before accessing `item.loader` in all
  serverWebpackConfig files to prevent "Cannot read properties of
  undefined" when rule.use contains undefined/null entries
- Initialize testValue to empty string to prevent undefined.includes()
- Add Array.isArray guard to extractLoader in pro dummy
- Fix self-referential troubleshooting link in rspack migration docs
  to point to the getLocalIdent implementation section instead

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link

claude bot commented Feb 27, 2026

Review Summary

This PR correctly addresses two real bugs in the rspack SSR setup and the documentation is thorough. The core fixes are sound. A few issues worth addressing before merge:

Bugs / Inconsistencies

1. Spec dummy files not updated with the CSS modules spread fix

Both react_on_rails/spec/dummy/config/webpack/serverWebpackConfig.js (line 93–94) and react_on_rails_pro/spec/dummy/config/webpack/serverWebpackConfig.js (line 109–110) still use the old overwrite pattern:

if (cssLoader && cssLoader.options) {
  cssLoader.options.modules = { exportOnlyLocals: true };
}

The template and generator_spec_helper.rb were correctly updated with the spread syntax, but these two test fixture files were not. If these configs are used in any integration test that exercises CSS modules, the divergence could hide regressions. If they're intentionally kept as older-style configs (e.g. to exercise the pro_setup.rb upgrade path), that reasoning should be noted.

2. cssExtractLoader match relies on an internal rspack path

testValue.includes('cssExtractLoader') matches @rspack/core/dist/cssExtractLoader.js. This is a private implementation detail — rspack could rename or move the file in a minor release and the filter would silently stop working (CSS extraction would remain in the server bundle again). Consider also checking the plugin constructor name (e.g. plugin.constructor.name !== 'CssExtractRspackPlugin') for the plugin-level filter, and documenting the known rspack path so it's easy to update.

Minor issues

3. add_babel_ssr_caller_to_server_config regex edge-case

The new pattern /(cssLoader\.options\.modules = \{[\s\S]*?exportOnlyLocals: true[\s\S]*?\};\s*\n\s*\})/ uses lazy matching. If the user's modules object ends with a trailing comma before }; (which formatters like Prettier produce by default), the pattern \}; requires no trailing comma — it matches }; literally but the formatted code will be },\n };. This combination (inner multiline spread + Prettier formatting) could cause the insertion to be silently skipped, falling through to the new say_status :warning line. The warning is a good addition; just worth noting in a comment that this is the expected fallback for formatted files.

4. Documentation example hardcodes project-specific CSS modules options

In the usage snippet:

cssLoader.options.modules.namedExport = false;
cssLoader.options.modules.exportLocalsConvention = 'camelCase';

These values are specific to one project's configuration. Copying them blindly could silently change CSS export behavior (e.g. namedExport: false disables named exports if the user had them enabled). These lines should either be removed from the example or annotated with // adjust to match your project settings.

Positive notes

  • The item && null guard before item.loader access is a good defensive improvement across all the touched files.
  • Initialising testValue = '' avoids the prior risk of calling .includes or .match on undefined.
  • The if (!Array.isArray(rule.use)) return null guard added to extractLoader in the pro dummy is correct.
  • The post-insertion verification (File.read + say_status :warning) in add_babel_ssr_caller_to_server_config is a meaningful improvement over silent failure.
  • The getLocalIdent approach in the docs is technically correct and the resourceQuery inclusion for virtual CSS modules is a nice forward-looking touch.

gsub_file(
webpack_config,
/(cssLoader\.options\.modules = \{ exportOnlyLocals: true \};\s*\n\s*\})/,
/(cssLoader\.options\.modules = \{[\s\S]*?exportOnlyLocals: true[\s\S]*?\};\s*\n\s*\})/,
Copy link

Choose a reason for hiding this comment

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

The lazy [\s\S]*? matching will correctly handle the multiline spread pattern, but the tail \};\s*\n\s*\} requires that:

  1. The modules assignment ends with }; (no trailing comma before the })
  2. A newline immediately follows before the closing } of the if block

Prettier-formatted files typically produce }, (trailing comma) on the last property, not };. If a user's config has been auto-formatted, this could fail to match even the standard template output — the added say_status :warning will surface it, but it's worth a comment explaining why formatting divergence triggers the fallback.

}
return !(
testValue.includes('mini-css-extract-plugin') ||
testValue.includes('cssExtractLoader') // Rspack uses this path
Copy link

Choose a reason for hiding this comment

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

cssExtractLoader is an internal rspack implementation detail (@rspack/core/dist/cssExtractLoader.js). If rspack moves or renames this file in a future version the filter silently stops working and CSS extraction leaks back into the server bundle. Worth adding a comment with the full known path and a note to check it on rspack upgrades:

Suggested change
testValue.includes('cssExtractLoader') // Rspack uses this path
testValue.includes('mini-css-extract-plugin') ||
// Rspack's internal CSS extract loader - verify path on rspack upgrades:
// @rspack/core/dist/cssExtractLoader.js
testValue.includes('cssExtractLoader') // Rspack uses this path


// In the CSS modules configuration loop:
if (cssLoader?.options?.modules) {
cssLoader.options.modules.namedExport = false;
Copy link

Choose a reason for hiding this comment

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

These two lines hardcode project-specific CSS module conventions. namedExport: false and exportLocalsConvention: 'camelCase' are not universally correct defaults — users who rely on named exports or a different convention will silently regress if they copy this snippet verbatim. Consider removing them from the example entirely (they're unrelated to the getLocalIdent fix) or adding a comment like // adjust to match your existing CSS modules settings.

if (!Array.isArray(rule.use)) return null;
return rule.use.find((item) => {
let testValue;
let testValue = '';
Copy link

Choose a reason for hiding this comment

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

The extractLoader function here still accesses item.loader without a null guard (item &&), unlike the updated version in the template and all the other touched files. If item is null or undefined in the use array this would throw.

Suggested change
let testValue = '';
let testValue = '';
if (typeof item === 'string') {
testValue = item;
} else if (item && typeof item.loader === 'string') {
testValue = item.loader;
}

…cases

Document two rspack migration pitfalls discovered during Academia's
re-land: UMD libraries (lodash/underscore) behaving differently under
rspack's module interop, and modern entry points crashing on globals
only exposed in legacy bundles. Kept in the Troubleshooting section
to avoid distracting from the main migration flow.

Closes #2488 (additional context)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Astroturf CSS class name collision with rspack: resourcePath points to source file instead of matchResource

1 participant