Skip to content

Implement RSCRspackPlugin: rspack-native manifest emitter #30

@AbanoubGhadban

Description

@AbanoubGhadban

Summary

Add first-class Rspack support to react-on-rails-rsc by shipping a new RSCRspackPlugin that emits the same manifest schema the package already consumes. Today only RSCWebpackPlugin exists, and it cannot run under rspack because it reaches into webpack/lib/dependencies/* internals that rspack does not expose.

Design and investigation are fully captured in shakacode/react_on_rails#3147. That PR lands three docs under .claude/docs/:

  • rspack-rsc-support-state.md — state of official rspack RSC support (v2 RC, built-in but experimental and tightly coupled to Rsbuild / Layers)
  • rsc-rspack-implementation-plan.md — original high-level investigation
  • rsc-rspack-plugin-implementation.md — the decision doc for the plugin design

Why this is scoped to just the plugin

Empirically verified on branch test/rspack-compatibility (31 tests across 7 suites, including a full Flight encode→decode round trip where both sides are bundled by rspack):

File Rspack-compatible?
src/WebpackLoader.ts Yes — only uses this.resourcePath
src/server.node.ts Yes — Node built-ins + react-dom only
src/client.node.ts Yes — uses __webpack_require__ / __webpack_chunk_load__ (rspack emits these identically)
src/client.browser.ts Yes — runtime globals only; __webpack_require__.u is monkey-patchable
src/types.ts Yes
src/WebpackPlugin.ts No — wraps a vendored plugin that imports webpack/lib/dependencies/ModuleDependency, webpack/lib/dependencies/NullDependency, webpack/lib/Template, and calls contextModuleFactory.resolveDependencies() which rspack does not expose

The first runtime failure under rspack is deterministic:

TypeError: contextModuleFactory.resolveDependencies is not a function
  at react-server-dom-webpack-plugin.js:345:38

What we are explicitly NOT doing

  • Not adopting rspack's built-in RSC (rspackExperiments.reactServerComponents + experiments.rsc.createPlugins()). It is coupled to rspack's Layers machinery + the builtin:swc-loader flag + the Rsbuild path, and is still experimental.
  • Not depending on react-server-dom-rspack (ByteDance/SyMind wrapper). It ships a 394-line chunk-cache patch over the vendored react-server-dom-webpack and pins us to their release cadence.
  • Not changing the manifest schema. The new plugin emits the exact JSON shape that server.node.ts, client.node.ts, and client.browser.ts already consume — so zero runtime changes anywhere else.

Plugin design (summary)

Dual-path behind the existing RSCWebpackPlugin facade:

  • compiler.rspack / compiler.webpack detection at apply() time
  • Webpack path unchanged (back-compat preserved)
  • Rspack path uses only standard public bundler APIs

Discovery technique is a strict improvement over the current FS walk in both plugins:

  1. Loader tags modules during parse. A tiny loader (enforce: 'pre', matches /\.[cm]?[jt]sx?$/) checks each source for a top-of-file "use client" directive (accepting both quote styles, optional semicolon, leading whitespace / BOM / shebang). If present, the loader records this.resourcePath on a compilation-scoped Set via a well-known key (not a module-level singleton — avoids clashes in parallel tests).
  2. Plugin collects the set in processAssets at stage PROCESS_ASSETS_STAGE_REPORT. By then every reachable module has been parsed and (if client) tagged.
  3. Per-module chunk lookup via compilation.chunkGraph.getModuleChunks(module) + getModuleId(module). No need to walk all chunk groups; no need for a custom ModuleDependency subclass.
  4. Emit the manifest via compilation.emitAsset + sources.RawSource.

Advantages over the current FS walk:

  • No directory / include regex; works with any resolver config (aliases, node_modules, conditional exports, virtual modules)
  • Dead code is excluded automatically (unreachable "use client" files are never parsed → never tagged)
  • Single pass, no double I/O

Acceptance criteria

  • src/react-server-dom-rspack/{loader.ts,plugin.ts,shared.ts} — new files, self-contained
  • src/WebpackPlugin.ts dispatches to rspack path when compiler.rspack or bundler.rspackVersion is present (back-compat: pre-existing constructors keep behaving identically on webpack)
  • Manifest JSON is byte-compatible with the webpack plugin's output (filePathToModuleMetadata with file:// URL keys, moduleLoading.prefix from publicPath, crossOrigin derived from outputOptions.crossOriginLoading)
  • Test suite covering: manifest emission, top-level shape, directive edge cases, multiple clients, dead-code exclusion, per-entry shape, option validation
  • Package exports add react-on-rails-rsc/RspackPlugin and react-on-rails-rsc/RspackLoader
  • Handles ConcatenatedModule (scope-hoisted) correctly — walk module.modules for inner resources

Phased implementation (from the design doc)

  1. Prototype — minimal plugin + loader, verify unknowns listed below
  2. Webpack refactor — parameterize vendored plugin to take an injected bundler handle (no behavior change)
  3. Rspack plugin — production implementation
  4. Chunk-cache patch (conditional) — only if the prototype reproduces the bug that react-server-dom-rspack patched around
  5. Generator updates in react_on_rails
  6. Testing infra + docs

This repo owns phases 1–4; phases 5–6 happen in react_on_rails.

Known unknowns to resolve in the prototype

  1. Which built-in dep type to pass to AsyncDependenciesBlock.addDependency() if/when we decide to force per-client-file chunk splits (likely deferrable — the plugin's initial version can rely on rspack's existing splitChunks behavior and still produce a correct manifest).
  2. Whether the chunkCache patch from react-server-dom-rspack is required for React on Rails. Tested by exercising concurrent client-component loads in the prototype; apply the patch only if the bug reproduces.
  3. Whether compilation.chunkGraph.getModuleChunks(module) returns the right chunks for modules ending up in named async chunks.
  4. Whether the tagging loader coexists cleanly with users' other SWC/Babel loaders (must run first — enforce: 'pre').
  5. Module ID format under rspack (plain ./src/Dialog.tsx vs. layer-prefixed (server-side-rendering)/./src/Dialog.tsx) and whether it round-trips through __webpack_require__(id) at runtime.
  6. compilation.entrypoints.forEach vs. compilation.chunkGroups.forEach semantics on rspack.
  7. outputOptions.crossOriginLoading defaults on rspack.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions