From 6d05731eca512c6128ae9bb45a2ddee13c7ffa02 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 6 Jul 2026 23:30:12 -1000 Subject: [PATCH] Recover transitive CSS split chunks --- CHANGELOG.md | 2 + src/react-server-dom-rspack/plugin.ts | 56 +++++++-- src/webpack/RSCWebpackPlugin.ts | 60 +++++++-- .../transitive-css-only-chunk/Button.js | 7 ++ .../transitive-css-only-chunk/Panel.css | 3 + .../transitive-css-only-chunk/Panel.js | 5 + .../transitive-css-only-chunk/entryOnly.js | 1 + .../transitive-css-only-chunk/index.js | 3 + tests/rspack-plugin/plugin.test.ts | 54 ++++++++ .../transitive-css-only-chunk/Button.js | 7 ++ .../transitive-css-only-chunk/Panel.css | 3 + .../transitive-css-only-chunk/Panel.js | 5 + .../transitive-css-only-chunk/entryOnly.js | 1 + .../transitive-css-only-chunk/index.js | 3 + .../webpack-plugin/plugin-integration.test.ts | 118 ++++++++++++++++++ 15 files changed, 308 insertions(+), 20 deletions(-) create mode 100644 tests/rspack-plugin/fixtures/transitive-css-only-chunk/Button.js create mode 100644 tests/rspack-plugin/fixtures/transitive-css-only-chunk/Panel.css create mode 100644 tests/rspack-plugin/fixtures/transitive-css-only-chunk/Panel.js create mode 100644 tests/rspack-plugin/fixtures/transitive-css-only-chunk/entryOnly.js create mode 100644 tests/rspack-plugin/fixtures/transitive-css-only-chunk/index.js create mode 100644 tests/webpack-plugin/fixtures/transitive-css-only-chunk/Button.js create mode 100644 tests/webpack-plugin/fixtures/transitive-css-only-chunk/Panel.css create mode 100644 tests/webpack-plugin/fixtures/transitive-css-only-chunk/Panel.js create mode 100644 tests/webpack-plugin/fixtures/transitive-css-only-chunk/entryOnly.js create mode 100644 tests/webpack-plugin/fixtures/transitive-css-only-chunk/index.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 481dd33..7d98411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ All notable changes to this package will be documented in this file. - Skipped rspack diagnostics CSS collection when client-reference diagnostics are disabled, avoiding dead per-chunk-group CSS scans on the default build path. ([#152]) ### Fixed +- Recovered RSC stylesheet hints for CSS imported by a client reference's child module when a CSS-merging SplitChunks cache group moves the stylesheet into a CSS-only split chunk. ([#184]) - Fixed Webpack client-reference string paths to resolve relative to the compiler context and warned when `output.publicPath: "auto"` cannot be serialized into the RSC manifest. ([#171]) - Fixed `RSCRspackPlugin` to skip manifest and diagnostics emission when the Flight client runtime is missing, matching the Webpack plugin's misconfiguration behavior instead of writing unusable assets. ([#176]) - Fixed Rspack client-reference manifests to emit deterministic chunk pair ordering under Rspack 2 while preserving the full sibling chunk set. ([#140]) @@ -81,6 +82,7 @@ All notable changes to this package will be documented in this file. [#172]: https://github.com/shakacode/react_on_rails_rsc/pull/172 [#176]: https://github.com/shakacode/react_on_rails_rsc/pull/176 [#183]: https://github.com/shakacode/react_on_rails_rsc/pull/183 +[#184]: https://github.com/shakacode/react_on_rails_rsc/pull/184 [#165]: https://github.com/shakacode/react_on_rails_rsc/pull/165 [#164]: https://github.com/shakacode/react_on_rails_rsc/pull/164 [#151]: https://github.com/shakacode/react_on_rails_rsc/pull/151 diff --git a/src/react-server-dom-rspack/plugin.ts b/src/react-server-dom-rspack/plugin.ts index d289887..e1b9a86 100644 --- a/src/react-server-dom-rspack/plugin.ts +++ b/src/react-server-dom-rspack/plugin.ts @@ -708,6 +708,17 @@ export class RSCRspackPlugin { // plugin, lines 241-294). Each module gets the full list of sibling // chunks in its group — this ensures splitChunks dependencies are // included. + const chunkGroupUseCount = new Map(); + for (const candidateGroup of compilation.chunkGroups) { + for (const candidateChunkUnknown of candidateGroup.chunks) { + const candidateChunk = candidateChunkUnknown as AnyChunk; + chunkGroupUseCount.set( + candidateChunk, + (chunkGroupUseCount.get(candidateChunk) ?? 0) + 1, + ); + } + } + for (const chunkGroup of compilation.chunkGroups) { const groupChunkList = [...chunkGroup.chunks].map((chunk) => chunk as AnyChunk); const normalizedChunkGroup = { name: chunkGroup.name, chunks: groupChunkList }; @@ -728,6 +739,11 @@ export class RSCRspackPlugin { const directCssDepFiles = (module: AnyModule): string[] => { if (!getOutgoingConnections || cssPrefix === null) return []; const files = new Set(); + const moduleChunks = new Set( + [...compilation.chunkGraph.getModuleChunks(module)] + .map((chunk) => chunk as AnyChunk) + .filter((chunk) => groupChunkSet.has(chunk)), + ); const addCssFromModuleChunks = (cssModule: AnyModule): void => { for (const cssChunkUnknown of compilation.chunkGraph.getModuleChunks(cssModule)) { const cssChunk = cssChunkUnknown as AnyChunk; @@ -737,18 +753,44 @@ export class RSCRspackPlugin { } } }; + const addDirectStyleImports = (sourceModule: AnyModule): void => { + for (const connection of getOutgoingConnections(sourceModule)) { + const depModule = connection.module ?? connection.resolvedModule; + if (!depModule?.resource) continue; + const depResource = depModule.resource.replace(/[?#].*$/, ''); + if (!STYLE_SOURCE_RE.test(depResource)) continue; + addCssFromModuleChunks(depModule); + for (const cssConnection of getOutgoingConnections(depModule)) { + const extractedCssModule = cssConnection.module ?? cssConnection.resolvedModule; + if (!extractedCssModule || extractedCssModule.type !== 'css/mini-extract') continue; + addCssFromModuleChunks(extractedCssModule); + } + } + }; + // Match the webpack plugin: a one-hop non-style child qualifies only + // when it shares the reference module's chunk or rspack split it to a + // chunk used by this async chunk group only. The use-count check keeps + // local child-component CSS without re-broadcasting shared dependency + // chunks across reference groups (#108). + const belongsToReferenceChunkGroup = (depModule: AnyModule): boolean => { + for (const depChunkUnknown of compilation.chunkGraph.getModuleChunks(depModule)) { + const depChunk = depChunkUnknown as AnyChunk; + if (moduleChunks.has(depChunk)) return true; + if (groupChunkSet.has(depChunk) && (chunkGroupUseCount.get(depChunk) ?? 0) === 1) { + return true; + } + } + return false; + }; + addDirectStyleImports(module); for (const connection of getOutgoingConnections(module)) { const depModule = connection.module ?? connection.resolvedModule; if (!depModule?.resource) continue; const depResource = depModule.resource.replace(/[?#].*$/, ''); - if (!STYLE_SOURCE_RE.test(depResource)) continue; - addCssFromModuleChunks(depModule); - for (const cssConnection of getOutgoingConnections(depModule)) { - const extractedCssModule = cssConnection.module ?? cssConnection.resolvedModule; - if (!extractedCssModule || extractedCssModule.type !== 'css/mini-extract') continue; - addCssFromModuleChunks(extractedCssModule); - } + if (STYLE_SOURCE_RE.test(depResource)) continue; + if (!belongsToReferenceChunkGroup(depModule)) continue; + addDirectStyleImports(depModule); } return [...files]; diff --git a/src/webpack/RSCWebpackPlugin.ts b/src/webpack/RSCWebpackPlugin.ts index c7f66dc..3862d9a 100644 --- a/src/webpack/RSCWebpackPlugin.ts +++ b/src/webpack/RSCWebpackPlugin.ts @@ -668,6 +668,15 @@ export class RSCWebpackPlugin { let missingClientReferenceBlocksWarningEmitted = false; const clientReferenceChunkGroupsByResource = new Map>(); + const chunkGroupUseCount = new Map(); + for (const candidateGroup of compilation.chunkGroups) { + for (const candidateChunk of candidateGroup.chunks) { + chunkGroupUseCount.set( + candidateChunk, + (chunkGroupUseCount.get(candidateChunk) ?? 0) + 1, + ); + } + } // Records every module of `chunkGroup` whose resource is in // `chunkResolvedClientFiles`, listing the chunk group's own @@ -789,10 +798,12 @@ export class RSCWebpackPlugin { // css/mini-extract cache group that moves the extracted CssModule // into a CSS-only chunk). Follow the module's DIRECT `.css` // imports to the chunk(s) that carry them, intersected with this - // chunk group, and merge that CSS. Only direct imports are - // followed, so CSS reached through a shared dependency (imported by - // another module, not the reference) is NOT picked up — the #108 - // broadcast stays fixed. + // chunk group, and merge that CSS. Also follow one non-style child + // hop when that child belongs to the reference's async chunk group: + // either it shares one of the reference module's chunks, or webpack + // split it to a chunk used by this group only. That second check + // keeps a split local child component's stylesheet while still + // excluding chunks shared by multiple reference groups (#108). // Guarded on `moduleGraph`/`getModuleChunksIterable`, which the // unit-test mocks omit (they exercise the per-chunk pass only). const moduleGraph = compilation.moduleGraph; @@ -801,6 +812,9 @@ export class RSCWebpackPlugin { const directCssDepFiles = (module: FlightModule): string[] => { if (!moduleGraph || !getModuleChunksIterable || cssPrefix === null) return []; const files = new Set(); + const moduleChunks = new Set( + [...getModuleChunksIterable(module)].filter((chunk) => groupChunks.has(chunk)), + ); const addCssFromModuleChunks = (cssModule: FlightModule): void => { for (const cssChunk of getModuleChunksIterable(cssModule)) { if (!groupChunks.has(cssChunk)) continue; @@ -811,6 +825,32 @@ export class RSCWebpackPlugin { } } }; + const addDirectStyleImports = (sourceModule: FlightModule): void => { + for (const connection of moduleGraph.getOutgoingConnections(sourceModule)) { + const depModule = connection.module ?? connection.resolvedModule; + if (!depModule || !depModule.resource) continue; + const depResource = depModule.resource.replace(/[?#].*$/, ''); + if (!STYLE_SOURCE_RE.test(depResource)) continue; + addCssFromModuleChunks(depModule); + for (const cssConnection of moduleGraph.getOutgoingConnections(depModule)) { + const extractedCssModule = cssConnection.module ?? cssConnection.resolvedModule; + if (!extractedCssModule || extractedCssModule.type !== 'css/mini-extract') { + continue; + } + addCssFromModuleChunks(extractedCssModule); + } + } + }; + const belongsToReferenceChunkGroup = (depModule: FlightModule): boolean => { + for (const depChunk of getModuleChunksIterable(depModule)) { + if (moduleChunks.has(depChunk)) return true; + if (groupChunks.has(depChunk) && (chunkGroupUseCount.get(depChunk) ?? 0) === 1) { + return true; + } + } + return false; + }; + addDirectStyleImports(module); for (const connection of moduleGraph.getOutgoingConnections(module)) { // `module` is the resolved destination for most connections; // some dependency types leave it null with the target on @@ -823,15 +863,9 @@ export class RSCWebpackPlugin { // emitted chunk file is always `.css`. Strip any webpack // resource query/fragment (`./Button.css?inline`) first. const depResource = depModule.resource.replace(/[?#].*$/, ''); - if (!STYLE_SOURCE_RE.test(depResource)) continue; - addCssFromModuleChunks(depModule); - for (const cssConnection of moduleGraph.getOutgoingConnections(depModule)) { - const extractedCssModule = cssConnection.module ?? cssConnection.resolvedModule; - if (!extractedCssModule || extractedCssModule.type !== 'css/mini-extract') { - continue; - } - addCssFromModuleChunks(extractedCssModule); - } + if (STYLE_SOURCE_RE.test(depResource)) continue; + if (!belongsToReferenceChunkGroup(depModule)) continue; + addDirectStyleImports(depModule); } return [...files]; }; diff --git a/tests/rspack-plugin/fixtures/transitive-css-only-chunk/Button.js b/tests/rspack-plugin/fixtures/transitive-css-only-chunk/Button.js new file mode 100644 index 0000000..676a9a2 --- /dev/null +++ b/tests/rspack-plugin/fixtures/transitive-css-only-chunk/Button.js @@ -0,0 +1,7 @@ +'use client'; + +import { Panel } from './Panel'; + +export default function Button() { + return 'button:' + Panel(); +} diff --git a/tests/rspack-plugin/fixtures/transitive-css-only-chunk/Panel.css b/tests/rspack-plugin/fixtures/transitive-css-only-chunk/Panel.css new file mode 100644 index 0000000..6939021 --- /dev/null +++ b/tests/rspack-plugin/fixtures/transitive-css-only-chunk/Panel.css @@ -0,0 +1,3 @@ +.panel { + color: rebeccapurple; +} diff --git a/tests/rspack-plugin/fixtures/transitive-css-only-chunk/Panel.js b/tests/rspack-plugin/fixtures/transitive-css-only-chunk/Panel.js new file mode 100644 index 0000000..4f25d5e --- /dev/null +++ b/tests/rspack-plugin/fixtures/transitive-css-only-chunk/Panel.js @@ -0,0 +1,5 @@ +import './Panel.css'; + +export function Panel() { + return 'panel'; +} diff --git a/tests/rspack-plugin/fixtures/transitive-css-only-chunk/entryOnly.js b/tests/rspack-plugin/fixtures/transitive-css-only-chunk/entryOnly.js new file mode 100644 index 0000000..e39957f --- /dev/null +++ b/tests/rspack-plugin/fixtures/transitive-css-only-chunk/entryOnly.js @@ -0,0 +1 @@ +export const entryOnly = 'entry-only-module'; diff --git a/tests/rspack-plugin/fixtures/transitive-css-only-chunk/index.js b/tests/rspack-plugin/fixtures/transitive-css-only-chunk/index.js new file mode 100644 index 0000000..f47d51f --- /dev/null +++ b/tests/rspack-plugin/fixtures/transitive-css-only-chunk/index.js @@ -0,0 +1,3 @@ +import { entryOnly } from './entryOnly'; + +export const app = [entryOnly]; diff --git a/tests/rspack-plugin/plugin.test.ts b/tests/rspack-plugin/plugin.test.ts index 7b3ae77..31be9ce 100644 --- a/tests/rspack-plugin/plugin.test.ts +++ b/tests/rspack-plugin/plugin.test.ts @@ -337,6 +337,60 @@ describe('RSCRspackPlugin', () => { expect(readManifestCss(result, '/StyledIsland.js')).toContain('.styled-island'); }); + it('keeps child-module CSS-only split chunk styles in the server manifest', () => { + const result = run('transitive-css-only-chunk', { + isServer: true, + clientReferences: staticIslandClientReferences(/^\.\/Button\.js$/), + publicPath: '/assets', + withCss: true, + configExtra: splitStaticIslandCssOnlyChunks, + }); + + expect(result.assets).toContain('styles.chunk.css'); + expect(manifestMetadataFor(result, '/Button.js').css).toEqual([ + '/assets/styles.chunk.css', + ]); + expect(readManifestCss(result, '/Button.js')).toContain('.panel'); + }); + + it('keeps child-module CSS when the child is split into its own JS chunk', () => { + const result = run('transitive-css-only-chunk', { + isServer: true, + clientReferences: staticIslandClientReferences(/^\.\/Button\.js$/), + publicPath: '/assets', + withCss: true, + configExtra: { + optimization: { + splitChunks: { + chunks: 'all', + minSize: 0, + cacheGroups: { + default: false, + defaultVendors: false, + panel: { + test: /Panel\.js$/, + name: 'panel', + chunks: 'all', + enforce: true, + }, + styles: { + name: 'styles', + type: 'css/mini-extract', + chunks: 'all', + enforce: true, + }, + }, + }, + }, + }, + }); + + expect(result.assets).toEqual(expect.arrayContaining(['panel.chunk.js', 'styles.chunk.css'])); + const button = manifestMetadataFor(result, '/Button.js'); + expect(manifestChunkFiles(button.chunks)).toContain('panel.chunk.js'); + expect(button.css).toContain('/assets/styles.chunk.css'); + }); + it('keeps mixed chunk-local and CSS-only split styles in the server manifest', () => { const result = run('static-islands', { isServer: true, diff --git a/tests/webpack-plugin/fixtures/transitive-css-only-chunk/Button.js b/tests/webpack-plugin/fixtures/transitive-css-only-chunk/Button.js new file mode 100644 index 0000000..676a9a2 --- /dev/null +++ b/tests/webpack-plugin/fixtures/transitive-css-only-chunk/Button.js @@ -0,0 +1,7 @@ +'use client'; + +import { Panel } from './Panel'; + +export default function Button() { + return 'button:' + Panel(); +} diff --git a/tests/webpack-plugin/fixtures/transitive-css-only-chunk/Panel.css b/tests/webpack-plugin/fixtures/transitive-css-only-chunk/Panel.css new file mode 100644 index 0000000..6939021 --- /dev/null +++ b/tests/webpack-plugin/fixtures/transitive-css-only-chunk/Panel.css @@ -0,0 +1,3 @@ +.panel { + color: rebeccapurple; +} diff --git a/tests/webpack-plugin/fixtures/transitive-css-only-chunk/Panel.js b/tests/webpack-plugin/fixtures/transitive-css-only-chunk/Panel.js new file mode 100644 index 0000000..4f25d5e --- /dev/null +++ b/tests/webpack-plugin/fixtures/transitive-css-only-chunk/Panel.js @@ -0,0 +1,5 @@ +import './Panel.css'; + +export function Panel() { + return 'panel'; +} diff --git a/tests/webpack-plugin/fixtures/transitive-css-only-chunk/entryOnly.js b/tests/webpack-plugin/fixtures/transitive-css-only-chunk/entryOnly.js new file mode 100644 index 0000000..e39957f --- /dev/null +++ b/tests/webpack-plugin/fixtures/transitive-css-only-chunk/entryOnly.js @@ -0,0 +1 @@ +export const entryOnly = 'entry-only-module'; diff --git a/tests/webpack-plugin/fixtures/transitive-css-only-chunk/index.js b/tests/webpack-plugin/fixtures/transitive-css-only-chunk/index.js new file mode 100644 index 0000000..f47d51f --- /dev/null +++ b/tests/webpack-plugin/fixtures/transitive-css-only-chunk/index.js @@ -0,0 +1,3 @@ +import { entryOnly } from './entryOnly'; + +export const app = [entryOnly]; diff --git a/tests/webpack-plugin/plugin-integration.test.ts b/tests/webpack-plugin/plugin-integration.test.ts index 5541001..9b241a7 100644 --- a/tests/webpack-plugin/plugin-integration.test.ts +++ b/tests/webpack-plugin/plugin-integration.test.ts @@ -413,6 +413,85 @@ describe('ReactFlightWebpackPlugin (real webpack)', () => { }); }); + describe('CSS-only SplitChunks chunks: transitive CSS moved away from client-reference JS', () => { + let result: CompileResult; + + beforeAll(() => { + result = run('transitive-css-only-chunk', { + chunkName: 'client-[request]', + publicPath: '/assets/', + withCss: true, + optimizationExtra: { + splitChunks: { + chunks: 'all', + minSize: 0, + cacheGroups: { + default: false, + defaultVendors: false, + styles: { + name: 'styles', + type: 'css/mini-extract', + chunks: 'all', + enforce: true, + }, + }, + }, + }, + }); + }); + + it('emits the child stylesheet as a CSS-only split chunk (precondition)', () => { + expect(result.assets).toContain('styles.chunk.css'); + const button = entryEndingWith(result.manifest, '/Button.js'); + expect(chunkFiles(button)).toContain('client-Button-js.chunk.js'); + expect(result.assets).not.toContain('client-Button-js.chunk.css'); + }); + + it("keeps CSS imported by the client reference's child module", () => { + const button = entryEndingWith(result.manifest, '/Button.js'); + expect(button.css).toContain('/assets/styles.chunk.css'); + }); + + it("keeps child-module CSS when the child is split into its own JS chunk", () => { + const splitChild = run('transitive-css-only-chunk', { + chunkName: 'client-[request]', + publicPath: '/assets/', + withCss: true, + optimizationExtra: { + splitChunks: { + chunks: 'all', + minSize: 0, + cacheGroups: { + default: false, + defaultVendors: false, + panel: { + test: /Panel\.js$/, + name: 'panel', + chunks: 'all', + enforce: true, + }, + styles: { + name: 'styles', + type: 'css/mini-extract', + chunks: 'all', + enforce: true, + }, + }, + }, + }, + }); + + expect(splitChild.assets).toEqual(expect.arrayContaining(['panel.chunk.js', 'styles.chunk.css'])); + const button = entryEndingWith(splitChild.manifest, '/Button.js'); + expect(chunkFiles(button)).toContain('panel.chunk.js'); + expect(button.css).toContain('/assets/styles.chunk.css'); + }); + + it('produces no fallback warning', () => { + expectNoWarnings(result); + }); + }); + describe('CSS-only shared dependency chunks stay excluded from client-reference CSS', () => { let result: CompileResult; @@ -720,6 +799,45 @@ describe('ReactFlightWebpackPlugin (real webpack)', () => { expect(chunkFiles(settings)).toEqual(['client-SettingsPage-js.chunk.js']); expectNoWarnings(result); }); + + it("does not broadcast a shared dependency's split stylesheet on the server build", () => { + const result = run('split-shared-css', { + isServer: true, + chunkName: 'client-[request]', + publicPath: '/assets/', + withCss: true, + optimizationExtra: { + splitChunks: { + chunks: 'all', + minSize: 0, + cacheGroups: { + default: false, + defaultVendors: false, + sharedJs: { + test: /shared\.js$/, + name: 'shared-js', + minChunks: 2, + enforce: true, + }, + sharedStyles: { + test: /shared\.css$/, + name: 'shared-styles', + type: 'css/mini-extract', + chunks: 'all', + enforce: true, + }, + }, + }, + }, + }); + + expect(result.assets).toContain('shared-styles.chunk.css'); + const button = entryEndingWith(result.manifest, '/Button.js'); + const settings = entryEndingWith(result.manifest, '/SettingsPage.js'); + expect(button.css ?? []).not.toContain('/assets/shared-styles.chunk.css'); + expect(settings.css ?? []).not.toContain('/assets/shared-styles.chunk.css'); + expectNoWarnings(result); + }); }); describe('manifest shape', () => {