Skip to content

Extract and own the webpack RSC plugin as TypeScript source#87

Merged
justin808 merged 3 commits into
mainfrom
jg/56-extract-webpack-plugin-ts
Jun 12, 2026
Merged

Extract and own the webpack RSC plugin as TypeScript source#87
justin808 merged 3 commits into
mainfrom
jg/56-extract-webpack-plugin-ts

Conversation

@justin808

@justin808 justin808 commented Jun 12, 2026

Copy link
Copy Markdown
Member

Closes #56

Summary

Ports the webpack React Server Components 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, mirroring the structure of the already-owned src/react-server-dom-rspack/plugin.ts. The public wrapper in src/WebpackPlugin.ts now instantiates the TS plugin instead of require-ing the vendored build. "use client" directive detection reuses the shared hasUseClientDirective helper from src/clientReferences.ts.

The vendored plugin file and all vendored RUNTIME files are untouched (runtime swap is #60). The vendored plugin still ships in dist/ (via allowJs copy) but is no longer used by any export path — reverting this squash commit fully restores the old wiring.

Parity checklist (every item ported and verified)

Item Status Verification
Server-build support + server manifest emission (fork patches) Ported isServer branch records every chunk group; react-server-client-manifest.json default. Pinned by tests/react-flight-webpack-plugin-client-reference-chunks.test.ts ("keeps generating server manifest metadata…") and tests/webpack-plugin/plugin-integration.test.ts ("emits server manifest entries from the single merged server bundle").
CSS-before-JS chunk scan fix Ported CSS and JS files recorded independently of order in chunk.files (recordedJS flag). Pinned by tests/react-flight-webpack-plugin-css-order.test.ts (#44 regression tests).
Runtime-chunk filtering from per-module chunk lists Ported runtimeChunkFiles set from entrypoint.getRuntimeChunk(); client JS/CSS filtered. Pinned by css-order + integration suites.
#54 dependency-type chunk-group manifest + eager-import fallback + blocks-unavailable warning Ported Block matching via ClientReferenceDependency (instanceof + type === 'client-reference' duplicate-copy fallback), unrecorded-files fallback loop with per-iteration pruning, warning texts byte-identical. Pinned by tests/webpack-plugin/plugin-integration.test.ts (eager-import, client-imports-client, multi-entry, split-shared fixtures) and the chunk-selection unit suite.
#52 runtime-chunk CSS + .hot-update.css exclusion (client); server retains runtime CSS Ported `(isServer
#47 RSC-graph-derived client references Ported The plugin-side half of #47 (block-derived chunkResolvedClientFiles narrowing + blocksIterable fallback + warning) is in the port; RSCReferenceDiscoveryPlugin/WebpackLoader halves were already TS and are unchanged.
#43 duplicate-package-install runtime detection Ported Exact-path fast check + path-suffix + 20-level package.json walk, memoized; static __internal_isReactOnRailsRSCRuntimeResource kept. Pinned by tests/webpack-plugin-runtime-detection.test.ts (doppelganger-install cases).
#42 default clientReferences exclusions Unchanged Lives in the src/WebpackPlugin.ts wrapper (DEFAULT_CLIENT_REFERENCES_INCLUDE/EXCLUDE), which is preserved verbatim; the inner plugin keeps the upstream default (`{directory: '.', include: /.(js
#23 chunk-merge fix — superseded by #54? Verified: NOT fully superseded; ported #54 narrowed which chunk groups are recorded on the client path, but the merge-instead-of-overwrite semantics in recordModule (commit 8f09a6b) are still load-bearing: the server path records every chunk group, so a client reference appearing in several groups merges chunk lists (dedup by chunk id) and CSS (dedup by URL). Pinned by the existing unit test "dedupes CSS when merging a client reference recorded from several server chunk groups" and the client-path fallback loop. The merge code is therefore ported as-is, with a comment.

Intentional, behavior-reviewed deviations

  1. "use client" detection in resolveAllClientFiles now uses the shared hasUseClientDirective (acorn-loose, ecmaVersion: 'latest', allowHashBang: true, directive-terminator check) instead of the vendored inline copy (ecmaVersion: '2024', no hashbang support, no terminator check). The shared helper is the one already used by WebpackLoader/RSCReferenceDiscoveryPlugin/rspack plugin, so discovery is now consistent across all entry points. All fixtures pass unchanged.
  2. The plugin uses webpack's public exports (webpack.dependencies.ModuleDependency, webpack.dependencies.NullDependency, webpack.Template, webpack.AsyncDependenciesBlock) instead of deep webpack/lib/* requires. Same classes, same runtime objects; tests/rspack-compat/static-analysis.test.ts sanity check updated to document the new (still webpack-only) API usage.
  3. AsyncDependenciesBlock loc argument is undefined instead of null (both falsy; webpack stores it opaquely).

Test wiring changes (no fixture-expectation changes)

  • tests/react-flight-webpack-plugin-client-reference-chunks.test.ts, tests/react-flight-webpack-plugin-css-order.test.ts, tests/webpack-plugin-runtime-detection.test.ts: import path switched from the vendored CJS file to ../src/webpack/RSCWebpackPlugin (same assertions, same fixtures).
  • tests/webpack-plugin/helpers/runWebpackWithPlugin.js: requires dist/webpack/RSCWebpackPlugin.js (mirroring tests/rspack-plugin/helpers/runRspackWithPlugin.js, which already requires from dist/), with the same "run yarn build first" guard.
  • tests/rspack-compat/static-analysis.test.ts: the "known-incompatible WebpackPlugin" sanity check now reads the TS source and asserts its webpack-only value-API usage.
  • package.json build-if-needed: adds dist/webpack/RSCWebpackPlugin.js to the artifact checks so prepare/prepack rebuild when it is missing.

ZERO manifest/fixture expectation changes anywhere.

npm pack artifact diff (branch vs clean main clone, both --dry-run --json --ignore-scripts after full build)

  • Main: 201 files. Branch: 205 files. Diff is exactly the 4 new build outputs:
    • dist/webpack/RSCWebpackPlugin.js / .js.map / .d.ts / .d.ts.map
  • package.json exports, dependencies, peerDependencies: byte-identical (verified programmatically); only scripts.build-if-needed differs.
  • The vendored plugin file still ships (unchanged), so the runtime swap (Move the runtime to React 19.2.x (stock npm package, or 2-patch rebuild fallback) #60) and rollback story are unaffected.

Local validation

  • yarn build (tsc typecheck): clean.
  • yarn test (both halves): 17 suites / 158 passed (non-RSC half) + all RSC suites passed, exit 0 — matches the green baseline on main.
  • All files end with a newline.

Review gates and Codex Decision Log

Codex review (codex review --base origin/main, run from the worktree): completed with zero findings — "I did not identify any discrete, actionable correctness issues in the diff. The TypeScript port appears to preserve the vendored plugin behavior and the export path is updated consistently."

Claude code-review pass (claude -p '/code-review high' from the worktree, targeting the branch diff): 5 findings, all triaged, none confirmed as in-scope blockers:

  1. "CI never runs yarn build, integration tests will fail"rejected (false). package.json prepare runs build-if-needed on yarn install (the CI install step), and this PR extends build-if-needed to include dist/webpack/RSCWebpackPlugin.js. Empirically verified on a fresh clone: yarn install --frozen-lockfile emitted the build and dist/ was fully populated. Precedent: tests/rspack-plugin/helpers/runRspackWithPlugin.js has required dist/ on main with green CI since it landed.
  2. clientFileNameFound never reset between watch-mode compilationsrejected (pre-existing vendored behavior, parity-preserving by design). The vendored plugin has the identical single-assignment lifetime. Changing it would be a behavior change outside this port's scope.
  3. Null chunk-id dedup collapse in the merge pathrejected (parity). Identical semantics to vendored commit 8f09a6b; pre-existing, intentionally preserved, pinned by existing tests.
  4. Module-level runtime-detection cache leaks across Jest testsrejected (parity). The vendored plugin has the same module-level Map; the detection tests use unique mkdtemp paths.
  5. Empty-string publicPath cssPrefix gets no trailing slashrejected (parity). Identical cssPrefix && guard in the vendored code.

/simplify gate (claude -p '/simplify origin/main' --model claude-opus-4-8 --max-budget-usd 20): ran to completion; 4 behavior-preserving cleanups accepted (removed _this alias — all usages were inside arrow callbacks; dropped a redundant typeof !== 'string' guard before Array.isArray; cssFiles array → Set with identical insertion-order dedup; two index-unused C-style loops → for...of). Speculative/out-of-scope suggestions (public-wrapper type dedup, include-regex unification) were rejected by the gate itself as behavior changes or public-API churn. Full yarn build + yarn test re-run green after the edits (commit a83cc3f).

Final-candidate codex re-review after the /simplify commit: skipped — the rerun was started but interrupted by a maintainer directive to land #56 ASAP (it blocks #60). The /simplify edits are mechanical and were manually verified hunk-by-hunk plus re-validated by the full suite; the substantive diff codex reviewed (zero findings) is unchanged.

Merge readiness evaluation

  • Gates passed: yarn build clean; yarn test fully green both halves (17 suites / 158 tests non-RSC half + all RSC suites) with ZERO fixture-expectation changes; codex review zero findings; Claude review findings all triaged with evidence; /simplify applied and re-validated; npm pack diff shows no public API change.
  • Residual risk: low. The port is line-faithful to the vendored plugin; the only behavior deltas are the documented directive-detection unification and webpack public-export usage, both covered by the integration suite running real webpack against real fixtures from the built dist/ artifact (exactly what ships).
  • Rollback: revert the squash commit. The vendored plugin file is untouched and still shipped in dist/, so a revert fully restores the previous wiring with no artifact gap.

Note

Medium Risk
Large, behavior-sensitive change to webpack manifest generation on the critical RSC build path; risk is mitigated by a parity-focused port and existing unit/integration coverage, with small intentional deltas in directive detection and webpack public API usage.

Overview
Moves the webpack React Server Components plugin from the vendored CJS build (react-server-dom-webpack-plugin.js) into owned TypeScript at src/webpack/RSCWebpackPlugin.ts, and wires src/WebpackPlugin.ts to instantiate that implementation instead of require-ing the vendored file.

Public surface is unchanged (./WebpackPlugin and related exports); the vendored plugin file remains in the package but is no longer on any export path. Build output adds dist/webpack/RSCWebpackPlugin.js, and build-if-needed now treats that artifact as required. Tests and the webpack integration helper load the new module (integration runs against the built dist/ file).

The port is intended as behavior parity with the forked plugin (manifest emission, chunk/CSS scanning, runtime-chunk filtering, client-reference chunk groups, duplicate-install runtime detection). "use client" discovery in the plugin now uses the shared hasUseClientDirective helper instead of the vendored inline parser.

Reviewed by Cursor Bugbot for commit 6d568aa. Bugbot is set up for automated code reviews on this repo. Configure here.

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@justin808, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 8 minutes and 43 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more credits in the billing tab to continue.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bfd65b69-fd35-4838-a653-56124e83576c

📥 Commits

Reviewing files that changed from the base of the PR and between 378783d and 6d568aa.

📒 Files selected for processing (9)
  • CHANGELOG.md
  • package.json
  • src/WebpackPlugin.ts
  • src/webpack/RSCWebpackPlugin.ts
  • tests/react-flight-webpack-plugin-client-reference-chunks.test.ts
  • tests/react-flight-webpack-plugin-css-order.test.ts
  • tests/rspack-compat/static-analysis.test.ts
  • tests/webpack-plugin-runtime-detection.test.ts
  • tests/webpack-plugin/helpers/runWebpackWithPlugin.js
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch jg/56-extract-webpack-plugin-ts

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

❤️ Share

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

justin808 added a commit that referenced this pull request Jun 12, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
dep.request,
);
block.addDependency(dep);
module.addBlock!(block);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

FlightModule.addBlock is typed as optional, so ! suppresses the TypeScript error but shifts the failure to a runtime TypeError with no diagnostic. All known webpack 5 builds will have addBlock here, but a guard would surface the problem more gracefully if that assumption ever breaks:

Suggested change
module.addBlock!(block);
if (module.addBlock) {
module.addBlock(block);
}

Alternatively, emit a compilation.warnings.push(new webpack.WebpackError(...)) if the guard fails — consistent with how the plugin handles other unexpected-state cases.

@greptile-apps

greptile-apps Bot commented Jun 12, 2026

Copy link
Copy Markdown

Greptile Summary

This PR replaces the vendored compiled JavaScript react-server-dom-webpack-plugin.js with a first-class TypeScript implementation at src/webpack/RSCWebpackPlugin.ts, wiring src/WebpackPlugin.ts to instantiate it directly. All fork patches (server manifest emission, CSS-before-JS chunk scanning, runtime-chunk filtering, AsyncDependenciesBlock-based chunk-group matching with eager-import fallback, duplicate-package runtime detection) are faithfully ported, and "use client" detection is unified onto the shared hasUseClientDirective helper.

  • src/webpack/RSCWebpackPlugin.ts — 780-line TypeScript port; uses webpack's public exports (webpack.dependencies.*, webpack.AsyncDependenciesBlock, webpack.Template) instead of deep webpack/lib/* requires, with structural typing to keep unit-test mocks viable.
  • Test re-wiring — unit tests switch their imports to the TS source; the integration runner switches to dist/webpack/RSCWebpackPlugin.js with a build-guard; zero fixture/expectation changes.
  • CHANGELOG.md — references [#75] (pull/75) instead of the current PR [#87]; both the inline citation and the footnote definition need updating.

Confidence Score: 4/5

Safe to merge; the TypeScript port is line-faithful to the vendored plugin with only the documented, test-covered behavioral deviations. The only finding is a CHANGELOG link pointing to pull/75 instead of pull/87.

The port is thorough and well-documented: all fork patches are present, the test suite is unchanged in expectations, and the integration tests run against the actual built artifact. The single issue is a mismatched PR number in the CHANGELOG footnote — no functional impact, but it should be corrected before the release entry is cut.

CHANGELOG.md — the [#75] citation and its footnote definition both need to reference [#87] / pull/87.

Important Files Changed

Filename Overview
src/webpack/RSCWebpackPlugin.ts New TypeScript source for the RSC webpack plugin, faithfully porting the vendored CJS artifact with documented behavioral deviations (shared hasUseClientDirective, webpack public exports, undefined loc arg); all fork patches preserved and covered by existing tests.
src/WebpackPlugin.ts Switches the inner plugin import from the vendored CJS require to the new TS class; the ReactFlightWebpackPlugin type shim and ReactFlightWebpackPluginConstructor workaround are removed, and the instantiation is now type-safe.
CHANGELOG.md Adds an Unreleased entry for the port; the changelog link references [#75] (pull/75) instead of the current PR [#87] (pull/87).
package.json Extends build-if-needed to also gate on dist/webpack/RSCWebpackPlugin.js so CI/install triggers a rebuild when the new artifact is absent.
tests/webpack-plugin/helpers/runWebpackWithPlugin.js Switches from requiring the vendored CJS file to requiring dist/webpack/RSCWebpackPlugin.js, with an explicit existence check that surfaces a useful 'run yarn build first' message on missing artifact.
tests/rspack-compat/static-analysis.test.ts Updates the known-incompatible sanity check to read the new TS source and assert webpack public-API usage (webpack.dependencies., webpack.Template, webpack.AsyncDependenciesBlock) rather than the old webpack/lib/ deep-require patterns.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["src/WebpackPlugin.ts\n(public API wrapper)"] -->|"new RSCFlightWebpackPlugin()"| B["src/webpack/RSCWebpackPlugin.ts\n(TypeScript port — new)"]
    B --> C["src/clientReferences.ts\nhasUseClientDirective()"]
    B --> D["webpack public API\nModuleDependency · NullDependency\nTemplate · AsyncDependenciesBlock"]

    subgraph "Before this PR"
      E["src/WebpackPlugin.ts"] -->|"require()"| F["src/react-server-dom-webpack/cjs/\nreact-server-dom-webpack-plugin.js\n(vendored CJS — still shipped)"]
    end

    subgraph "Test wiring changes"
      G["unit tests\n(client-ref-chunks, css-order,\nruntime-detection)"] -->|"import from TS source"| B
      H["integration test\nrunWebpackWithPlugin.js"] -->|"require from dist/"| I["dist/webpack/RSCWebpackPlugin.js\n(built artifact)"]
    end
Loading

Reviews (1): Last reviewed commit: "Point changelog entry at PR #87" | Re-trigger Greptile

dep.request,
);
block.addDependency(dep);
module.addBlock!(block);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Potential runtime crash: non-null assertion on optional method

FlightModule.addDependency is typed as optional (addBlock?: (block: unknown) => void), so the ! operator silently bypasses the guard TypeScript set up. If a module type that lacks addBlock ever reaches this path (e.g. a DelegatedModule, external module, or a custom module type from a third-party plugin), this throws TypeError: module.addBlock is not a function with no diagnostic message.

Suggested change
module.addBlock!(block);
if (module.addBlock) module.addBlock(block);

The parser hook only fires when isReactOnRailsRSCRuntimeResource returns true, so this path is narrow — but "narrow" doesn't mean "impossible" in a heterogeneous compilation, and a guarded no-op is far cheaper to debug than an unhandled TypeError.

Comment thread CHANGELOG.md
## [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])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 The changelog entry links to PR #75, but this work is being landed in PR #87. The reference and its footnote definition should both use #87 so the link points to the right pull request.

Suggested change
- 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])
- 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])

