diff --git a/CHANGELOG.md b/CHANGELOG.md index cace1cb..89bdff9 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. ([#87]) + ## [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 +[#87]: https://github.com/shakacode/react_on_rails_rsc/pull/87 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..4cce719 --- /dev/null +++ b/src/webpack/RSCWebpackPlugin.ts @@ -0,0 +1,780 @@ +/** + * 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 = 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 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 = new Set(); + + 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: Array.from(cssFiles), + 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)) + ) { + 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)) && + !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 (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. + 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 (const deps of result || []) { + flattened.push(...deps); + } + 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) {