Skip to content

Derive RSC client refs from the RSC graph#47

Merged
justin808 merged 8 commits into
mainfrom
jg-codex/pr-46-rsc-review-fixes
Jun 3, 2026
Merged

Derive RSC client refs from the RSC graph#47
justin808 merged 8 commits into
mainfrom
jg-codex/pr-46-rsc-review-fixes

Conversation

@justin808

@justin808 justin808 commented Jun 3, 2026

Copy link
Copy Markdown
Member

Important

Replacement / maintainer-owned follow-up for #46. This is paired with shakacode/react_on_rails#3556 and should be reviewed together with that PR.

Summary

  • Carries forward Derive RSC client refs from the RSC graph #46's RSC graph-derived client-reference discovery support.
  • Adds the public RSCReferenceDiscoveryPlugin package export and loader recording path from Derive RSC client refs from the RSC graph #46.
  • Adds review fixes on top of Derive RSC client refs from the RSC graph #46:
    • Detects same-line directives such as 'use client'; export const x = 1.
    • Throws a descriptive error when neither compiler.webpack nor compiler.rspack is available instead of falling through to a bare require('webpack').
    • Emits a Webpack warning when a client chunk group cannot expose client-reference blocks, rather than silently skipping it.
    • Removes as never casts in manifest tests by typing the mock client-reference shape explicitly.

Verification

  • Red first:
    • tests/client-references.test.ts failed for same-line 'use client' detection.
    • tests/rsc-reference-discovery-plugin.test.ts failed for the missing bundler API diagnostic.
    • tests/react-flight-webpack-plugin-client-reference-chunks.test.ts failed for the missing warning on chunk groups without block metadata.
  • Green after fix:
    • yarn jest tests/client-references.test.ts tests/rsc-reference-discovery-plugin.test.ts tests/react-flight-webpack-plugin-client-reference-chunks.test.ts tests/react-flight-webpack-plugin-css-order.test.ts --runInBand
    • yarn test
    • yarn build
    • git diff --check
    • /Users/justin/.agents/skills/autoreview/scripts/autoreview --mode local

Notes

The existing cacheable(false) behavior is intentionally preserved. #46's tests already assert that loader output is non-cacheable even for currently non-client modules, because otherwise a watch rebuild can miss a file that gains a 'use client' directive.


Note

Medium Risk
Changes build-time RSC client manifest chunk mapping and directive discovery; incorrect behavior could mis-wire client components at runtime, though behavior is heavily tested.

Overview
Adds graph-derived RSC client-reference discovery: a public RSCReferenceDiscoveryPlugin records "use client" modules during the RSC Webpack loader pass and emits rsc-client-references.json, with loader output marked non-cacheable while the plugin is active so watch rebuilds do not miss newly added directives.

hasUseClientDirective is centralized in clientReferences (Acorn-based parsing) and shared with Rspack; it now handles edge cases such as same-line directives and stricter prologue rules.

ReactFlightWebpackPlugin client manifests tie modules to chunks using client-reference async blocks per chunk group (with getBlocks / blocksIterable fallbacks), reducing unrelated entry chunks in the client manifest and emitting a Webpack warning when block metadata is missing instead of silently skipping.

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

@coderabbitai

coderabbitai Bot commented Jun 3, 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 42 minutes and 13 seconds. Learn how PR review limits work.

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

⌛ 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: 9475ea60-ef34-427f-850f-1973fb1cfd52

📥 Commits

Reviewing files that changed from the base of the PR and between 6262bf2 and 5695cf9.

📒 Files selected for processing (10)
  • package.json
  • src/RSCReferenceDiscoveryPlugin.ts
  • src/WebpackLoader.ts
  • src/clientReferences.ts
  • src/react-server-dom-rspack/shared.ts
  • src/react-server-dom-webpack/cjs/react-server-dom-webpack-plugin.js
  • tests/client-references.test.ts
  • tests/react-flight-webpack-plugin-client-reference-chunks.test.ts
  • tests/react-flight-webpack-plugin-css-order.test.ts
  • tests/rsc-reference-discovery-plugin.test.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch jg-codex/pr-46-rsc-review-fixes

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.

@claude

claude Bot commented Jun 3, 2026

Copy link
Copy Markdown

Code Review: Derive RSC client refs from the RSC graph

Overview

This PR introduces RSCReferenceDiscoveryPlugin — a build-time plugin that records modules with a "use client" directive during the RSC build and emits a rsc-client-references.json asset. It also tightens ReactFlightWebpackPlugin's client manifest generation to scope each chunk group's entries to only the client-reference dependency blocks belonging to that group (preventing unrelated entry chunks from bleeding into the manifest).

The implementation is solid overall, with good test coverage for the new behavior. A few things worth discussing:


Correctness