Comment thread CHANGELOG.md
[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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 The footnote definition for the PR link also needs to be updated to match.

Suggested change
[#87]: https://github.com/shakacode/react_on_rails_rsc/pull/87
[#87]: https://github.com/shakacode/react_on_rails_rsc/pull/87

@claude

claude Bot commented Jun 12, 2026

Copy link
Copy Markdown

Code Review: Extract and own the webpack RSC plugin as TypeScript source

Overview

A solid, well-documented port of the vendored CJS plugin to owned TypeScript. The parity checklist, zero fixture-expectation changes, and the codex/Claude/simplify gate history give high confidence the behavior is preserved. The shift to webpack's public API and the unified hasUseClientDirective helper are both clear improvements.


What's Working Well

  • Comprehensive test coverage — every ported patch is pinned by a named test; zero expectation changes confirm behavioral fidelity
  • Webpack public API usagewebpack.dependencies.ModuleDependency, webpack.Template, webpack.AsyncDependenciesBlock instead of deep webpack/lib/* requires
  • Shared hasUseClientDirective — directive detection is now consistent across webpack, rspack, and RSCReferenceDiscoveryPlugin
  • cssFiles as Set (from /simplify) — eliminates per-insertion duplicate work in the accumulation pass
  • Structural types — honest contracts for the webpack API surface that keep the unit-test mocks runnable

Issues Found

1. Non-null assertion on optional addBlock may throw at runtime (line 410)

FlightModule.addBlock is typed as optional (addBlock?: (block: unknown) => void). The ! asserts it is always present when the runtime module is parsed, which is true for standard webpack 5, but a non-standard build or future webpack version without addBlock will throw a silent TypeError instead of emitting a diagnostic warning. See inline comment.

2. O(n) CSS dedup in the merge path (line 515)

The /simplify pass promoted the accumulation-side cssFiles to a Set, but the merge-path dedup (existing.css.indexOf(cssFile) === -1) is still a linear scan. Low-priority for typical build sizes, but inconsistent with the rest of the post-simplify code. See inline comment.

3. clientFileNameFound watch-mode implication not noted on the declaration (line 355)

The flag's intentional single-lifetime semantics (parity with vendored) are documented in the PR description, but there is no comment on the let declaration itself. A future reader will see a flag that is set but never reset and wonder whether that is a bug. A one-liner on the declaration would save that investigation.

4. FlightCompilation.warnings typed as Error[] instead of webpack.WebpackError[] (line 194)

All call sites push new webpack.WebpackError(), which is correct. However the structural type is looser than necessary — tightening to webpack.WebpackError[] costs nothing and makes the contract more accurate.


Pre-existing / Acknowledged (no action needed)

  • Module-level runtimeResourceDetectionCache grows unboundedly across watch cycles — documented parity
  • AsyncDependenciesBlock loc: undefined vs null — both falsy, documented
  • Empty-string publicPath CSS prefix — identical cssPrefix && guard as vendored

}
if (existing.css == null) existing.css = [];
for (const cssFile of cssFiles) {
if (existing.css.indexOf(cssFile) === -1) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Linear scan in the merge path. The /simplify pass already converted the accumulation-side cssFiles to a Set; the same approach here would be O(1) per lookup:

Suggested change
if (existing.css.indexOf(cssFile) === -1) {
const existingCssSet = new Set(existing.css);
for (const cssFile of cssFiles) {
if (!existingCssSet.has(cssFile)) {
existing.css.push(cssFile);
}
}

Low priority for typical build sizes, but it aligns with the rest of the post-simplify code.

Comment on lines +415 to +419
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Tap name 'HarmonyModulesPlugin' misleads diagnostics

These three taps are registered under a name that belongs to webpack's own HarmonyModulesPlugin. Webpack uses tap names in --profile output, hook tracing, and diagnostic messages — so any profiling or debug trace will attribute this plugin's parser work to the real HarmonyModulesPlugin, making performance and ordering issues harder to diagnose.

This is carried from the vendored CJS source (parity), but since we own this code now it's worth switching to PLUGIN_NAME:

Suggested change
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);
normalModuleFactory.hooks.parser.for('javascript/auto').tap(PLUGIN_NAME, handler);
normalModuleFactory.hooks.parser.for('javascript/esm').tap(PLUGIN_NAME, handler);
normalModuleFactory.hooks.parser
.for('javascript/dynamic')
.tap(PLUGIN_NAME, handler);

SyncHook tap order is insertion-order, not name-based, so this change is behaviorally safe.

apply(compiler: webpack.Compiler): void {
const flightCompiler = compiler as unknown as FlightCompiler;
let resolvedClientReferences: ClientReferenceDependency[] | undefined;
let clientFileNameFound = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This flag is intentionally single-lifetime (set once, never reset between watch-mode compilations) — parity with the vendored plugin. Worth a short comment here so a future reader doesn't treat it as an overlooked bug:

Suggested change
let clientFileNameFound = false;
// Intentionally not reset between watch-mode compilations (parity with
// the vendored plugin). Once the runtime module is seen in any build,
// subsequent builds assume it is still present.
let clientFileNameFound = false;

type FlightCompilation = {
dependencyFactories: Map<unknown, unknown>;
dependencyTemplates: Map<unknown, unknown>;
warnings: Error[];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All call sites push webpack.WebpackError instances. Tightening the structural type from Error[] to webpack.WebpackError[] makes the contract more accurate at no cost:

Suggested change
warnings: Error[];
warnings: webpack.WebpackError[];

Comment on lines +717 to +722
(err, resolvedDirectory) => {
if (err) return cb(err);
contextModuleFactory.resolveDependencies(
fs,
{
resource: resolvedDirectory as string,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Type-unsafe resolver cast: false can slip past the error guard

The resolver callback is (err: Error | null, result?: unknown) => void. Webpack resolvers can call back as (null, false) when the path doesn't resolve (non-error "not found"). The if (err) return cb(err) guard handles err !== null but lets resolvedDirectory === false fall through to the as string cast. Downstream this lands in contextModuleFactory.resolveDependencies as resource: false and later in path.join(false, dep.userRequest) (line 735), which produces "false/..." on some Node versions or throws on others.

Suggested change
(err, resolvedDirectory) => {
if (err) return cb(err);
contextModuleFactory.resolveDependencies(
fs,
{
resource: resolvedDirectory as string,
(err, resolvedDirectory) => {
if (err || typeof resolvedDirectory !== 'string') return cb(err ?? null);

The same pattern covers undefined as well. The second occurrence on line 735 (path.join(resolvedDirectory as string, ...)) is inside the resolveDependencies callback that already ran, so fixing the outer guard is sufficient.

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<string, boolean>();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Module-level cache is never cleared: silent stale hits in watch mode

runtimeResourceDetectionCache lives at module scope, so it accumulates entries for the lifetime of the Node.js process. In watch mode a user could add or remove a duplicate react-on-rails-rsc install between rebuilds, and the cache would continue returning the stale result.

The PR body explicitly calls this out as a parity-preserving decision (the vendored plugin has the same module-level Map), which is a fair call for a porting PR. Flagging it here as a follow-up candidate: a per-compilation cache (set up in beforeCompile and cleared between runs) would be straightforward and eliminate the watch-mode blind spot without changing public behavior on single builds.

@claude

claude Bot commented Jun 12, 2026

Copy link
Copy Markdown

Code Review — PR #87: Extract webpack RSC plugin as TypeScript source

Overview

This is a well-executed porting PR. The parity checklist is thorough, the intentional behavioral deviations are documented with rationale, and the test wiring (unit tests against TS source, integration tests against the built dist artifact) gives solid confidence in correctness. The zero fixture-expectation changes are the strongest signal that behavior is preserved.


Findings

I left four inline comments; summary below.

Must-fix

1. Non-null assertion on module.addBlock (line 410)
FlightModule.addBlock is typed optional, but module.addBlock!(block) bypasses that guard. If any module type that lacks addBlock reaches this parser-hook path (custom module, external, delegated), it throws an unhandled TypeError with no diagnostic context. A one-line if (module.addBlock) guard fixes it. See inline comment.

2. Resolver false/undefined not guarded before as string cast (line 722)
Webpack resolvers can call back as (null, false) for non-error "not found". The if (err) return cb(err) guard only handles the error case, so false falls through the as string cast and reaches path.join(false, ...) at line 735. On some Node versions this produces "false/dep.js" silently; on others it throws. Adding || typeof resolvedDirectory !== 'string' to the guard is the right fix. See inline comment.

Worth addressing

3. Parser taps registered as 'HarmonyModulesPlugin' instead of PLUGIN_NAME (lines 415–419)
These three parser-hook taps are named after webpack's own plugin, so --profile traces and hook diagnostic output attribute this plugin's work to the real HarmonyModulesPlugin. This is parity with the vendored source, but since we own it now the rename is safe and free. See inline comment.

Follow-up (not blocking)

4. Module-level detection cache never cleared between watch-mode rebuilds (line 76)
runtimeResourceDetectionCache accumulates indefinitely, so duplicate-install topology changes mid-watch won't be detected. Documented as intentional parity in the PR body, but a per-compilation cache initialized in beforeCompile would be a clean follow-up. See inline comment.


Notes on intentional deviations

The two documented behavioral deltas look correct:

  • hasUseClientDirective unification — switching from the vendored inline check (ecmaVersion '2024', no hashbang) to the shared helper (ecmaVersion 'latest', allowHashBang: true, directive-terminator check) is the right call for consistency. The only observable difference is that files with a hashbang shebang and a "use client" directive will now be correctly detected. All existing fixtures pass unchanged.
  • webpack public exports (webpack.dependencies.ModuleDependency etc.) instead of deep webpack/lib/* requires — strictly better; same runtime objects, no internal coupling.

The serverConsumerManifestFilename option is accepted but produces no output (parity with the vendored plugin). A short // accepted for option-shape parity; never emitted comment on the field declaration would save future contributors from hunting for the emit site, but that is cosmetic.


Summary

Two must-fix issues (non-null assertion crash risk, resolver false type gap). Everything else is clean. The port is faithful, the test coverage is solid, and the PR body documentation is exemplary.

justin808 and others added 3 commits June 12, 2026 11:30
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@justin808 justin808 force-pushed the jg/56-extract-webpack-plugin-ts branch from c9d7bf7 to 6d568aa Compare June 12, 2026 21:32
@justin808

Copy link
Copy Markdown
Member Author

Rebased onto main after #85 (packed-tarball E2E suite) and #80 landed. Local validation on the rebased head 6d568aa: yarn build clean, yarn test fully green (17 suites / 158 tests non-RSC half + all RSC suites), and the new yarn test:e2e packed-tarball pipeline suite green (21/21) — it packs the npm tarball and drives webpack+rspack → Flight → SSR → hydration through the new TypeScript plugin with exact-manifest assertions, providing end-to-end parity evidence beyond the unit/integration oracle.

@claude

claude Bot commented Jun 12, 2026

Copy link
Copy Markdown

Code Review: Extract webpack RSC plugin as TypeScript source

Overall assessment: Solid port with thorough documentation. The TypeScript source is well-typed, the JSDoc at the top traces every forked patch, and the parity checklist is unusually rigorous. A few specific issues and one potentially real bug are noted below — the rest is informational.


Bug: module.addBlock! non-null assertion on an optional property (line 410)

FlightModule.addBlock is typed as optional (addBlock?: (block: unknown) => void), but the call site uses a non-null assertion instead of a guard:

module.addBlock!(block);   // crashes with TypeError if addBlock is undefined

The isReactOnRailsRSCRuntimeResource check guarantees that module is the Flight client runtime, and in practice that module has addBlock. But the structural type correctly leaves it optional (to stay compatible with test mocks and alternative bundler objects). The assertion silences TypeScript while pushing the failure to runtime. A guard like module.addBlock?.(block) costs nothing and is correct.


Watch-mode flag: clientFileNameFound never reset between compilations (line 355)

Declared in apply()'s closure and set to true once on first parse, this flag stays true for every subsequent watch-mode compilation even if the client runtime is no longer found. The manifest is then emitted silently on recompilations that should have warned. The PR description explicitly calls this parity with the vendored plugin (finding #2 in the Claude review triage), so this is a known pre-existing issue — but it is a real behavioral gap in watch mode that would be cheap to fix here since we own the source.


Parameter fs shadows module-level import (line 701)

resolveAllClientFiles(context, contextResolver, normalResolver, fs: unknown, ...) — the parameter name fs shadows import * as fs from 'fs' at the top of the file. It is not a bug (the parameter is unknown and cast to ReadFileFs later; the module-level fs is not used inside this method), but it is confusing. Renaming to inputFs matches the rspack plugin's naming pattern and eliminates the shadow.


Minor: indexOf in the CSS dedup merge path (line 515)

if (existing.css.indexOf(cssFile) === -1) {

Prefer !existing.css.includes(cssFile) — same semantics, clearer intent. Trivial but consistent with the /simplify gate pass that already ran.


Informational: module-level runtimeResourceDetectionCache grows unbounded (line 76)

The Map<string, boolean> at module scope memoizes detection results indefinitely. In the common case (a one-shot webpack build) this is correct and efficient. In a long-running watch process that processes many unique resource paths over time, the cache grows without bound. The PR description acknowledges this as parity with the vendored behavior, so no change is required, but it is worth noting for future watch-mode optimization work.


Positive observations

  • Using webpack.dependencies.ModuleDependency / webpack.dependencies.NullDependency / webpack.Template / webpack.AsyncDependenciesBlock instead of webpack/lib/* internal requires is a clear improvement and is correctly documented by the updated rspack-compat static-analysis test.
  • The hasUseClientDirective unification (shared helper instead of inline parser copy) is the right call for long-term consistency.
  • The fallback loop that prunes unrecordedClientFiles between chunk groups (lines 720–757) is complex but correctly bounded and well-commented.
  • The build-if-needed extension and the dist-path guard in runWebpackWithPlugin.js correctly mirror the pattern already used for the rspack plugin.
  • Zero fixture-expectation changes is the right measure of parity correctness.

dep.request,
);
block.addDependency(dep);
module.addBlock!(block);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The non-null assertion ! is unsafe here. FlightModule.addBlock is typed as optional, so this crashes with TypeError: module.addBlock is not a function if any module that passes the isReactOnRailsRSCRuntimeResource check doesn't expose addBlock (e.g. a test mock or a future webpack version that renames the method).

Suggested change
module.addBlock!(block);
module.addBlock?.(block);

Using optional chaining is a no-op when addBlock is present (the normal path) and safe when it is absent, which is all we need here.

context: string,
contextResolver: FlightResolver,
normalResolver: FlightResolver,
fs: unknown,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The parameter name fs shadows the import * as fs from 'fs' at the top of the file. While it is not a bug (the module-level fs isn't referenced inside this method, and the parameter is cast to ReadFileFs before use), it is confusing and could mask accidental use of the wrong object.

Suggested change
fs: unknown,
inputFs: unknown,

And update the cast on line 831 from (fs as ReadFileFs) to (inputFs as ReadFileFs). This matches the naming the rspack plugin uses for the equivalent parameter.

apply(compiler: webpack.Compiler): void {
const flightCompiler = compiler as unknown as FlightCompiler;
let resolvedClientReferences: ClientReferenceDependency[] | undefined;
let clientFileNameFound = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

clientFileNameFound is declared once in apply()'s closure and set to true the first time the Flight client runtime is parsed. In webpack watch mode it stays true for all subsequent compilations, so if the client runtime is removed or unreachable in a later watch run the "client runtime not found" warning is silently suppressed and the manifest is still emitted.

This is acknowledged as parity with the vendored plugin (PR description, finding #2). Since we now own the source, resetting the flag at the start of each compilation would be a cheap fix:

Suggested change
let clientFileNameFound = false;
let clientFileNameFound = false;
flightCompiler.hooks.beforeCompile.tapAsync(PLUGIN_NAME, (_params, callback) => {
clientFileNameFound = false; // reset per compilation for watch-mode correctness
callback();
});

Leaving as-is is a reasonable call for an initial port, but worth tracking for the follow-up watch-mode hardening pass.

}
if (existing.css == null) existing.css = [];
for (const cssFile of cssFiles) {
if (existing.css.indexOf(cssFile) === -1) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor style nit: indexOf(...) === -1 can be written more clearly as includes.

Suggested change
if (existing.css.indexOf(cssFile) === -1) {
if (!existing.css.includes(cssFile)) {

@justin808

Copy link
Copy Markdown
Member Author

Review-thread triage (all inline threads are from advisory AI reviewers; none identify a confirmed blocker). Threads are left open for maintainer visibility; disposition for each:

  • CHANGELOG links to Document release dist-tag policy and artifact parity checks #75 (greptile, 2 threads): already fixed — the current head references Extract and own the webpack RSC plugin as TypeScript source #87 in both the entry and the footnote (commit "Point changelog entry at PR Extract and own the webpack RSC plugin as TypeScript source #87").
  • module.addBlock! non-null assertion (3 threads): intentional. The vendored plugin calls module.addBlock(...) unconditionally; ?. would silently skip block injection (a behavior change that hides breakage), while ! preserves the vendored fail-loud semantics. The structural type is optional only because unit-test mocks drive apply().
  • Tap name 'HarmonyModulesPlugin': parity with both the vendored build and upstream React's ReactFlightWebpackPlugin, which registers these parser taps under exactly this name. Renaming is out of scope for a parity port.
  • clientFileNameFound not reset between watch compilations / module-level detection cache staleness (3 threads): pre-existing vendored behavior, intentionally preserved and documented in the PR body; behavior changes are out of scope here.
  • Resolver (null, false) falling past the error guard: parity — the vendored code passes resolvedDirectory through with the same (lack of) guard.
  • warnings: Error[] tightening, fs parameter shadowing, indexOfincludes, merge-path Set micro-optimization: valid style nits; deferred to keep the parity diff stable per the merge-endgame debounce (no nit-only pushes after the final candidate). Happy to fold into a follow-up.

@justin808 justin808 merged commit cb75042 into main Jun 12, 2026
13 checks passed
justin808 added a commit that referenced this pull request Jun 12, 2026
…0.7-cve-dos-fixes

* origin/main:
  Extract and own the webpack RSC plugin as TypeScript source (#87)
  Add packed-tarball E2E pipeline suite: webpack+rspack → Flight → SSR HTML → hydration (#85)

# Conflicts:
#	CHANGELOG.md
justin808 added a commit that referenced this pull request Jun 12, 2026
…atrix

* origin/main:
  Make PR skill workflows discoverable (#88)
  Extract and own the webpack RSC plugin as TypeScript source (#87)
  Add packed-tarball E2E pipeline suite: webpack+rspack → Flight → SSR HTML → hydration (#85)
  Docs: Option 5 stock npm runtime go/no-go decision (#55 spike) (#80)
justin808 added a commit that referenced this pull request Jun 12, 2026
…verification

* origin/main:
  Harden dependency and Claude automation (#76)
  Make PR skill workflows discoverable (#88)
  Extract and own the webpack RSC plugin as TypeScript source (#87)
  Add packed-tarball E2E pipeline suite: webpack+rspack → Flight → SSR HTML → hydration (#85)
  Docs: Option 5 stock npm runtime go/no-go decision (#55 spike) (#80)

# Conflicts:
#	yarn.lock
justin808 added a commit that referenced this pull request Jun 12, 2026
* origin/main:
  Add release artifact verification gate (#77)
  Apply React 19.0.7 RSC reply-decode DoS fixes (CVE-2026-23869, CVE-2026-23870) to vendored runtime (#86)
  Harden dependency and Claude automation (#76)
  Make PR skill workflows discoverable (#88)
  Extract and own the webpack RSC plugin as TypeScript source (#87)
  Add packed-tarball E2E pipeline suite: webpack+rspack → Flight → SSR HTML → hydration (#85)
  Docs: Option 5 stock npm runtime go/no-go decision (#55 spike) (#80)
justin808 added a commit that referenced this pull request Jun 12, 2026
…h-tests

* origin/main:
  Add release artifact verification gate (#77)
  Apply React 19.0.7 RSC reply-decode DoS fixes (CVE-2026-23869, CVE-2026-23870) to vendored runtime (#86)
  Harden dependency and Claude automation (#76)
  Make PR skill workflows discoverable (#88)
  Extract and own the webpack RSC plugin as TypeScript source (#87)
  Add packed-tarball E2E pipeline suite: webpack+rspack → Flight → SSR HTML → hydration (#85)
  Docs: Option 5 stock npm runtime go/no-go decision (#55 spike) (#80)
justin808 added a commit that referenced this pull request Jun 12, 2026
* origin/main:
  Add compatibility matrix and canary signal (#83)
  Document runtime versioning policy and live backlog pointers (#79)
  Add Flight client error path coverage (#78)
  Document release dist-tag policy and artifact parity checks (#75)
  Allow artifact verifier to accept covering root peer ranges (#95)
  Add release artifact verification gate (#77)
  Apply React 19.0.7 RSC reply-decode DoS fixes (CVE-2026-23869, CVE-2026-23870) to vendored runtime (#86)
  Harden dependency and Claude automation (#76)
  Make PR skill workflows discoverable (#88)
  Extract and own the webpack RSC plugin as TypeScript source (#87)
  Add packed-tarball E2E pipeline suite: webpack+rspack → Flight → SSR HTML → hydration (#85)
  Docs: Option 5 stock npm runtime go/no-go decision (#55 spike) (#80)

# Conflicts:
#	docs/releasing.md
justin808 added a commit that referenced this pull request Jun 13, 2026
…ase-motion

* origin/main:
  Release from GitHub Actions with trusted publishing (#84)
  Add Claude agent setup and workflow skill stubs (#82)
  Add compatibility matrix and canary signal (#83)
  Document runtime versioning policy and live backlog pointers (#79)
  Add Flight client error path coverage (#78)
  Document release dist-tag policy and artifact parity checks (#75)
  Allow artifact verifier to accept covering root peer ranges (#95)
  Add release artifact verification gate (#77)
  Apply React 19.0.7 RSC reply-decode DoS fixes (CVE-2026-23869, CVE-2026-23870) to vendored runtime (#86)
  Harden dependency and Claude automation (#76)
  Make PR skill workflows discoverable (#88)
  Extract and own the webpack RSC plugin as TypeScript source (#87)
  Add packed-tarball E2E pipeline suite: webpack+rspack → Flight → SSR HTML → hydration (#85)
  Docs: Option 5 stock npm runtime go/no-go decision (#55 spike) (#80)

# Conflicts:
#	CHANGELOG.md
@cursor cursor Bot mentioned this pull request Jun 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Extract and own the webpack RSC plugin as TypeScript source

1 participant