From 51e54c55cb1658fd66584ab49622a6f732e67d9a Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Fri, 12 Jun 2026 01:03:41 -1000 Subject: [PATCH 1/3] Port webpack RSC plugin to TypeScript source Replace the vendored built ReactFlightWebpackPlugin (src/react-server-dom-webpack/cjs/react-server-dom-webpack-plugin.js) with a first-class TypeScript port at src/webpack/RSCWebpackPlugin.ts, mirroring the rspack plugin structure. The port preserves full behavior parity: server-build manifest emission, CSS-before-JS chunk scanning, runtime-chunk filtering, the #54 dependency-type chunk-group manifest algorithm with eager-import fallback and blocks-unavailable warning, the #52 runtime/hot-update CSS exclusions, #43 duplicate-package runtime detection, and #23 merge semantics (still required on the server path). Directive detection now reuses the shared hasUseClientDirective helper from clientReferences.ts. Public exports (./WebpackPlugin, ./WebpackLoader, ./RSCReferenceDiscoveryPlugin) are unchanged; the vendored plugin file stays in-tree and in dist but is no longer used by any export path. Test suites that exercised the vendored file now target the TS plugin (source for unit suites, dist for the child-process webpack runner), with zero fixture-expectation changes. Closes #56 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 6 + package.json | 2 +- src/WebpackPlugin.ts | 14 +- src/webpack/RSCWebpackPlugin.ts | 784 ++++++++++++++++++ ...ack-plugin-client-reference-chunks.test.ts | 2 +- ...ct-flight-webpack-plugin-css-order.test.ts | 2 +- tests/rspack-compat/static-analysis.test.ts | 22 +- .../webpack-plugin-runtime-detection.test.ts | 6 +- .../helpers/runWebpackWithPlugin.js | 16 +- 9 files changed, 825 insertions(+), 29 deletions(-) create mode 100644 src/webpack/RSCWebpackPlugin.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cace1cb..fa6298f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to this package will be documented in this file. +## [Unreleased] + +### Changed +- Ported the webpack RSC plugin from the vendored built JavaScript artifact (`src/react-server-dom-webpack/cjs/react-server-dom-webpack-plugin.js`) to first-class TypeScript source at `src/webpack/RSCWebpackPlugin.ts`, preserving full behavior parity (server manifest emission, CSS/JS chunk scanning, runtime-chunk filtering, dependency-type chunk-group manifest construction with eager-import fallback, duplicate-package runtime detection, and hot-update CSS exclusion). The `./WebpackPlugin`, `./WebpackLoader`, and `./RSCReferenceDiscoveryPlugin` exports are unchanged; the vendored plugin file remains in the package but is no longer used by any export path. ([#75]) + ## [19.0.5-rc.7] - 2026-06-09 ### Added @@ -80,3 +85,4 @@ All notable changes to this package will be documented in this file. [19.0.5-rc.1]: https://github.com/shakacode/react_on_rails_rsc/releases/tag/19.0.5-rc.1 [#52]: https://github.com/shakacode/react_on_rails_rsc/pull/52 +[#75]: https://github.com/shakacode/react_on_rails_rsc/pull/75 diff --git a/package.json b/package.json index 7789866..8734fbe 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "prepublishOnly": "yarn run build", "release": "bash scripts/release.sh", "release:dry-run": "bash scripts/release.sh --dry-run", - "build-if-needed": "test -f dist/client.browser.js -a -f dist/client.node.js -a -f dist/server.node.js -a -f dist/RSCReferenceDiscoveryPlugin.js -a -f dist/RSCReferenceDiscoveryPlugin.d.ts -a -f dist/react-server-dom-rspack/plugin.js -a -f dist/react-server-dom-rspack/loader.js || (yarn run build && test -f dist/client.browser.js -a -f dist/client.node.js -a -f dist/server.node.js -a -f dist/RSCReferenceDiscoveryPlugin.js -a -f dist/RSCReferenceDiscoveryPlugin.d.ts -a -f dist/react-server-dom-rspack/plugin.js -a -f dist/react-server-dom-rspack/loader.js)", + "build-if-needed": "test -f dist/client.browser.js -a -f dist/client.node.js -a -f dist/server.node.js -a -f dist/RSCReferenceDiscoveryPlugin.js -a -f dist/RSCReferenceDiscoveryPlugin.d.ts -a -f dist/react-server-dom-rspack/plugin.js -a -f dist/react-server-dom-rspack/loader.js -a -f dist/webpack/RSCWebpackPlugin.js || (yarn run build && test -f dist/client.browser.js -a -f dist/client.node.js -a -f dist/server.node.js -a -f dist/RSCReferenceDiscoveryPlugin.js -a -f dist/RSCReferenceDiscoveryPlugin.d.ts -a -f dist/react-server-dom-rspack/plugin.js -a -f dist/react-server-dom-rspack/loader.js -a -f dist/webpack/RSCWebpackPlugin.js)", "prepack": "yarn run build-if-needed", "prepare": "yarn run build-if-needed" }, diff --git a/src/WebpackPlugin.ts b/src/WebpackPlugin.ts index 9c27bac..d0669a9 100644 --- a/src/WebpackPlugin.ts +++ b/src/WebpackPlugin.ts @@ -3,15 +3,7 @@ import { DEFAULT_CLIENT_REFERENCES_EXCLUDE, DEFAULT_CLIENT_REFERENCES_INCLUDE, } from "./clientReferences"; -import RSCWebpackPluginLib = require("./react-server-dom-webpack/plugin"); - -type ReactFlightWebpackPlugin = { - apply(compiler: Compiler): void; -}; - -type ReactFlightWebpackPluginConstructor = { - new (options: unknown): ReactFlightWebpackPlugin; -}; +import { RSCWebpackPlugin as RSCFlightWebpackPlugin } from "./webpack/RSCWebpackPlugin"; type ClientReferenceSearchPath = { directory: string, @@ -31,7 +23,7 @@ export type Options = { }; export class RSCWebpackPlugin { - private plugin: ReactFlightWebpackPlugin; + private plugin: RSCFlightWebpackPlugin; constructor(options: Options) { const normalizedOptions = @@ -48,7 +40,7 @@ export class RSCWebpackPlugin { ], } : options; - this.plugin = new (RSCWebpackPluginLib as ReactFlightWebpackPluginConstructor)(normalizedOptions); + this.plugin = new RSCFlightWebpackPlugin(normalizedOptions); } apply(compiler: Compiler) { diff --git a/src/webpack/RSCWebpackPlugin.ts b/src/webpack/RSCWebpackPlugin.ts new file mode 100644 index 0000000..7dd4bc5 --- /dev/null +++ b/src/webpack/RSCWebpackPlugin.ts @@ -0,0 +1,784 @@ +/** + * RSCWebpackPlugin — the webpack React Server Components plugin, owned as + * TypeScript source. + * + * This is a faithful port of the previously vendored build of React's + * reference `ReactFlightWebpackPlugin` + * (`src/react-server-dom-webpack/cjs/react-server-dom-webpack-plugin.js`) + * including every fork patch and in-repo edit that file accumulated: + * + * - Server-build support: `isServer` option, server manifest emission + * (`react-server-client-manifest.json`), and server chunk-group scanning. + * - CSS-before-JS chunk scan: CSS and JS files are recorded independently + * of their order inside `chunk.files` (#44 regression coverage). + * - Runtime-chunk filtering: files of each entrypoint's runtime chunk are + * excluded from per-module chunk lists on the client build. + * - #54: client manifest entries are built from the chunk group created by + * each client reference's `AsyncDependenciesBlock` (matched through the + * `ClientReferenceDependency`), with an eager-import fallback pass and a + * warning when client-reference blocks are unavailable. + * - #52: runtime-chunk CSS and `.hot-update.css` files are excluded from + * the CLIENT manifest; the server manifest retains runtime CSS. + * - #43: duplicate-package-install runtime detection — the Flight client + * runtime is recognized by exact resolved path or by walking up from a + * `react-server-dom-webpack/client.*.js` resource to a `package.json` + * named `react-on-rails-rsc`. + * - #23: manifest entries for a module recorded from several chunk groups + * merge their chunk lists (deduped by chunk id) and CSS lists (deduped + * by URL). The server build and the #54 fallback pass still rely on + * these merge semantics. + * + * The `"use client"` directive detection reuses the shared + * `hasUseClientDirective` helper from `../clientReferences` (also used by + * the rspack plugin and `RSCReferenceDiscoveryPlugin`). + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as url from 'url'; +import webpack = require('webpack'); +import { hasUseClientDirective } from '../clientReferences'; + +// neo-async ships no type definitions; declare the two helpers we use. +// eslint-disable-next-line @typescript-eslint/no-var-requires +const asyncLib = require('neo-async') as { + map( + arr: ReadonlyArray, + iterator: (item: T, callback: (err: Error | null, result?: R) => void) => void, + callback: (err: Error | null, results?: R[]) => void, + ): void; + filter( + arr: ReadonlyArray, + iterator: (item: T, callback: (err: Error | null, keep?: boolean) => void) => void, + callback: (err: Error | null, results?: T[]) => void, + ): void; +}; + +const ModuleDependency: typeof webpack.dependencies.ModuleDependency = + webpack.dependencies.ModuleDependency; +const NullDependency: typeof webpack.dependencies.NullDependency = + webpack.dependencies.NullDependency; +const Template: typeof webpack.Template = webpack.Template; + +export class ClientReferenceDependency extends ModuleDependency { + constructor(request: string) { + super(request); + } + + override get type(): string { + return 'client-reference'; + } +} + +const clientFileNameOnClient = require.resolve('../react-server-dom-webpack/client.browser.js'); +const clientFileNameOnServer = require.resolve('../react-server-dom-webpack/client.node.js'); + +const runtimeResourceDetectionCache = new Map(); + +/** + * Detects whether `resource` is the react-on-rails-rsc Flight client runtime + * the plugin keys its client-reference injection on. Results are memoized + * because the parser hook runs for every module in the compilation. + */ +function isReactOnRailsRSCRuntimeResource(resource: string | undefined, isServer: boolean): boolean { + const cacheKey = `${isServer}\0${resource}`; + const cached = runtimeResourceDetectionCache.get(cacheKey); + if (cached !== undefined) return cached; + const result = detectReactOnRailsRSCRuntimeResource(resource, isServer); + runtimeResourceDetectionCache.set(cacheKey, result); + return result; +} + +function detectReactOnRailsRSCRuntimeResource( + resource: string | undefined, + isServer: boolean, +): boolean { + // Fast path: the runtime module of THIS package install. + if (resource === (isServer ? clientFileNameOnServer : clientFileNameOnClient)) { + return true; + } + if (typeof resource !== 'string') return false; + + // Duplicate-install path (#43): another copy of react-on-rails-rsc in the + // module graph still counts as the runtime. Recognize it by file-name + // suffix, then confirm by walking up to a package.json whose `name` is + // `react-on-rails-rsc`. + const normalizedResource = path.normalize(resource); + const expectedSuffix = path.join( + 'react-server-dom-webpack', + isServer ? 'client.node.js' : 'client.browser.js', + ); + if (!normalizedResource.endsWith(path.sep + expectedSuffix)) return false; + + let dir = path.dirname(normalizedResource); + for (let i = 0; i < 20; i++) { + const packageJsonPath = path.join(dir, 'package.json'); + try { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { + name?: string; + }; + if (packageJson.name === 'react-on-rails-rsc') return true; + } catch (x) { + const code = (x as NodeJS.ErrnoException).code; + if (!(x instanceof SyntaxError) && code !== 'ENOENT' && code !== 'ENOTDIR') { + return false; + } + } + const parent = path.dirname(dir); + if (parent === dir) return false; + dir = parent; + } + return false; +} + +export type ClientReferenceSearchPath = { + directory: string; + recursive?: boolean; + include: RegExp; + exclude?: RegExp; +}; + +export type ClientReferencePath = string | ClientReferenceSearchPath; + +export type Options = { + isServer: boolean; + clientReferences?: ClientReferencePath | ReadonlyArray; + chunkName?: string; + clientManifestFilename?: string; + serverConsumerManifestFilename?: string; +}; + +type ModuleMetadata = { + id: string | number | null; + chunks: (string | number | null)[]; + css: string[] | null; + name: string; +}; + +/** + * Structural types for the parts of webpack's object graph the plugin + * touches. The unit-test suites drive `apply()` with hand-built mock + * compilers/compilations, and webpack's own `Module` type does not expose + * `resource`/`modules`, so structural types are both the honest contract + * and what keeps the parity oracle runnable. + */ +type FlightModule = { + resource?: string; + /** Inner modules of a ConcatenatedModule. */ + modules?: FlightModule[]; + addBlock?: (block: unknown) => void; +}; + +type FlightChunk = { + id: string | number | null; + files: Iterable; +}; + +type FlightChunkGroup = { + chunks: Iterable; + getBlocks?: () => Iterable | undefined; + blocksIterable?: Iterable; +}; + +type FlightBlock = { + dependencies?: Array<{ type?: string; request?: string }>; +} | null; + +type FlightEntrypoint = { + getRuntimeChunk(): { files: Iterable } | null; +}; + +type FlightCompilation = { + dependencyFactories: Map; + dependencyTemplates: Map; + warnings: Error[]; + outputOptions: { + publicPath?: string; + crossOriginLoading?: false | 'anonymous' | 'use-credentials'; + }; + entrypoints: { forEach(fn: (entrypoint: FlightEntrypoint) => void): void }; + chunkGroups: Iterable; + chunkGraph: { + getChunkModulesIterable(chunk: FlightChunk): Iterable; + getModuleId(module: FlightModule): string | number | null; + }; + hooks: { + processAssets: { + tap(options: { name: string; stage: number }, fn: () => void): void; + }; + }; + emitAsset(filename: string, source: unknown): void; +}; + +type FlightParser = { + state: { module: FlightModule }; + hooks: { + program: { tap(name: string, fn: () => void): void }; + }; +}; + +type FlightNormalModuleFactory = { + hooks: { + parser: { + for(type: string): { tap(name: string, fn: (parser: FlightParser) => void): void }; + }; + }; +}; + +type FlightResolver = { + resolve( + context: object, + basePath: string, + request: string, + resolveContext: object, + callback: (err: Error | null, result?: unknown) => void, + ): void; +}; + +type FlightContextModuleFactory = { + resolveDependencies( + fs: unknown, + options: { + resource: string; + resourceQuery: string; + recursive: boolean; + regExp: RegExp; + include: undefined; + exclude: RegExp | undefined; + }, + callback: (err: Error | null, dependencies?: Array<{ userRequest: string }>) => void, + ): void; +}; + +type FlightCompiler = { + context: string; + resolverFactory: { + get(type: string, options?: object): FlightResolver; + }; + inputFileSystem: unknown; + hooks: { + beforeCompile: { + tapAsync( + name: string, + fn: ( + params: { contextModuleFactory: FlightContextModuleFactory }, + callback: (err?: Error | null) => void, + ) => void, + ): void; + }; + thisCompilation: { + tap( + name: string, + fn: ( + compilation: FlightCompilation, + params: { normalModuleFactory: FlightNormalModuleFactory }, + ) => void, + ): void; + }; + make: { + tap(name: string, fn: (compilation: FlightCompilation) => void): void; + }; + }; +}; + +type ReadFileFs = { + readFile( + filePath: string, + encoding: string, + callback: (err: Error | null, content?: string) => void, + ): void; +}; + +const PLUGIN_NAME = 'React Server Plugin'; + +export class RSCWebpackPlugin { + readonly isServer: boolean; + + readonly clientReferences: ReadonlyArray; + + readonly chunkName: string; + + readonly clientManifestFilename: string; + + /** + * Accepted for option-shape compatibility with React's reference plugin; + * the previously vendored build never emitted this manifest and neither + * does this port. + */ + readonly serverConsumerManifestFilename: string; + + static __internal_isReactOnRailsRSCRuntimeResource = isReactOnRailsRSCRuntimeResource; + + constructor(options: Options) { + if (!options || typeof options.isServer !== 'boolean') { + throw new Error( + 'React Server Plugin: You must specify the isServer option as a boolean.', + ); + } + this.isServer = options.isServer; + + if (options.clientReferences) { + this.clientReferences = + typeof options.clientReferences !== 'string' && Array.isArray(options.clientReferences) + ? options.clientReferences + : [options.clientReferences as ClientReferencePath]; + } else { + this.clientReferences = [ + { + directory: '.', + recursive: true, + include: /\.(js|ts|jsx|tsx)$/, + }, + ]; + } + + if (typeof options.chunkName === 'string') { + this.chunkName = options.chunkName; + if (!/\[(index|request)\]/.test(this.chunkName)) { + this.chunkName += '[index]'; + } + } else { + this.chunkName = 'client[index]'; + } + + const defaultClientManifestFilename = this.isServer + ? 'react-server-client-manifest.json' + : 'react-client-manifest.json'; + this.clientManifestFilename = + options.clientManifestFilename || defaultClientManifestFilename; + this.serverConsumerManifestFilename = + options.serverConsumerManifestFilename || 'react-ssr-manifest.json'; + } + + apply(compiler: webpack.Compiler): void { + const _this = this; + const flightCompiler = compiler as unknown as FlightCompiler; + let resolvedClientReferences: ClientReferenceDependency[] | undefined; + let clientFileNameFound = false; + + // Phase 1: resolve every configured client reference before the + // compilation starts so the parser hook below can attach their + // AsyncDependenciesBlocks to the Flight runtime module. + flightCompiler.hooks.beforeCompile.tapAsync(PLUGIN_NAME, (params, callback) => { + const contextResolver = flightCompiler.resolverFactory.get('context', {}); + const normalResolver = flightCompiler.resolverFactory.get('normal'); + _this.resolveAllClientFiles( + flightCompiler.context, + contextResolver, + normalResolver, + flightCompiler.inputFileSystem, + params.contextModuleFactory, + (err, resolvedClientRefs) => { + if (err) { + callback(err); + return; + } + resolvedClientReferences = resolvedClientRefs; + callback(); + }, + ); + }); + + // Phase 2: when the Flight client runtime module is parsed, attach one + // named AsyncDependenciesBlock per resolved client reference, creating + // the chunk groups the manifest is later built from. + flightCompiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation, params) => { + const normalModuleFactory = params.normalModuleFactory; + compilation.dependencyFactories.set(ClientReferenceDependency, normalModuleFactory); + compilation.dependencyTemplates.set( + ClientReferenceDependency, + new NullDependency.Template(), + ); + + const handler = (parser: FlightParser) => { + parser.hooks.program.tap(PLUGIN_NAME, () => { + const module = parser.state.module; + if (!isReactOnRailsRSCRuntimeResource(module.resource, _this.isServer)) { + return; + } + clientFileNameFound = true; + if (!resolvedClientReferences) return; + for (let i = 0; i < resolvedClientReferences.length; i++) { + const dep = resolvedClientReferences[i]!; + const chunkName = _this.chunkName + .replace(/\[index\]/g, `${i}`) + .replace(/\[request\]/g, Template.toPath(dep.userRequest)); + const block = new webpack.AsyncDependenciesBlock( + { name: chunkName }, + undefined, + dep.request, + ); + block.addDependency(dep); + module.addBlock!(block); + } + }); + }; + + normalModuleFactory.hooks.parser.for('javascript/auto').tap('HarmonyModulesPlugin', handler); + normalModuleFactory.hooks.parser.for('javascript/esm').tap('HarmonyModulesPlugin', handler); + normalModuleFactory.hooks.parser + .for('javascript/dynamic') + .tap('HarmonyModulesPlugin', handler); + }); + + // Phase 3: emit the client manifest. + flightCompiler.hooks.make.tap(PLUGIN_NAME, (compilation) => { + compilation.hooks.processAssets.tap( + { + name: PLUGIN_NAME, + stage: webpack.Compilation.PROCESS_ASSETS_STAGE_REPORT, + }, + () => { + if (clientFileNameFound === false) { + compilation.warnings.push( + new webpack.WebpackError( + 'Client runtime at react-on-rails-rsc/client was not found. React Server Components module map file ' + + _this.clientManifestFilename + + ' was not created.', + ), + ); + return; + } + + const configuredCrossOriginLoading = compilation.outputOptions.crossOriginLoading; + const crossOrigin = + typeof configuredCrossOriginLoading === 'string' + ? configuredCrossOriginLoading === 'use-credentials' + ? configuredCrossOriginLoading + : 'anonymous' + : null; + + const resolvedClientFiles = new Set( + (resolvedClientReferences || []).map((ref) => ref.request), + ); + const filePathToModuleMetadata: Record = {}; + const manifest = { + moduleLoading: { + prefix: compilation.outputOptions.publicPath || '', + crossOrigin, + }, + filePathToModuleMetadata, + }; + + // Runtime-chunk filtering: collect the files of every + // entrypoint's runtime chunk so the client build can exclude + // them from per-module chunk lists. + const runtimeChunkFiles = new Set(); + compilation.entrypoints.forEach((entrypoint) => { + const runtimeChunk = entrypoint.getRuntimeChunk(); + if (runtimeChunk) { + for (const runtimeFile of runtimeChunk.files) { + runtimeChunkFiles.add(runtimeFile); + } + } + }); + + let cssPrefix = + typeof compilation.outputOptions.publicPath === 'string' && + compilation.outputOptions.publicPath !== 'auto' + ? compilation.outputOptions.publicPath + : null; + if (cssPrefix && !cssPrefix.endsWith('/')) { + cssPrefix += '/'; + } + + let missingClientReferenceBlocksWarningEmitted = false; + + // Records every module of `chunkGroup` whose resource is in + // `chunkResolvedClientFiles`, listing the chunk group's own + // chunks (and CSS files) in the manifest entry. Merges into an + // existing manifest entry (chunks deduped by chunk id, CSS by + // URL) instead of overwriting it. + const recordChunkGroup = ( + chunkGroup: FlightChunkGroup, + chunkResolvedClientFiles: Set, + ): void => { + const chunks: (string | number | null)[] = []; + const cssFiles: string[] = []; + + const recordModule = (id: string | number | null, module: FlightModule): void => { + if (!module.resource || !chunkResolvedClientFiles.has(module.resource)) { + return; + } + const href = url.pathToFileURL(module.resource).href; + const existing = filePathToModuleMetadata[href]; + if (existing) { + const seenChunkIds = new Set(); + for (let i = 0; i < existing.chunks.length; i += 2) { + seenChunkIds.add(existing.chunks[i]!); + } + for (let i = 0; i < chunks.length; i += 2) { + if (!seenChunkIds.has(chunks[i]!)) { + existing.chunks.push(chunks[i]!, chunks[i + 1]!); + } + } + if (existing.css == null) existing.css = []; + for (const cssFile of cssFiles) { + if (existing.css.indexOf(cssFile) === -1) { + existing.css.push(cssFile); + } + } + } else { + filePathToModuleMetadata[href] = { + id, + chunks: chunks.slice(), + css: cssFiles.slice(), + name: '*', + }; + } + }; + + // CSS-before-JS scan fix: record CSS files and the chunk's JS + // file independently of their order inside `chunk.files`. + for (const chunk of chunkGroup.chunks) { + let recordedJS = false; + for (const file of chunk.files) { + if ( + file.endsWith('.css') && + !file.endsWith('.hot-update.css') && + cssPrefix !== null && + (_this.isServer || !runtimeChunkFiles.has(file)) + ) { + const cssUrl = cssPrefix + file; + if (cssFiles.indexOf(cssUrl) === -1) cssFiles.push(cssUrl); + } else if ( + (file.endsWith('.js') || file.endsWith('.mjs')) && + !file.endsWith('.hot-update.js') && + !file.endsWith('.hot-update.mjs') && + (_this.isServer || !runtimeChunkFiles.has(file)) && + !recordedJS + ) { + chunks.push(chunk.id, file); + recordedJS = true; + } + } + } + + for (const chunk of chunkGroup.chunks) { + for (const module of compilation.chunkGraph.getChunkModulesIterable(chunk)) { + const moduleId = compilation.chunkGraph.getModuleId(module); + recordModule(moduleId, module); + if (module.modules) { + for (const concatenatedMod of module.modules) { + recordModule(moduleId, concatenatedMod); + } + } + } + } + }; + + if (_this.isServer) { + // The server bundle has no per-reference chunk groups; record + // every chunk group that contains a client reference. + for (const chunkGroup of compilation.chunkGroups) { + recordChunkGroup(chunkGroup, resolvedClientFiles); + } + } else { + // Match chunk groups through the ClientReferenceDependency + // attached to the AsyncDependenciesBlock that created them, so + // each client component's manifest entry lists exactly the + // chunks of the one chunk group webpack created for it instead + // of the union of every chunk group the module appears in. + const chunkGroupsWithBlocks: FlightChunkGroup[] = []; + for (const chunkGroup of compilation.chunkGroups) { + // Prefer `getBlocks()`; fall back to the `blocksIterable` + // getter for webpack-compatible bundlers or builds where + // `getBlocks` is unavailable (webpack 5 proper has both). + const blocks = + typeof chunkGroup.getBlocks === 'function' + ? chunkGroup.getBlocks() + : chunkGroup.blocksIterable; + if (!blocks) { + if (!missingClientReferenceBlocksWarningEmitted) { + missingClientReferenceBlocksWarningEmitted = true; + compilation.warnings.push( + new webpack.WebpackError( + 'Client reference blocks were unavailable for one or more chunk groups. ' + + 'React Server Components client manifest entries for affected chunk groups were skipped.', + ), + ); + } + continue; + } + chunkGroupsWithBlocks.push(chunkGroup); + const chunkResolvedClientFiles = new Set(); + for (const block of blocks) { + const dependencies = block && block.dependencies; + if (!dependencies) continue; + for (let i = 0; i < dependencies.length; i++) { + const dep = dependencies[i]!; + // The `type` check matches dependencies created by a + // duplicate copy of this plugin module (e.g. two + // node_modules instances), where `instanceof` fails. + if ( + (dep instanceof ClientReferenceDependency || + dep.type === 'client-reference') && + typeof dep.request === 'string' && + resolvedClientFiles.has(dep.request) + ) { + chunkResolvedClientFiles.add(dep.request); + } + } + } + if (chunkResolvedClientFiles.size > 0) { + recordChunkGroup(chunkGroup, chunkResolvedClientFiles); + } + } + + // Client references whose block-created chunk group ended up + // without any chunk containing them (webpack drops modules + // that are already available in a parent chunk, e.g. a client + // component eagerly imported by an entry) have no manifest + // entry yet, and Flight fails at runtime on missing entries. + // Fall back to scanning the chunk groups whose blocks were + // available. The runtime-chunk exclusion keeps the runtime + // chunk out of the recorded chunk list; with the default + // config that is the entry chunk itself, while with a split + // `runtimeChunk` the already-loaded entry chunk is still + // listed (webpack's chunk loader treats it as a no-op). + const unrecordedClientFiles = new Set(); + resolvedClientFiles.forEach((file) => { + if (!filePathToModuleMetadata[url.pathToFileURL(file).href]) { + unrecordedClientFiles.add(file); + } + }); + if (unrecordedClientFiles.size > 0) { + // Prune recorded files between groups so a later group + // cannot union its chunks into an entry the fallback + // already created — the over-preload behavior the block + // matching above exists to eliminate. + for ( + let i = 0; + i < chunkGroupsWithBlocks.length && unrecordedClientFiles.size > 0; + i++ + ) { + recordChunkGroup(chunkGroupsWithBlocks[i]!, unrecordedClientFiles); + unrecordedClientFiles.forEach((file) => { + if (filePathToModuleMetadata[url.pathToFileURL(file).href]) { + unrecordedClientFiles.delete(file); + } + }); + } + // Anything still unrecorded has no manifest entry and will + // crash Flight if it gets rendered ("Could not find the + // module in React Client Manifest"). Surface the files at + // build time as a warning — consistent with the other + // manifest warnings above (including the fatal "client + // runtime not found" case) and with upstream + // ReactFlightWebpackPlugin, which warns rather than failing + // the build. A client reference that is never rendered will + // not crash, so this stays a warning, not a hard error. + if (unrecordedClientFiles.size > 0) { + const missing = Array.from(unrecordedClientFiles); + compilation.warnings.push( + new webpack.WebpackError( + 'React Server Components: no client manifest entry could be created for ' + + missing.length + + ' client reference(s). Rendering them will fail at runtime with a missing manifest entry:\n ' + + missing.join('\n '), + ), + ); + } + } + } + + compilation.emitAsset( + _this.clientManifestFilename, + new webpack.sources.RawSource(JSON.stringify(manifest, null, 2), false), + ); + }, + ); + }); + } + + /** + * Resolves every configured `clientReferences` entry to a + * `ClientReferenceDependency`: + * - string entries are direct file references, included unconditionally; + * - search-path entries are expanded through the context module factory + * and filtered to files containing a `"use client"` directive. + */ + resolveAllClientFiles( + context: string, + contextResolver: FlightResolver, + normalResolver: FlightResolver, + fs: unknown, + contextModuleFactory: FlightContextModuleFactory, + callback: (err: Error | null, result?: ClientReferenceDependency[]) => void, + ): void { + asyncLib.map( + this.clientReferences, + (clientReferencePath, cb) => { + if (typeof clientReferencePath === 'string') { + cb(null, [new ClientReferenceDependency(clientReferencePath)]); + return; + } + contextResolver.resolve( + {}, + context, + clientReferencePath.directory, + {}, + (err, resolvedDirectory) => { + if (err) return cb(err); + contextModuleFactory.resolveDependencies( + fs, + { + resource: resolvedDirectory as string, + resourceQuery: '', + recursive: + clientReferencePath.recursive === undefined + ? true + : clientReferencePath.recursive, + regExp: clientReferencePath.include, + include: undefined, + exclude: clientReferencePath.exclude, + }, + (err2, deps) => { + if (err2) return cb(err2); + const clientRefDeps = (deps || []).map((dep) => { + const request = path.join(resolvedDirectory as string, dep.userRequest); + const clientRefDep = new ClientReferenceDependency(request); + clientRefDep.userRequest = dep.userRequest; + return clientRefDep; + }); + asyncLib.filter( + clientRefDeps, + (clientRefDep, filterCb) => { + normalResolver.resolve( + {}, + context, + clientRefDep.request, + {}, + (err3, resolvedPath) => { + if (err3 || typeof resolvedPath !== 'string') { + return filterCb(null, false); + } + (fs as ReadFileFs).readFile(resolvedPath, 'utf-8', (err4, content) => { + if (err4 || typeof content !== 'string') { + return filterCb(null, false); + } + filterCb(null, hasUseClientDirective(content)); + }); + }, + ); + }, + cb, + ); + }, + ); + }, + ); + }, + (err, result) => { + if (err) return callback(err); + const flattened: ClientReferenceDependency[] = []; + for (let i = 0; i < (result || []).length; i++) { + flattened.push(...result![i]!); + } + callback(null, flattened); + }, + ); + } +} + +export default RSCWebpackPlugin; diff --git a/tests/react-flight-webpack-plugin-client-reference-chunks.test.ts b/tests/react-flight-webpack-plugin-client-reference-chunks.test.ts index 06d075c..af52998 100644 --- a/tests/react-flight-webpack-plugin-client-reference-chunks.test.ts +++ b/tests/react-flight-webpack-plugin-client-reference-chunks.test.ts @@ -1,6 +1,6 @@ import { pathToFileURL } from 'url'; -const ReactFlightWebpackPlugin = require('../src/react-server-dom-webpack/cjs/react-server-dom-webpack-plugin.js'); +const { RSCWebpackPlugin: ReactFlightWebpackPlugin } = require('../src/webpack/RSCWebpackPlugin'); type AsyncHookCallback = (params: unknown, callback: (error?: Error | null) => void) => void; type SyncHookCallback = (...args: unknown[]) => void; diff --git a/tests/react-flight-webpack-plugin-css-order.test.ts b/tests/react-flight-webpack-plugin-css-order.test.ts index 1413b82..256fca8 100644 --- a/tests/react-flight-webpack-plugin-css-order.test.ts +++ b/tests/react-flight-webpack-plugin-css-order.test.ts @@ -1,6 +1,6 @@ import { pathToFileURL } from 'url'; -const ReactFlightWebpackPlugin = require('../src/react-server-dom-webpack/cjs/react-server-dom-webpack-plugin.js'); +const { RSCWebpackPlugin: ReactFlightWebpackPlugin } = require('../src/webpack/RSCWebpackPlugin'); type AsyncHookCallback = (params: unknown, callback: (error?: Error | null) => void) => void; type SyncHookCallback = (...args: unknown[]) => void; diff --git a/tests/rspack-compat/static-analysis.test.ts b/tests/rspack-compat/static-analysis.test.ts index 052c1ef..3f777c8 100644 --- a/tests/rspack-compat/static-analysis.test.ts +++ b/tests/rspack-compat/static-analysis.test.ts @@ -145,20 +145,20 @@ describe('Static analysis: no webpack-specific runtime imports', () => { describe('Static analysis: known-incompatible WebpackPlugin is excluded', () => { /** * Sanity check — we are NOT claiming WebpackPlugin works with rspack. - * This test documents that we know it uses webpack/lib/* internals. + * This test documents that we know its plugin implementation uses + * webpack-only value-level APIs (dependencies.ModuleDependency, + * AsyncDependenciesBlock, Template) that rspack does not expose to JS. * If someone removes those usages, this test fails and the README should be updated. */ - it('WebpackPlugin.ts or its vendored plugin reaches into webpack/lib/*', () => { - const vendoredPluginPath = path.join( - SRC_DIR, - 'react-server-dom-webpack/cjs/react-server-dom-webpack-plugin.js', - ); - const raw = fs.readFileSync(vendoredPluginPath, 'utf8'); + it('WebpackPlugin.ts implementation (webpack/RSCWebpackPlugin.ts) uses webpack-only value APIs', () => { + const pluginPath = path.join(SRC_DIR, 'webpack/RSCWebpackPlugin.ts'); + const raw = fs.readFileSync(pluginPath, 'utf8'); // This is a SANITY check — we expect these to exist because they are the // whole reason rspack compat is hard for the plugin. - expect(raw).toMatch(/require\(["']webpack\/lib\/dependencies\/ModuleDependency["']\)/); - expect(raw).toMatch(/require\(["']webpack\/lib\/dependencies\/NullDependency["']\)/); - expect(raw).toMatch(/require\(["']webpack\/lib\/Template["']\)/); - expect(raw).toMatch(/require\(["']webpack["']\)/); + expect(raw).toMatch(/import\s+webpack\s*=\s*require\(['"]webpack['"]\)/); + expect(raw).toMatch(/webpack\.dependencies\.ModuleDependency/); + expect(raw).toMatch(/webpack\.dependencies\.NullDependency/); + expect(raw).toMatch(/webpack\.Template/); + expect(raw).toMatch(/webpack\.AsyncDependenciesBlock/); }); }); diff --git a/tests/webpack-plugin-runtime-detection.test.ts b/tests/webpack-plugin-runtime-detection.test.ts index f1c5043..1636581 100644 --- a/tests/webpack-plugin-runtime-detection.test.ts +++ b/tests/webpack-plugin-runtime-detection.test.ts @@ -2,8 +2,10 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -const ReactFlightWebpackPlugin = require('../src/react-server-dom-webpack/cjs/react-server-dom-webpack-plugin.js') as { - __internal_isReactOnRailsRSCRuntimeResource(resource: string | undefined, isServer: boolean): boolean; +const { RSCWebpackPlugin: ReactFlightWebpackPlugin } = require('../src/webpack/RSCWebpackPlugin') as { + RSCWebpackPlugin: { + __internal_isReactOnRailsRSCRuntimeResource(resource: string | undefined, isServer: boolean): boolean; + }; }; const tempRoots: string[] = []; diff --git a/tests/webpack-plugin/helpers/runWebpackWithPlugin.js b/tests/webpack-plugin/helpers/runWebpackWithPlugin.js index 89af881..3b7d47a 100644 --- a/tests/webpack-plugin/helpers/runWebpackWithPlugin.js +++ b/tests/webpack-plugin/helpers/runWebpackWithPlugin.js @@ -1,7 +1,7 @@ #!/usr/bin/env node /** * Child-process runner that compiles a fixture with real webpack + - * ReactFlightWebpackPlugin (the fork in src/react-server-dom-webpack). + * RSCWebpackPlugin (the TypeScript plugin built to dist/webpack). * * Called from tests/webpack-plugin/helpers/compile.ts. Reads an args JSON * file, runs webpack, writes result JSON to stdout. Mirrors the pattern of @@ -46,7 +46,19 @@ const fs = require('fs'); const path = require('path'); const webpack = require('webpack'); -const ReactFlightWebpackPlugin = require('../../../src/react-server-dom-webpack/cjs/react-server-dom-webpack-plugin.js'); +const pluginDistPath = path.resolve(__dirname, '../../../dist/webpack/RSCWebpackPlugin.js'); +if (!fs.existsSync(pluginDistPath)) { + process.stdout.write( + JSON.stringify({ + ok: false, + errors: [ + `Missing ${path.relative(path.resolve(__dirname, '../../..'), pluginDistPath)}. Run \`yarn build\` first.`, + ], + }), + ); + process.exit(1); +} +const { RSCWebpackPlugin: ReactFlightWebpackPlugin } = require(pluginDistPath); const argsFile = process.argv[2]; if (!argsFile) { From 3fa3fda0f769a05081d9e34a1f5845173dd9665b Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Fri, 12 Jun 2026 01:39:48 -1000 Subject: [PATCH 2/3] Apply /simplify cleanups to RSCWebpackPlugin.ts Remove the _this alias (all usages are in arrow callbacks), drop a redundant typeof-string guard before Array.isArray, use a Set for the cssFiles accumulator (same insertion-order dedup), and convert two index-unused C-style loops to for...of. Behavior-preserving; full suite re-verified. Co-Authored-By: Claude Fable 5 --- src/webpack/RSCWebpackPlugin.ts | 38 +++++++++++++++------------------ 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/src/webpack/RSCWebpackPlugin.ts b/src/webpack/RSCWebpackPlugin.ts index 7dd4bc5..4cce719 100644 --- a/src/webpack/RSCWebpackPlugin.ts +++ b/src/webpack/RSCWebpackPlugin.ts @@ -318,10 +318,9 @@ export class RSCWebpackPlugin { this.isServer = options.isServer; if (options.clientReferences) { - this.clientReferences = - typeof options.clientReferences !== 'string' && Array.isArray(options.clientReferences) - ? options.clientReferences - : [options.clientReferences as ClientReferencePath]; + this.clientReferences = Array.isArray(options.clientReferences) + ? options.clientReferences + : [options.clientReferences as ClientReferencePath]; } else { this.clientReferences = [ { @@ -351,7 +350,6 @@ export class RSCWebpackPlugin { } apply(compiler: webpack.Compiler): void { - const _this = this; const flightCompiler = compiler as unknown as FlightCompiler; let resolvedClientReferences: ClientReferenceDependency[] | undefined; let clientFileNameFound = false; @@ -362,7 +360,7 @@ export class RSCWebpackPlugin { flightCompiler.hooks.beforeCompile.tapAsync(PLUGIN_NAME, (params, callback) => { const contextResolver = flightCompiler.resolverFactory.get('context', {}); const normalResolver = flightCompiler.resolverFactory.get('normal'); - _this.resolveAllClientFiles( + this.resolveAllClientFiles( flightCompiler.context, contextResolver, normalResolver, @@ -393,14 +391,14 @@ export class RSCWebpackPlugin { const handler = (parser: FlightParser) => { parser.hooks.program.tap(PLUGIN_NAME, () => { const module = parser.state.module; - if (!isReactOnRailsRSCRuntimeResource(module.resource, _this.isServer)) { + if (!isReactOnRailsRSCRuntimeResource(module.resource, this.isServer)) { return; } clientFileNameFound = true; if (!resolvedClientReferences) return; for (let i = 0; i < resolvedClientReferences.length; i++) { const dep = resolvedClientReferences[i]!; - const chunkName = _this.chunkName + const chunkName = this.chunkName .replace(/\[index\]/g, `${i}`) .replace(/\[request\]/g, Template.toPath(dep.userRequest)); const block = new webpack.AsyncDependenciesBlock( @@ -433,7 +431,7 @@ export class RSCWebpackPlugin { compilation.warnings.push( new webpack.WebpackError( 'Client runtime at react-on-rails-rsc/client was not found. React Server Components module map file ' + - _this.clientManifestFilename + + this.clientManifestFilename + ' was not created.', ), ); @@ -494,7 +492,7 @@ export class RSCWebpackPlugin { chunkResolvedClientFiles: Set, ): void => { const chunks: (string | number | null)[] = []; - const cssFiles: string[] = []; + const cssFiles = new Set(); const recordModule = (id: string | number | null, module: FlightModule): void => { if (!module.resource || !chunkResolvedClientFiles.has(module.resource)) { @@ -522,7 +520,7 @@ export class RSCWebpackPlugin { filePathToModuleMetadata[href] = { id, chunks: chunks.slice(), - css: cssFiles.slice(), + css: Array.from(cssFiles), name: '*', }; } @@ -537,15 +535,14 @@ export class RSCWebpackPlugin { file.endsWith('.css') && !file.endsWith('.hot-update.css') && cssPrefix !== null && - (_this.isServer || !runtimeChunkFiles.has(file)) + (this.isServer || !runtimeChunkFiles.has(file)) ) { - const cssUrl = cssPrefix + file; - if (cssFiles.indexOf(cssUrl) === -1) cssFiles.push(cssUrl); + cssFiles.add(cssPrefix + file); } else if ( (file.endsWith('.js') || file.endsWith('.mjs')) && !file.endsWith('.hot-update.js') && !file.endsWith('.hot-update.mjs') && - (_this.isServer || !runtimeChunkFiles.has(file)) && + (this.isServer || !runtimeChunkFiles.has(file)) && !recordedJS ) { chunks.push(chunk.id, file); @@ -567,7 +564,7 @@ export class RSCWebpackPlugin { } }; - if (_this.isServer) { + if (this.isServer) { // The server bundle has no per-reference chunk groups; record // every chunk group that contains a client reference. for (const chunkGroup of compilation.chunkGroups) { @@ -605,8 +602,7 @@ export class RSCWebpackPlugin { for (const block of blocks) { const dependencies = block && block.dependencies; if (!dependencies) continue; - for (let i = 0; i < dependencies.length; i++) { - const dep = dependencies[i]!; + for (const dep of dependencies) { // The `type` check matches dependencies created by a // duplicate copy of this plugin module (e.g. two // node_modules instances), where `instanceof` fails. @@ -683,7 +679,7 @@ export class RSCWebpackPlugin { } compilation.emitAsset( - _this.clientManifestFilename, + this.clientManifestFilename, new webpack.sources.RawSource(JSON.stringify(manifest, null, 2), false), ); }, @@ -772,8 +768,8 @@ export class RSCWebpackPlugin { (err, result) => { if (err) return callback(err); const flattened: ClientReferenceDependency[] = []; - for (let i = 0; i < (result || []).length; i++) { - flattened.push(...result![i]!); + for (const deps of result || []) { + flattened.push(...deps); } callback(null, flattened); }, From 6d568aaa234e84fac10e079482c7209fd3f03ae1 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Fri, 12 Jun 2026 11:24:51 -1000 Subject: [PATCH 3/3] Point changelog entry at PR #87 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa6298f..89bdff9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this package will be documented in this file. ## [Unreleased] ### Changed -- Ported the webpack RSC plugin from the vendored built JavaScript artifact (`src/react-server-dom-webpack/cjs/react-server-dom-webpack-plugin.js`) to first-class TypeScript source at `src/webpack/RSCWebpackPlugin.ts`, preserving full behavior parity (server manifest emission, CSS/JS chunk scanning, runtime-chunk filtering, dependency-type chunk-group manifest construction with eager-import fallback, duplicate-package runtime detection, and hot-update CSS exclusion). The `./WebpackPlugin`, `./WebpackLoader`, and `./RSCReferenceDiscoveryPlugin` exports are unchanged; the vendored plugin file remains in the package but is no longer used by any export path. ([#75]) +- Ported the webpack RSC plugin from the vendored built JavaScript artifact (`src/react-server-dom-webpack/cjs/react-server-dom-webpack-plugin.js`) to first-class TypeScript source at `src/webpack/RSCWebpackPlugin.ts`, preserving full behavior parity (server manifest emission, CSS/JS chunk scanning, runtime-chunk filtering, dependency-type chunk-group manifest construction with eager-import fallback, duplicate-package runtime detection, and hot-update CSS exclusion). The `./WebpackPlugin`, `./WebpackLoader`, and `./RSCReferenceDiscoveryPlugin` exports are unchanged; the vendored plugin file remains in the package but is no longer used by any export path. ([#87]) ## [19.0.5-rc.7] - 2026-06-09 @@ -85,4 +85,4 @@ All notable changes to this package will be documented in this file. [19.0.5-rc.1]: https://github.com/shakacode/react_on_rails_rsc/releases/tag/19.0.5-rc.1 [#52]: https://github.com/shakacode/react_on_rails_rsc/pull/52 -[#75]: https://github.com/shakacode/react_on_rails_rsc/pull/75 +[#87]: https://github.com/shakacode/react_on_rails_rsc/pull/87