USE_CLIENT_REGEX change is correct. The old regex /^\s*['"]use client['"]\s*;?\s*(?:\n|$)/ required a newline or EOF after the optional semicolon, so 'use client'; export const x = 1 would not match. The new /^\s*['"]use client['"]\s*(?:;|\n|$)/ correctly anchors on the directive terminator without requiring anything about what follows.

Chunk group scoping logic is correct. Filtering resolvedClientFiles to only those referenced by a chunk group's own ClientReferenceDependency blocks before calling recordModule properly isolates manifest entries to the right chunk groups.


Concerns

1. Performance: every loader invocation is marked non-cacheable when the plugin is active

recordDiscoveredClientReferenceIfNeeded calls cacheable(false) for all files processed by WebpackLoader when RSCReferenceDiscoveryPlugin is active — even files that are not client components. This means no RSC loader output will ever be reused from webpack's persistent cache in incremental builds, which can be a significant regression on large applications.

The comment explains the reasoning (stale discovery if cache is reused), but users who opt into this plugin need to understand the tradeoff. Consider documenting this in the plugin's JSDoc/README, or exploring a finer-grained invalidation strategy (e.g. storing a content hash of the recorded set and invalidating only when it changes).

2. Private webpack API: loaderContext._compilation

_compilation is not part of webpack's public loader API. It is a well-known internal property in practice, but it could change across webpack major versions. There is currently no official public API for getting the compilation from a loader context, so this may be unavoidable — but a comment acknowledging the risk would help future maintainers.

3. client-references.test.ts coverage is thin

The new test file has a single case. Given that hasUseClientDirective is now the shared implementation for both the webpack and rspack paths, additional cases would be valuable: double-quote variant, Buffer input, directive preceded by a block comment, directive at EOF with no trailing newline, and negative cases (plain server modules).

4. blocksIterable fallback semantics worth a comment

The code falls back to chunkGroup.blocksIterable when getBlocks is not a function. blocksIterable was a webpack 5 getter that was superseded by getBlocks() in later webpack 5 releases. If both are absent, the warning fires and the chunk group is skipped — correct behavior. A short comment clarifying which webpack versions each branch targets would help future maintainers.


Minor nits

  • The as Set<string> cast after instanceof Set in getClientReferenceSet is technically sound (TypeScript narrows to Set<unknown> after the check), but skips element-type verification. Acceptable since insertion is controlled.
  • In the plugin test, compilation.warnings is typed as [] (empty array literal). TypeScript infers never[], which means type errors on the warnings elements could go undetected. Typing it as webpack.WebpackError[] (or unknown[]) would be safer.

// Recording is a loader side effect. If this loader output is reused from a
// cache in a later watch rebuild, the side effect can be skipped and the
// emitted references can become stale.
loaderContext.cacheable?.(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 marks the loader output non-cacheable for every file when the plugin is active, not just files with a 'use client' directive. The intent (preventing stale discovery in watch rebuilds) is clear from the comment, but the blast radius is the entire RSC compilation — no loader result will ever be reused from webpack's persistent cache as long as this plugin is registered.

For large applications this could meaningfully hurt incremental build times. Worth surfacing in the plugin's docs/JSDoc so adopters can make an informed tradeoff. If a finer-grained signal is ever needed, one approach is to store a hash of the collected paths in the compilation and only invalidate when it changes.

loaderContext: LoaderContextWithCompilation,
source: string | Buffer,
): boolean {
const compilation = loaderContext._compilation as AnyCompilation | undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

_compilation is a private internal webpack property — it's not part of the public loader context API and could disappear in a future webpack major version. There's no official alternative today, so using it is probably unavoidable, but a comment like // webpack does not expose a public API for this would flag it for future maintainers rather than letting it look like an intentional choice.

it('recognizes a directive followed by code on the same line', () => {
expect(hasUseClientDirective("'use client'; export const answer = 42;\n")).toBe(true);
});
});

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 file only covers one case. Since hasUseClientDirective is now the shared implementation for both webpack and rspack paths, a few more cases here would prevent regressions:

// double-quote variant
expect(hasUseClientDirective('"use client";\nexport default function C() {}')).toBe(true);

// Buffer input
expect(hasUseClientDirective(Buffer.from("'use client';\n"))).toBe(true);

// directive preceded by a block comment
expect(hasUseClientDirective('/* @generated */\n\'use client\';\n')).toBe(true);

// directive at EOF, no trailing newline
expect(hasUseClientDirective("'use client'")).toBe(true);

// negative: plain server module
expect(hasUseClientDirective("export default function Server() {}")).toBe(false);

let chunkResolvedClientFiles = resolvedClientFiles;
if (!_this.isServer) {
const blocks =
"function" === typeof chunkGroup.getBlocks

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 blocksIterable branch is a fallback for older webpack 5 releases where getBlocks() did not yet exist as a method. A short comment clarifying the version split (e.g. // getBlocks() was introduced in webpack 5.x; blocksIterable is the earlier equivalent) would prevent a future reader from wondering whether the fallback is dead code or still load-bearing.

hooks: {
processAssets: {
tap: (_options: unknown, callback: () => void) => {
processAssetCallbacks.push(callback);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

compilation.warnings is inferred as never[] from the [] literal, so TypeScript won't catch type mismatches on the warning elements. Typing it as unknown[] (or Array<{ message: string }>) would give the String(warnings[0]) assertion below a bit more type safety.

@claude

claude Bot commented Jun 3, 2026

Copy link
Copy Markdown

Code Review: Derive RSC client refs from the RSC graph

Overall this is a well-structured PR with sound design and good test coverage. A few items worth addressing before merge.


Correctness

Same-line directive detection
The regex change from ;?(?:\n|$) to (?:;|\n|$) is correct and intentional. 'use client'; export const x = 1 is now detected, while 'use client' not followed by ;, newline, or EOF is correctly rejected.

Windows line endings (\r\n) ⚠️
'use client'\r\n is handled implicitly: \s* consumes the \r before \n matches. This works because \r is whitespace in JS, but it's non-obvious. Adding \r explicitly to the terminator group ((?:;|\r?\n|$)) would make the intent clear and remove the dependency on \s* absorbing the carriage return.

_compilation private API ⚠️
loaderContext._compilation is the only practical bridge here, and the comment explains why—good. This field is not part of webpack's public API and has been renamed/moved in past minor releases. A comment naming the minimum compatible webpack 5 minor version (or a link to the webpack issue tracking a public API) would help future maintainers assess breakage risk when upgrading.


Performance

cacheable(false) called before the directive check
Every file processed by the RSC loader becomes non-cacheable while the plugin is active, even pure server modules. This is explicitly acknowledged in the comment and the test 'marks loader output non-cacheable when active even for non-client modules', but the performance cost is real in large projects with many non-client files. A JSDoc note on RSCReferenceDiscoveryPlugin warning users of this trade-off would set expectations.


Warning verbosity

In react-server-dom-webpack-plugin.js, the missing-blocks warning is emitted once per chunk group. On older webpack 5 builds where both getBlocks and blocksIterable are absent, this could flood the console for projects with many entries. Consider a per-compilation guard (e.g., a Set of already-warned chunk group names) to emit the warning at most once.


Test gaps

  • No test asserts that 'use client' export const x = 1 (directive without a terminating ;, followed by same-line code) is correctly rejected. If the regex is ever loosened, this case could silently become a false positive.
  • The rsc-reference-discovery-plugin.test.ts suite does not test the rspack code path in resolveBundler. Even a trivial test that apply() works when compiler.rspack is set (no compiler.webpack) would guard against typos in the rspack branch.

Minor

  • emitAsset at PROCESS_ASSETS_STAGE_REPORT is the right stage—all loaders have completed. Just note that any consumer expecting rsc-client-references.json in an earlier pipeline stage (e.g. another plugin at PROCESS_ASSETS_STAGE_OPTIMIZE) will not see it.
  • path.relative(...).replace(/\\/g, '/') correctly normalizes Windows paths. ✅
  • Symbol.for with a namespaced key is the right choice for cross-module state on the compilation object. ✅

Comment thread src/clientReferences.ts Outdated
*/
export const DEFAULT_CLIENT_REFERENCES_INCLUDE = /\.[cm]?[jt]sx?$/;

const USE_CLIENT_REGEX = /^\s*['"]use client['"]\s*(?:;|\n|$)/;

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 \r\n (Windows) case works but only implicitly: \s* absorbs the \r, then \n matches. A reader might assume \r\n files fail. Consider making the intent explicit:

Suggested change
const USE_CLIENT_REGEX = /^\s*['"]use client['"]\s*(?:;|\n|$)/;
const USE_CLIENT_REGEX = /^\s*['"]use client['"]\s*(?:;|\r?\n|$)/;

This also covers bare \r (old Mac line endings) which \s* + \n would silently miss if the file has no trailing \n.

// watch rebuild, discovery can miss files that gained a `"use client"`
// directive. This intentionally disables caching for every RSC loader input
// while the plugin is active.
loaderContext.cacheable?.(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.

cacheable(false) is called here, before the hasUseClientDirective check on the next line, so every file passing through the RSC loader is marked non-cacheable while the plugin is active—not just client modules. The comment above explains why this is intentional (watch-rebuild correctness), but it's worth surfacing this in the plugin's public-facing JSDoc too, since users won't read the loader source to understand why incremental builds are slower after adding this plugin.

): boolean {
// Webpack/Rspack do not expose a public loader API for the active compilation.
// `_compilation` is the only available bridge for recording loader side effects.
const compilation = loaderContext._compilation as AnyCompilation | undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

_compilation is a private/internal webpack field. It has existed across all webpack 5 releases so far, but it is not part of the public API contract. If this field is ever renamed (it was previously _module in webpack 4), recordDiscoveredClientReferenceIfNeeded silently becomes a no-op (returns false) rather than throwing—which could cause missed client references without any diagnostic. A minimum webpack version comment here (e.g. // works on webpack >= 5.0) or a one-time warning when _compilation is undefined but resourcePath is defined would make breakage visible.

if (!blocks) {
const chunkGroupName =
chunkGroup.name || chunkGroup.id || "(unnamed)";
compilation.warnings.push(

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 warning is emitted once per chunk group that lacks getBlocks/blocksIterable. On older webpack 5 builds with many entry points, this could produce dozens of identical warnings per build. Consider deduplicating with a per-compilation flag:

if (!compilation._rscBlocksWarningEmitted) {
  compilation._rscBlocksWarningEmitted = true;
  compilation.warnings.push(new webpack.WebpackError(...));
}

Or at minimum, mention the webpack version that introduced getBlocks so users know whether to expect this warning.

@justin808 justin808 marked this pull request as ready for review June 3, 2026 02:35

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f0d78c86e8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread package.json
Comment on lines +40 to +42
"./RSCReferenceDiscoveryPlugin": {
"types": "./dist/RSCReferenceDiscoveryPlugin.d.ts",
"default": "./dist/RSCReferenceDiscoveryPlugin.js"

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 Badge Include the new export in the pack freshness check

This export points at a new dist artifact, but scripts.build-if-needed still only tests the old dist files in package.json:82. In a checkout that already has dist from the previous version, yarn prepack/prepare will skip yarn build, so the packed or git-installed package can contain this export pointing to a missing dist/RSCReferenceDiscoveryPlugin.js; add the new JS and declaration files to the freshness check.

Useful? React with 👍 / 👎.

@claude

claude Bot commented Jun 3, 2026

Copy link
Copy Markdown

Code Review — PR #47: Derive RSC client refs from the RSC graph

Overview

This PR is a clean follow-up to #46. It introduces RSCReferenceDiscoveryPlugin for graph-driven client-reference discovery, fixes the 'use client' same-line detection gap, improves bundler-API error messaging, and adds targeted warnings for chunk groups that can't expose block metadata. Test coverage is solid across all three new behaviours.


Findings

Medium — Directive regex misses trailing inline comment

USE_CLIENT_REGEX requires the directive be followed by ;, \n, or EOF. A trailing inline comment — e.g. 'use client' // eslint-disable-line — would not be detected. That pattern is unusual but not unheard-of (code generators, annotation tools). Consider a suffix-free check like /^\s*['"]use client['"]/ after stripProlog (false positives are unlikely once the prolog is stripped and you are at the first real token), or extend the alternation to allow an optional line comment before \n.

Medium — cacheable(false) silently degrades watch performance when plugin is active

loaderContext.cacheable?.(false) fires before the hasUseClientDirective check, so every file processed by the RSC loader is marked non-cacheable when the plugin is loaded — even pure server modules. This is intentional and the inline comment explains the rationale, but the performance impact could be surprising on large apps where every watch rebuild re-processes all RSC-loader inputs from scratch. Surfacing this in public documentation or a README would help adopters opt in knowingly.

Low — _compilation silently returns undefined under thread-loader

When webpack distributes loader work to worker threads (e.g. thread-loader), the loader context is serialised and re-inflated in the worker — _compilation is almost certainly undefined in that environment. recordDiscoveredClientReferenceIfNeeded returns false silently, so discovery stops working without any user-visible warning. A one-time warning when _compilation is missing while the plugin is active would help operators diagnose this.

Low — relativeRefs can emit ../-prefixed paths

path.relative(compiler.context, file) produces ../../outside/path.tsx when a discovered client file lives outside compiler.context (monorepo symlinks, shared packages). Either document that refs outside the context root will be ../-prefixed, or emit a warning so consumers are not surprised.


Positives

  • resolveBundler fail-fast at apply time is a clear improvement over the previous silent require('webpack') fallback.
  • The getBlocks() / blocksIterable dual-path for webpack 5 version compatibility is handled correctly.
  • Using Symbol.for for the compilation key is the right approach — it correctly survives multiple module copies in the same process (monorepo/hoisting scenarios).
  • The instanceof Set guard in existingClientReferenceSet cleanly doubles as an "is the plugin active for this compilation?" sentinel without an extra boolean flag.
  • Test coverage is thorough: same-line directive, buffer input, warning emission, chunk-group scoping, and the non-active-plugin path are all covered.

Comment thread src/clientReferences.ts Outdated
*/
export const DEFAULT_CLIENT_REFERENCES_INCLUDE = /\.[cm]?[jt]sx?$/;

const USE_CLIENT_REGEX = /^\s*['"]use client['"]\s*(?:;|\n|$)/;

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 suffix alternation (?:;|\n|$) does not match a trailing inline comment, so 'use client' // eslint-disable-line would be silently missed. That pattern is unusual but plausible (code generators, linter annotations). Two options:

Option A — drop the suffix constraint (safe after stripProlog puts you at the first real token):

Suggested change
const USE_CLIENT_REGEX = /^\s*['"]use client['"]\s*(?:;|\n|$)/;
const USE_CLIENT_REGEX = /^\s*['"]use client['"]/;

Option B — allow an optional line comment before the newline/EOF:

Suggested change
const USE_CLIENT_REGEX = /^\s*['"]use client['"]\s*(?:;|\n|$)/;
const USE_CLIENT_REGEX = /^\s*['"]use client['"]\s*(?:;|(?:\/\/[^\n]*)?\s*(?:\n|$))/;

// watch rebuild, discovery can miss files that gained a `"use client"`
// directive. This intentionally disables caching for every RSC loader input
// while the plugin is active.
loaderContext.cacheable?.(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.

cacheable(false) is called before hasUseClientDirective, so every file processed by the RSC loader loses persistent caching when the plugin is active — not just "use client" modules. The comment explains the correctness rationale, but the blast radius on large codebases (all RSC-loader inputs re-processed on every watch rebuild) is worth calling out in the public docs so users can make an informed decision about enabling this plugin.

// Webpack/Rspack do not expose a public loader API for the active compilation.
// `_compilation` is the only available bridge for recording loader side effects.
const compilation = loaderContext._compilation as AnyCompilation | undefined;
if (!compilation) return 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.

When webpack runs loaders inside worker threads (e.g. via thread-loader), the loader context is serialised into the worker and _compilation is almost certainly undefined. The function returns false silently, so discovery stops working without any diagnostic. If _compilation is absent while the RSC loader is otherwise running normally, emitting a one-time compilation.warnings entry (or a console.warn as a fallback) would make this failure mode visible to the user.

  // _compilation may be undefined in worker-thread contexts (e.g. thread-loader).
  const compilation = loaderContext._compilation as AnyCompilation | undefined;
  if (!compilation) {
    // No warning here yet — see note above about thread-loader compatibility.
    return false;
  }

Comment on lines +109 to +110
relativeRefs: refs.map((file) =>
path.relative(compiler.context, file).replace(/\\/g, '/'),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When a client file lives outside compiler.context (e.g. a symlinked package in a monorepo), path.relative produces a ../-prefixed path. The emitted JSON will mix within-context relative paths with escape paths, which may surprise consumers that assume all relativeRefs entries are rooted inside the project. Consider documenting this behaviour, or adding a warning/guard:

relativeRefs: refs.map((file) => {
  const rel = path.relative(compiler.context, file).replace(/\\/g, '/');
  // rel starts with '../' when the file is outside compiler.context
  return rel;
}),

@greptile-apps

greptile-apps Bot commented Jun 3, 2026

Copy link
Copy Markdown

Greptile Summary

This PR introduces graph-driven RSC client reference discovery: a new RSCReferenceDiscoveryPlugin intercepts the webpack loader to record "use client" modules and emit a rsc-client-references.json asset, and the React Flight webpack plugin now scopes client manifest entries to only the chunk groups whose dependency blocks actually contain client references.

  • hasUseClientDirective is consolidated in clientReferences.ts with a corrected regex ((?:;|\n|$) instead of requiring a trailing newline), Buffer input support, and shared to rspack via a re-export from shared.ts.
  • RSCReferenceDiscoveryPlugin uses a Symbol.for-keyed Set on the compilation object as a loader↔plugin channel, calls cacheable(false) for all processed modules while active (intentional, to avoid missing watch-rebuild directives), and emits the discovered paths as a versioned JSON asset.
  • The webpack plugin's chunkGroups loop now builds a per-chunk-group chunkResolvedClientFiles set (client builds only) by inspecting getBlocks() / blocksIterable, preventing unrelated entry chunks from polluting the client manifest; it warns via WebpackError when neither blocks API is available rather than silently skipping.

Confidence Score: 4/5

Safe to merge; the block-based chunk filtering and plugin discovery mechanism are well-tested and the directive regex fix is correct.

The core changes — graph-derived client reference discovery, the corrected hasUseClientDirective regex, and the chunk-group scoping in the webpack plugin — are all well-exercised by the new test suite. The only finding is a minor diagnostic gap in the warning message when chunkGroup.id === 0, which would not affect build output or runtime behavior.

The chunkGroup.name || chunkGroup.id || "(unnamed)" line in react-server-dom-webpack-plugin.js is worth a second look for the id === 0 edge case.

Important Files Changed

Filename Overview
src/RSCReferenceDiscoveryPlugin.ts New plugin that collects 'use client' module paths via a Symbol-keyed Set on the compilation object, emitting them to a JSON asset. Fail-fast bundler detection, clean sentinel pattern, and intentional cacheable(false) behavior are all correct.
src/clientReferences.ts Adds hasUseClientDirective with improved USE_CLIENT_REGEX that allows ; as a terminator (enabling same-line directive detection), Buffer input support, and correct BOM/shebang/comment prologue stripping.
src/react-server-dom-webpack/cjs/react-server-dom-webpack-plugin.js Adds per-chunk-group block-based client reference scoping for client builds. Logic is correct; minor diagnostic gap where chunkGroup.id === 0 would render as (unnamed) in the warning string.
src/WebpackLoader.ts Calls recordDiscoveredClientReferenceIfNeeded on every module. Correctly integrates with the plugin's Set-based discovery and cacheable(false) behavior.
tests/react-flight-webpack-plugin-client-reference-chunks.test.ts New test suite verifying block-based chunk group scoping: unrelated entry chunks excluded, server manifest still generated without blocks, and missing-blocks warning emitted.
src/react-server-dom-rspack/shared.ts Cleanly re-exports hasUseClientDirective from the canonical clientReferences module, removing the duplicate implementation.
tests/rsc-reference-discovery-plugin.test.ts Covers plugin lifecycle: bundler-API error, emit of discovered refs, inactive-plugin no-op, and non-cacheable behavior for non-client modules when plugin is active.
tests/react-flight-webpack-plugin-css-order.test.ts Updated existing test to supply getBlocks and collect clientReferenceBlocks, keeping it compatible with the new block-based chunk selection code path.
package.json Adds RSCReferenceDiscoveryPlugin as a public package export with correct types and default paths.

Sequence Diagram

sequenceDiagram
    participant WL as WebpackLoader
    participant RRDP as RSCReferenceDiscoveryPlugin
    participant Comp as Compilation
    participant WP as ReactFlightWebpackPlugin

    Note over RRDP,Comp: apply(compiler) — thisCompilation hook
    RRDP->>Comp: getClientReferenceSet(compilation) initialises Symbol-keyed Set

    Note over WL,Comp: Each module processed by loader
    WL->>RRDP: recordDiscoveredClientReferenceIfNeeded(this, source)
    RRDP->>Comp: existingClientReferenceSet(compilation)
    alt Plugin active (Set exists)
        RRDP->>WL: cacheable(false)
        alt hasUseClientDirective(source)
            RRDP->>Comp: refs.add(resourcePath)
        end
    end

    Note over RRDP,Comp: processAssets STAGE_REPORT
    RRDP->>Comp: getClientReferenceSet(compilation)
    RRDP->>Comp: emitAsset rsc-client-references.json

    Note over WP,Comp: processAssets chunkGroups loop client build
    loop each chunkGroup
        WP->>WP: "blocks = chunkGroup.getBlocks() or blocksIterable"
        alt blocks unavailable
            WP->>Comp: warnings.push(WebpackError)
        else blocks available
            WP->>WP: build chunkResolvedClientFiles from ClientReferenceDependency in blocks
            alt chunkResolvedClientFiles not empty
                WP->>Comp: recordModule to filePathToModuleMetadata
            end
        end
    end
    WP->>Comp: emitAsset react-client-manifest.json
Loading

Reviews (1): Last reviewed commit: "Address RSC review followups" | Re-trigger Greptile

Comment on lines +293 to +295
if (!blocks) {
if (!missingClientReferenceBlocksWarningEmitted) {
missingClientReferenceBlocksWarningEmitted = true;

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 Warning name can be (unnamed) when id is 0

chunkGroup.id can be 0 in webpack (IDs are integers starting from zero), but 0 is falsy, so chunkGroup.name || chunkGroup.id || "(unnamed)" skips id === 0 and falls through to "(unnamed)". A chunk group with no name and ID 0 would always appear as "(unnamed)" in the warning, making the message misleading to debuggers.

@claude

claude Bot commented Jun 3, 2026

Copy link
Copy Markdown

Code Review: Derive RSC client refs from the RSC graph

Overall: Well-structured PR with thorough test coverage. The directive-detection fix is a real correctness improvement and the chunk-group filtering change addresses a genuine manifest-pollution issue. A few things worth discussing:


What's good

  • Regex fix is correct. The old regex \s*;?\s*(?:\n|$) failed to match 'use client'; export default ... because after consuming ; it required \n or $ but found more code. The new \s*(?:;|\r?\n|$) correctly anchors on the semicolon and moves on — this is a real bug fix.
  • Bundler detection error. Throwing a descriptive error instead of falling through to a bare require('webpack') is the right call; the old behavior would produce a confusing module-not-found at runtime.
  • Chunk-group filtering. Tying manifest entries to chunk groups that actually contain ClientReferenceDependency blocks prevents unrelated entry chunks from appearing in the client manifest — good correctness improvement.
  • Re-export consolidation. Moving hasUseClientDirective to clientReferences.ts and re-exporting from shared.ts eliminates the duplication cleanly.
  • Test coverage. The three new test files cover directive parsing edge cases, plugin lifecycle, and chunk-group selection including the warning-dedup scenario.

Issues / questions

1. cacheable(false) penalises every RSC-loader file, not just client files

recordDiscoveredClientReferenceIfNeeded calls cacheable(false) before the hasUseClientDirective check, so every file processed by the RSC Webpack loader is marked non-cacheable for the entire lifetime of the plugin — even files that are pure server components. The PR description and code comment explain why (watch-rebuild correctness), and the same trade-off was already made in the Rspack loader, so it's a known cost. However, in a large app with many RSC-processed files this could meaningfully slow incremental builds. It would be worth documenting this trade-off in the plugin's JSDoc or README so consumers can make an informed choice about when to enable it.

2. Warning names only the first failing chunk group

var missingClientReferenceBlocksWarningEmitted = false;
// … later, inside forEach:
if (!missingClientReferenceBlocksWarningEmitted) {
  missingClientReferenceBlocksWarningEmitted = true;
  compilation.warnings.push(new webpack.WebpackError(
    "Client reference blocks were unavailable for chunk group " + chunkGroupName + 
  ));
}

The "emit once" behaviour is intentional and tested, but the message names chunkGroupName — the first failing group — making it sound like a single-group problem. If an older webpack build exposes neither getBlocks nor blocksIterable for any chunk group, the user sees one warning that mentions one chunk group name while all others are silently skipped. Consider making the message generic: "Client reference blocks were unavailable for one or more chunk groups …" so it doesn't mislead when there are multiple failures.

3. Symbol.for means two plugin instances share state

const RSC_CLIENT_REFERENCES_KEY: symbol = Symbol.for(
  'react-on-rails-rsc.discoveredClientReferences',
);

Symbol.for creates a global registry key. If RSCReferenceDiscoveryPlugin is applied twice to the same compiler (e.g. two output targets in a multi-compiler setup, or a duplicated dep in a monorepo), both instances will getClientReferenceSet against the same Set. The second plugin's processAssets handler will then emit its JSON with references accumulated from both passes — usually harmless but potentially confusing. Worth a note in the JSDoc, or using a per-instance counter in the key to guarantee isolation.

4. Minor: redundant if (stripped !== s) guard in stripProlog

const stripped = s.replace(LEADING_COMMENTS, '');
if (stripped !== s) s = stripped;  // condition is redundant — s = stripped is a no-op when equal

The assignment is correct either way; the guard only saves a cheap string assignment. Not a bug but adds noise.


One edge-case to verify

The new regex rejects 'use client' ; (space before semicolon):

  • Old: \s*;?\s*(?:\n|$) — consumed the space via the trailing \s* after ;?, so 'use client' ; would have matched.
  • New: \s*(?:;|\r?\n|$)\s* consumes the space, (?:;|…) then needs ; immediately, which it finds → ✅ still matches.

Actually this traces correctly — carry on. I was wrong; the \s* before (?:;|…) does consume the space, so 'use client' ; still matches the new regex. False alarm.

// watch rebuild, discovery can miss files that gained a `"use client"`
// directive. This intentionally disables caching for every RSC loader input
// while the plugin is active.
loaderContext.cacheable?.(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.

The cacheable(false) call happens before the hasUseClientDirective check, so every file that passes through the RSC loader is marked non-cacheable while the plugin is active — not just 'use client' files. This is intentional and matches the Rspack loader's approach, but the performance cost scales linearly with the number of RSC-processed modules. In a large app this could noticeably slow watch-mode rebuilds. Consider surfacing this trade-off in the plugin's public JSDoc comment so consumers know what they're opting into.


const RSC_CLIENT_REFERENCES_KEY: symbol = Symbol.for(
'react-on-rails-rsc.discoveredClientReferences',
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Symbol.for looks up a key in the global symbol registry, so if two RSCReferenceDiscoveryPlugin instances are applied to the same compilation (e.g. a multi-compiler build with shared state, or a duplicated dependency in a monorepo), both will read/write the same Set. The second plugin's processAssets handler will include references accumulated by both passes and emit them to its own filename — likely harmless in practice, but the shared-state behaviour is surprising. Adding a short note in the JSDoc would help future maintainers understand the intent.

Comment on lines +294 to +304
if (!missingClientReferenceBlocksWarningEmitted) {
missingClientReferenceBlocksWarningEmitted = true;
const chunkGroupName =
chunkGroup.name || chunkGroup.id || "(unnamed)";
compilation.warnings.push(
new webpack.WebpackError(
"Client reference blocks were unavailable for chunk group " +
chunkGroupName +
". React Server Components client manifest entries for this chunk group were skipped."
)
);

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 "emit once" behaviour is intentional and tested, but the warning message names chunkGroupName — the first failing chunk group — which makes the problem sound isolated when it may affect many groups (e.g. on a webpack build that exposes neither getBlocks nor blocksIterable). A user seeing this warning has no way to know whether one group or all of them are affected.

Consider a generic message that avoids naming a specific group:

Suggested change
if (!missingClientReferenceBlocksWarningEmitted) {
missingClientReferenceBlocksWarningEmitted = true;
const chunkGroupName =
chunkGroup.name || chunkGroup.id || "(unnamed)";
compilation.warnings.push(
new webpack.WebpackError(
"Client reference blocks were unavailable for chunk group " +
chunkGroupName +
". React Server Components client manifest entries for this chunk group were skipped."
)
);
compilation.warnings.push(
new webpack.WebpackError(
"Client reference blocks were unavailable for one or more chunk groups" +
" (first affected: " + chunkGroupName + ")." +
" React Server Components client manifest entries for affected chunk groups were skipped."
)
);

Comment thread src/clientReferences.ts Outdated
s = nl === -1 ? '' : s.slice(nl + 1);
}
const stripped = s.replace(LEADING_COMMENTS, '');
if (stripped !== s) s = stripped;

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 if (stripped !== s) guard is redundant — s = stripped is a no-op when the two values are already identical, and the comparison itself is O(n) on the string length. Just s = stripped is simpler.

Suggested change
if (stripped !== s) s = stripped;
s = s.replace(LEADING_COMMENTS, '');

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ab9cb9e7fa

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/clientReferences.ts Outdated
*/
export const DEFAULT_CLIENT_REFERENCES_INCLUDE = /\.[cm]?[jt]sx?$/;

const USE_CLIENT_REGEX = /^\s*['"]use client['"]\s*(?:;|\r?\n|$)/;

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 Badge Scan the whole directive prologue

When a client module starts with another directive such as 'use strict';\n'use client';, this regex returns false because it only accepts use client as the first directive. The RSC webpack loader in react-server-dom-webpack-node-loader.production.js scans every directive statement before the first non-directive, so such a file is still transformed as a client module, but RSCReferenceDiscoveryPlugin will omit it from rsc-client-references.json; keep scanning directive statements until the prologue ends instead of matching only the first one.

Useful? React with 👍 / 👎.

@claude

claude Bot commented Jun 3, 2026

Copy link
Copy Markdown

PR #47 Review: Derive RSC client refs from the RSC graph

Overview

This PR introduces RSCReferenceDiscoveryPlugin + recordDiscoveredClientReferenceIfNeeded to record "use client" modules during the build and emit rsc-client-references.json. It also tightens ReactFlightWebpackPlugin client-manifest generation to filter chunk groups by the ClientReferenceDependency blocks they actually contain, avoiding unrelated entry chunks in the manifest.

Strengths

  • Symbol.for state-sharing is the right call — concurrent package copies against the same compilation correctly share one reference set.
  • Descriptive error from resolveBundler is a clear improvement over a bare require('webpack') fallback.
  • cacheable(false) before the directive check is intentional and correctly documented — calling it for every loader invocation while the plugin is active (not just for client modules) avoids missed directives on watch rebuilds.
  • Same-line directive detection ('use client'; export const x = 1) is now correct with the updated regex.
  • Test coverage is solid; the chunk-group filtering test nicely covers the warning-deduplication case.

Issues

Moderate

Chunk-group filtering silently drops modules with non-standard dependency types. When isServer === false, a module is only added to chunkResolvedClientFiles if one of its blocks contains a ClientReferenceDependency instance or a dep with dep.type === 'client-reference'. A project that registers client references through a custom dependency type (or loads them via a static import that bypasses the lazy-loading mechanism) will have those modules silently omitted from the manifest. The single emitted warning says blocks were unavailable, not that specific files were dropped, making debugging non-obvious. Consider a targeted diagnostic when chunkResolvedClientFiles.size < resolvedClientFiles.size for a chunk group that did have blocks.

Minor

  • count field in emitted payload is redundant — refs.length gives the same value, and two sources of truth can drift. See inline comment.
  • Redundant guard in stripPrologif (stripped !== s) s = stripped is a no-op when they are equal. See inline comment.
  • Bare \r line endings unhandled — the regex \r?\n covers CRLF and LF but not old-Mac \r-only endings. Extremely rare, but easy to extend. See inline comment.
  • Loader coupling — WebpackLoader.ts unconditionally imports from RSCReferenceDiscoveryPlugin.ts, so every consumer loads the plugin code even when the plugin is not installed. The if (!refs) return false guard makes this safe; just worth noting if tree-shaking ever matters.

Comment thread src/clientReferences.ts Outdated
*/
export const DEFAULT_CLIENT_REFERENCES_INCLUDE = /\.[cm]?[jt]sx?$/;

const USE_CLIENT_REGEX = /^\s*['"]use client['"]\s*(?:;|\r?\n|$)/;

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 alternation \r?\n covers \r\n (CRLF) and \n (LF) but not a bare \r (classic Mac, pre-OS X). Vanishingly rare in the wild, but easy to cover:

Suggested change
const USE_CLIENT_REGEX = /^\s*['"]use client['"]\s*(?:;|\r?\n|$)/;
const USE_CLIENT_REGEX = /^\s*['"]use client['"]\s*(?:;|\r\n?|$)/;

\r\n? matches \r\n (CRLF) or \r alone (old Mac), and the existing $ already covers LF-less EOF.

Comment thread src/clientReferences.ts Outdated
Comment on lines +28 to +29
const stripped = s.replace(LEADING_COMMENTS, '');
if (stripped !== s) s = stripped;

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 if guard is a no-op — assigning s = stripped when they're equal changes nothing. Simplify to a single assignment:

Suggested change
const stripped = s.replace(LEADING_COMMENTS, '');
if (stripped !== s) s = stripped;
s = s.replace(LEADING_COMMENTS, '');

const payload = {
version: 1,
compilerContext: compiler.context,
count: refs.length,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

count duplicates refs.length. If a consumer parses this JSON and count ever disagrees with refs.length (e.g. after a transform), it becomes a source of confusion. Consider dropping it and letting consumers derive the value:

Suggested change
count: refs.length,

(i.e. remove the count line — callers can use payload.refs.length)

@claude

claude Bot commented Jun 3, 2026

Copy link
Copy Markdown

Code Review: Derive RSC client refs from the RSC graph

Overall: This is a well-structured PR that upgrades client-reference discovery from regex to proper AST parsing, adds a public plugin for emitting the reference list, and fixes chunk-group scoping in the React Flight webpack plugin. Test coverage is good and the intent of each change is clear.


Correctness / Potential Bugs

1. No error boundary around acorn.parse() (src/clientReferences.ts)

acorn-loose is resilient but not infallible. If it throws on a pathological input (a file with a raw null byte, an overlong string, etc.) the exception propagates through the loader and kills the entire build rather than gracefully returning false. The fix is straightforward:

try {
  const program = acorn.parse(text, { ... }) as ParsedProgram;
  // ... directive loop ...
} catch {
  return false;
}

2. blocksIterable fallback path has zero test coverage

The new chunk-group scoping code in react-server-dom-webpack-plugin.js falls back to chunkGroup.blocksIterable for older webpack 5 builds, but none of the new tests exercise that path. A chunk-group mock that exposes only blocksIterable would confirm the compatibility shim works.


Performance

3. Full-file parse for every file containing 'use client' anywhere

The early text.includes('use client') fast-path correctly skips most server modules. However, every file that does contain that string — including files where it appears only in a comment or a string argument — gets fully parsed by acorn. For a large codebase this can add up during cold builds. Consider documenting this trade-off in the JSDoc so future maintainers know it is intentional.

4. cacheable(false) covers every RSC loader input, not just client modules

This is documented and intentional, but the consequence is significant: in watch mode, all RSC loader inputs lose incremental-build cache reuse for the lifetime of the plugin. Worth benchmarking once the feature lands in a real project and revisiting if overhead is unacceptable (e.g., targeted invalidation instead of blanket non-cacheability).


Maintainability

5. build-if-needed is now ~450 chars of duplicated shell logic

Adding two more -f checks to both the test arm and the rebuild arm makes the one-liner fragile to extend. A dedicated scripts/build-if-needed.sh or a small node -e check would be easier to maintain.


Minor / Style

  • existingClientReferenceSet vs getClientReferenceSet — the two helpers are nearly identical. A single helper with a create: boolean flag would reduce duplication.
  • hasDirectiveTerminator lacks direct unit tests — it is exercised via hasUseClientDirective, but the helper is complex enough (handles //, /* */, CRLF, tabs, form-feed) that its own describe block would clarify the spec and catch regressions independently.
  • Warning message could name the chunk group — if chunkGroup.name is available, including it in the "Client reference blocks were unavailable…" message would help users locate the problem quickly.

Positive highlights

  • Replacing the regex with an AST-based directive check is the right call — correctly handles same-line directives, escaped literals, and parenthesized strings.
  • Symbol.for for the shared reference key is the correct cross-package approach.
  • Throwing at apply() time when neither bundler API is present gives a fast, descriptive failure.
  • The deduplication between react-server-dom-rspack/shared.ts and clientReferences.ts is clean.
  • Warning only once across multiple bad chunk groups (dedup via missingClientReferenceBlocksWarningEmitted) is a nice touch.

Comment thread src/clientReferences.ts Outdated
const text = Buffer.isBuffer(source) ? source.toString('utf8') : source;
if (!text.includes('use client')) return false;

const program = acorn.parse(text, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug risk: no error boundary around acorn.parse()

acorn-loose is designed to tolerate most syntax errors, but it can still throw on certain inputs (raw null bytes, extremely long strings, files starting with an unexpected BOM variant, etc.). When it throws here the exception propagates through the webpack loader pipeline and aborts the entire build.

Wrapping in a try/catch and returning false is the safe default — if we cannot parse the file we don't know whether it has a directive, so we skip it:

Suggested change
const program = acorn.parse(text, {
let program: ParsedProgram;
try {
program = acorn.parse(text, {
allowHashBang: true,
ecmaVersion: 'latest',
sourceType: 'module',
}) as ParsedProgram;
} catch {
return false;
}

Comment thread src/clientReferences.ts
/** Check whether `source` starts with a `"use client"` directive. */
export function hasUseClientDirective(source: string | Buffer): boolean {
const text = Buffer.isBuffer(source) ? source.toString('utf8') : source;
if (!text.includes('use client')) return 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.

Performance: full-file parse on every "use client" hit

The text.includes('use client') guard correctly skips pure server modules, but any file that mentions 'use client' in a comment, an error message, or a JSDoc will be handed to acorn for a complete AST parse. In a large monorepo this can add measurable time to cold builds.

Two lighter alternatives:

  1. Scan only the first N bytes (directives must appear before any non-string-literal statement, so a 2 KB window covers every real-world case).
  2. Pre-check for a quote+use client+quote pattern with a fast regex before invoking acorn.

If the current full-parse approach is intentional, please add a note to the JSDoc so future maintainers know the cost is accepted.

// watch rebuild, discovery can miss files that gained a `"use client"`
// directive. This intentionally disables caching for every RSC loader input
// while the plugin is active.
loaderContext.cacheable?.(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.

Watch-mode performance: blanket cacheable(false) covers all RSC loader inputs

The comment above explains why this is necessary for correctness, which is good. But the practical consequence in a large project is that every file processed by the RSC loader loses incremental-build caching for the entire compilation — not just the files that actually have 'use client'.

Worth benchmarking on a real project and tracking as a follow-up. A targeted approach (e.g., invalidating only the cached entries for files that gain or lose a 'use client' directive across rebuilds) could recover most of the cache benefit while preserving correctness.

return refs;
}

function existingClientReferenceSet(compilation: AnyCompilation): Set<string> | undefined {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Duplication between existingClientReferenceSet and getClientReferenceSet

Both helpers do the same compilation[RSC_CLIENT_REFERENCES_KEY] instanceof Set check. The only difference is whether they create the set when it's missing. A single helper avoids the duplication:

function clientReferenceSet(compilation: AnyCompilation, create: true): Set<string>;
function clientReferenceSet(compilation: AnyCompilation, create: false): Set<string> | undefined;
function clientReferenceSet(compilation: AnyCompilation, create: boolean): Set<string> | undefined {
  const existing = compilation[RSC_CLIENT_REFERENCES_KEY];
  if (existing instanceof Set) return existing as Set<string>;
  if (!create) return undefined;
  const refs = new Set<string>();
  compilation[RSC_CLIENT_REFERENCES_KEY] = refs;
  return refs;
}

Not a blocker, just reduces the surface area to keep in sync.

var _iterator = _createForOfIteratorHelper(blocks),
_step;
try {
for (_iterator.s(); !(_step = _iterator.n()).done; ) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

blocksIterable fallback path is untested

The compatibility shim for older webpack 5 builds (blocksIterable) is not exercised by any of the new tests. A chunk-group mock that omits getBlocks and only exposes blocksIterable would confirm the fallback works:

{
  blocksIterable: clientReferenceBlocks, // no getBlocks
  chunks: [clientChunk],
}

Without this, the fallback could be silently broken (e.g., if blocksIterable needs to be iterated differently from getBlocks()).

const dep = dependencies[i];
if (
(dep instanceof ClientReferenceDependency ||
"client-reference" === dep.type) &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Warning message could name the affected chunk group

When chunkGroup.name is set, including it in the warning message makes it much easier for users to identify which entry point is missing block metadata:

compilation.warnings.push(
  new webpack.WebpackError(
    "Client reference blocks were unavailable for chunk group" +
      (chunkGroup.name ? ` "${chunkGroup.name}"` : "") +
      ". React Server Components client manifest entries for this chunk group were skipped."
  )
);

This also enables removing the missingClientReferenceBlocksWarningEmitted dedup flag if the message is now per-group (though the flag is still useful to avoid spam when many anonymous groups are affected).

Comment thread src/clientReferences.ts
Comment on lines +39 to +41
const charCode = source.charCodeAt(i);
if (charCode === 9 || charCode === 11 || charCode === 12 || charCode === 32) {
i += 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.

The charCodeAt comparisons are hard to read at a glance. Named constants (or at least inline comments) would make the intent immediately clear:

Suggested change
const charCode = source.charCodeAt(i);
if (charCode === 9 || charCode === 11 || charCode === 12 || charCode === 32) {
i += 1;
if (charCode === 9 /* \t */ || charCode === 11 /* \v */ || charCode === 12 /* \f */ || charCode === 32 /* space */) {

Alternatively, a simple Unicode escape range check (charCode < 33 && charCode !== 10 && charCode !== 13) would be more concise, though the explicit list is more precise about the allowed whitespace class.

Comment thread src/clientReferences.ts
}

/** Check whether `source` starts with a `"use client"` directive. */
export function hasUseClientDirective(source: string | Buffer): 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.

The old shared.ts version explicitly stripped the BOM before testing (if (s.charCodeAt(0) === 0xfeff) s = s.slice(1)). The new Acorn-based path removes that explicit step.

Acorn handles the BOM (treats it as whitespace), and since both text and the AST end positions are relative to the same BOM-inclusive string, hasDirectiveTerminator should work correctly. But there's no test to confirm this. A test would guard against a regression if acorn-loose ever changes BOM position accounting:

it('recognizes directives in BOM-prefixed files', () => {
  // U+FEFF byte-order mark — previously stripped explicitly, now handled by acorn
  expect(hasUseClientDirective('\'use client\';\n')).toBe(true);
});

jest.spyOn(acorn, 'parse').mockImplementationOnce(() => {
throw new Error('parse failed');
});

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 jest.spyOn(acorn, 'parse') spy works in Jest's CommonJS mode because both the test file and src/clientReferences.ts receive the same module instance, making the parse property writable. In ESM (or if acorn-loose ever ships a native ES module build), the namespace object is sealed and spyOn would throw a runtime error — or worse, silently pass a test that exercises the wrong code path.

A more resilient approach is to provoke a real parse error without mocking:

it('returns false when directive parsing fails', () => {
  // An unclosed template literal causes acorn-loose to throw
  expect(hasUseClientDirective("'use client';\n`unclosed")).toBe(false);
});

If the spy is kept, add a comment documenting the CommonJS-instance assumption so a future ESM migration can catch the fragility early.

Comment on lines +310 to +321
const block = _step.value,
dependencies = block && block.dependencies;
if (!dependencies) continue;
for (let i = 0; i < dependencies.length; i++) {
const dep = dependencies[i];
if (
(dep instanceof ClientReferenceDependency ||
"client-reference" === dep.type) &&
resolvedClientFiles.has(dep.request)
)
chunkResolvedClientFiles.add(dep.request);
}

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 dep instanceof ClientReferenceDependency check only matches deps created in this file's class. The "client-reference" === dep.type fallback is meant to cover Rspack or third-party deps with the same semantic type. However, dep.request on a third-party dependency might be a relative module specifier rather than the resolved absolute path stored in resolvedClientFiles, causing the resolvedClientFiles.has(dep.request) lookup to silently miss it.

If this path is intentionally future-proofing for Rspack, it's worth a brief comment; if it's unreachable in the current codebase it can be removed to avoid ambiguity.

Comment on lines +103 to +114

apply(compiler: AnyCompiler): void {
const pluginName = 'RSCReferenceDiscoveryPlugin';
const bundler = resolveBundler(compiler);

compiler.hooks.thisCompilation.tap(pluginName, (compilation) => {
getClientReferenceSet(compilation);

compilation.hooks.processAssets.tap(
{ name: pluginName, stage: bundler.Compilation.PROCESS_ASSETS_STAGE_REPORT },
() => {
const refs = Array.from(getClientReferenceSet(compilation)).sort();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

cacheable(false) fires for every RSC-loader input while the plugin is active, even for server-only modules that will never have "use client". This is documented in the class JSDoc, but the cost compounds in large codebases where most files are server components.

One possible middle-ground: disable caching only on the first call per resource path and re-enable it once you know the directive is absent. That's tricky to implement correctly, so the current conservative approach is fine — just worth calling out explicitly in the option docs so users know to expect a slower incremental build when the plugin is installed.

@claude

claude Bot commented Jun 3, 2026

Copy link
Copy Markdown

See inline review comments for specifics.

@claude

claude Bot commented Jun 3, 2026

Copy link
Copy Markdown

Code Review Summary - This PR centralizes hasUseClientDirective under Acorn (replacing a fragile regex), adds RSCReferenceDiscoveryPlugin public export, and tightens the webpack manifest to only include chunk groups with client-reference async blocks. Well-motivated, clearly documented, solid test coverage. Key concerns (see inline comments): (1) BOM coverage gap in clientReferences.ts -- old code stripped BOM explicitly, new Acorn path handles it transparently but has no test; (2) jest.spyOn(acorn, parse) fragility in client-references.test.ts -- relies on CommonJS module-instance sharing, breaks silently under ESM, prefer a real parse error trigger; (3) charCodeAt magic numbers in clientReferences.ts -- inline char-name comments would help; (4) dep.type string fallback scope in react-server-dom-webpack-plugin.js needs a clarifying comment; (5) universal cacheable(false) cost in RSCReferenceDiscoveryPlugin.ts is correct but should be documented in plugin option docs. No correctness bugs found -- all concerns are hardening improvements, not blockers.

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.

2 participants