Skip to content

fix: use loader for client reference#1141

Open
hyoban wants to merge 7 commits into
cloudflare:mainfrom
hyoban:5-8-split-client-chunk
Open

fix: use loader for client reference#1141
hyoban wants to merge 7 commits into
cloudflare:mainfrom
hyoban:5-8-split-client-chunk

Conversation

@hyoban
Copy link
Copy Markdown
Contributor

@hyoban hyoban commented May 8, 2026

Safari RSC Client Reference Crash Report

Scope

The Safari/WebKit crash:

undefined is not an object (evaluating 'r.call(t,e[2])')

Root Cause Hypothesis

The minified crash maps to React RSC client-reference export lookup, roughly:

hasOwnProperty.call(moduleExports, exportName)

The crash means moduleExports is undefined.

The likely failure chain is:

  1. vinext groups many RSC client references under the virtual RSC server entry facade.

  2. This can produce a large browser chunk like:

    facade__virtual_vinext-rsc-entry-*.js
    
  3. Safari/WebKit evaluates the resulting dynamic import graph in an order where a client reference can be observed before its module namespace is fully initialized.

  4. React RSC later tries to read an export from undefined and crashes.

Chrome appears to tolerate this module graph timing better, which is why the same output can work there.

Next.js Behavior

Next.js does not organize App Router client references as one large virtual RSC entry facade.

Next.js uses this pipeline:

  1. next-flight-loader transforms "use client" modules into registerClientReference(...) references.
  2. FlightClientEntryPlugin walks each App Router entry, collects reachable client components, and injects synthetic client entries.
  3. next-flight-client-entry-loader eagerly imports those client modules.
  4. ClientReferenceManifestPlugin records each client module against real webpack entrypoint chunks.
  5. Next.js emits per-page client reference manifests, merged from the relevant layout hierarchy.

This makes client-reference chunks route/app-entry driven, not virtual-RSC-facade driven.

vinext Difference

vinext currently relies on @vitejs/plugin-rsc default client-reference grouping by meta.serverChunk.

For App Router builds, many references can share:

facade:virtual:vinext-rsc-entry

This is the artifact shape that likely exposes the Safari crash.

Bug-Relevant Invariant

vinext should avoid grouping many unrelated RSC client references into one browser chunk keyed by the virtual RSC server entry facade.

A safer shape should make browser client-reference chunks depend on client module identity or route/app-entry identity.

The closer Next.js parity target is route/app-entry based synthetic client entries plus per-route client reference manifests.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 8, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1141

commit: 1ba60e4

@hyoban hyoban marked this pull request as ready for review May 9, 2026 03:51
@hyoban hyoban changed the title fix: split client chunk fix: use loader for client reference May 9, 2026
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Review: fix: use loader for client reference

Good diagnosis in the PR description — the Safari crash from undefined module exports during RSC client reference resolution is a real issue, and the grouping-by-virtual-RSC-facade explanation is convincing. The fix approach (intercepting the \0virtual:vite-rsc/client-references module via transform to emit per-module direct imports instead of the RSC plugin's default grouped-chunk approach) is sound.

Some issues and questions below.


Core plugin

The plugin itself is clean. The transform hook at enforce: "post" correctly intercepts after the RSC plugin's load hook has produced the default grouped output. The groupChunkId = id mutation to keep the assets manifest correct is the right idea.

However, there's an issue with how groupChunkId is set. See inline comment.

Test concerns

The test is ambitious — it scaffolds an entire fixture app in a temp directory, runs a full production build, and serves it. This is good for confidence but has some robustness and CI cost concerns. See inline comments.

CI changes

Adding WebKit to CI is the right call given the bug is Safari-specific. The cache key branching and conditional install logic look correct. One concern about the dev server vs production build mismatch.


Overall: The core fix looks correct and well-motivated. The main issues are around the test structure and a potential correctness gap in groupChunkId assignment. Happy to re-review after addressing the inline comments.


for (const [id, meta] of metaEntries) {
// The RSC assets manifest reads this field to collect JS/CSS deps.
meta.groupChunkId = id;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The RSC plugin's assets manifest builder does assetDeps[meta.groupChunkId] to find each client reference's JS/CSS dependencies. The default RSC plugin sets groupChunkId to a virtual group module ID (\0virtual:vite-rsc/client-references/group/<name>) which is a key in the output bundle.

Here you set groupChunkId = id where id is the key from clientReferenceMetaMap — which is the resolved module ID of the original source file (e.g., /abs/path/to/Button.tsx). But in the generated client-references module, each loader does import(importId) (possibly via the resolved-id proxy), not import(id).

For the common case where id === meta.importId, these match. But ClientReferenceMeta has importId as a separate field specifically because they can differ (e.g., package sources where packageSource is set). If id !== meta.importId, then assetDeps[meta.groupChunkId] would look up the wrong key and the assets manifest would have missing JS/CSS deps for that reference.

Should this be:

Suggested change
meta.groupChunkId = id;
meta.groupChunkId = meta.importId;

Or even better, should groupChunkId be the withResolvedIdProxy(meta.importId) value (matching what actually gets import()-ed in the generated code), since that's the module ID the bundler will create a chunk for?

Either way, can you verify that the assets manifest is correct for a build with package-sourced client components (e.g., a "use client" component from node_modules)?

| undefined
)?.api;
},
transform(_code, id) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Using transform rather than load is an unusual choice here. The RSC plugin generates this module via a load hook. By intercepting in transform (with enforce: "post"), you're replacing the already-loaded code, which works but means the RSC plugin's load hook still runs and populates clientReferenceGroups and sets groupChunkId on each meta — and then your transform immediately overwrites groupChunkId.

This isn't necessarily wrong, but it creates a hidden ordering dependency: the RSC plugin's load must run first (to populate renderedExports and serverChunk on the metas), and then this transform overwrites the grouping. A comment explaining this would help future maintainers.

Alternatively, could this be done via the RSC plugin's clientChunks option instead? That's the intended extension point for customizing client reference grouping. It would avoid the fragile coupling to the RSC plugin's internal virtual module ID and generation logic.

const exports = meta.renderedExports
.slice()
.sort()
.map((name) => ` get ${JSON.stringify(name)}() { return m[${JSON.stringify(name)}]; },`)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: the lazy getter pattern is clever for avoiding eager evaluation, but each property access will re-index into m every time. In practice this is fine (React only reads each export once during hydration), but it's a subtlety worth a brief comment — especially since the default RSC plugin generates direct property copies rather than getters.

await fs.writeFile(
path.join(clientRefsDir, `probe-${index}.tsx`),
`"use client";
await Promise.resolve();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This await Promise.resolve() is the key ingredient that forces async module evaluation and exposes the Safari timing bug. Could you add a comment explaining why this is needed? Something like:

Suggested change
await Promise.resolve();
// Top-level await forces async module evaluation, which exposes the
// Safari/WebKit timing bug where client reference module namespaces
// can be observed as undefined before initialization completes.
await Promise.resolve();

(The suggestion is for the generated code template, so it would go in the string literal above.)

configFile,
logLevel: "silent",
});
await builder.buildApp();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This does a full createBuilder + buildApp() inside a Playwright fixture. That's a heavyweight operation that adds significant time to each CI run of the browser-specific projects. A few concerns:

  1. Timeout risk. The default Playwright test timeout is 30s. A production build of even a small app with RSC can take 10-20s depending on the runner. If the build is slow, the test will flake. Consider either increasing the test timeout or (better) adding a test.setTimeout() call.

  2. Build caching. Every test run rebuilds from scratch in a fresh tmpdir. If more browser-specific tests are added later, this could become expensive. Consider building once in a globalSetup and sharing the output across tests.

  3. Import of vite at test time. The await import("vite") inside the fixture means Vite's module graph is loaded in the Playwright worker process. This is unusual and could cause issues if Vite's process-level state conflicts with anything else.

Comment on lines +123 to +131
const { startProdServer } = await import(
pathToFileURL(path.resolve(process.cwd(), "packages/vinext/dist/server/prod-server.js")).href
);
const started = await startProdServer({
host: "127.0.0.1",
port: 0,
outDir: path.join(fixtureRoot, "dist"),
noCompression: true,
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The test imports prod-server.js from packages/vinext/dist/. This means the vinext package must be built before this test runs. The CI workflow does run vp run build before E2E tests, so this works in CI. But for local development (reuseExistingServer is true when !process.env.CI), a developer who hasn't built the package will get a confusing error.

The other browser-specific projects share the app-router dev server which starts via the Playwright webServer config. But this test bypasses that entirely and builds+serves its own app. That's a mismatch worth documenting — the baseURL from the project config (http://localhost:4174) is never used by this test.

Consider adding a comment at the top of the file explaining that this test manages its own build+server lifecycle and doesn't use the shared dev server.

Comment thread playwright.config.ts

const appRouterBrowserSpecificTests = "**/app-router/**/*.browser.spec.ts";
const appRouterServer = {
command: "npx vp dev --port 4174",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The appRouterServer starts a dev server (vp dev), but the test in client-reference-runtime-map.browser.spec.ts does its own production build and doesn't use this server at all. This means for the app-router-chrome-browser-specific and app-router-webkit-browser-specific CI jobs, the dev server is started but never hit.

That's wasted CI time (waiting for the dev server to be ready) and potentially confusing. Would it be better to have the browser-specific projects use a different (or no) server config? Or is the intent that future *.browser.spec.ts tests will use the dev server, and this particular test is the exception?

Comment thread .github/workflows/ci.yml

- name: Install Playwright WebKit with system dependencies
if: ${{ matrix.project == 'app-router-webkit-browser-specific' }}
run: vp exec playwright install --with-deps webkit
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

WebKit's system dependencies can be large and flaky to install on CI. --with-deps installs OS-level packages (libwoff1, libvpx, etc.) via apt. A few things to watch:

  1. The cache key is playwright-webkit-... which caches the browser binary, but the system deps are installed via apt and not cached. This means every CI run of the webkit job will re-install system deps. Consider whether this adds meaningful time.

  2. If the webkit test is the only test that exercises this fix (since the bug is Safari-specific), and it fails for infrastructure reasons (apt failures, WebKit install issues), it could block PRs. Is this job in the required checks? If so, consider making it non-required initially until it proves stable.

const manager = rscApi?.manager;
if (!manager || manager.isScanBuild) return null;

const metaEntries = Object.entries(manager.clientReferenceMetaMap ?? {}).filter(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: the ?? {} fallback on clientReferenceMetaMap is unnecessary if the RSC plugin always initializes this as an empty object (which it does in the RscPluginManager class). Not a bug, but the defensive fallback could mask issues where rscApi is retrieved from the wrong plugin.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 11, 2026

Review posted successfully. Here's a summary of the feedback:

Core fix — The approach of intercepting virtual:vite-rsc/client-references to emit direct per-module imports instead of grouped chunks is correct and should fix the Safari timing issue. The main concern is a potential correctness gap in groupChunkId assignment (line 74): when id !== meta.importId (e.g., package-sourced client components), the assets manifest would look up the wrong chunk for JS/CSS dependencies.

Key inline comments:

  1. groupChunkId = id may be wrong for package-sourced components — should likely be meta.importId or withResolvedIdProxy(meta.importId)
  2. transform vs load ordering — the implicit dependency on the RSC plugin's load running first needs documentation, or consider using the clientChunks extension point instead
  3. Test is heavyweight — full production build inside a Playwright fixture has timeout risk and doesn't use the shared dev server the CI project starts
  4. Wasted dev server — browser-specific CI jobs start the app-router dev server but the test bypasses it entirely
  5. WebKit CI stability--with-deps apt installs aren't cached and could be flaky; consider making the webkit job non-required initially
  6. Missing comments — the await Promise.resolve() in generated code and the lazy getter pattern deserve explanatory comments

github run

@hyoban
Copy link
Copy Markdown
Contributor Author

hyoban commented May 12, 2026

resolved

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Re-review: fix: use loader for client reference

The tweaks commit addresses the feedback from the first review well:

  • groupChunkId = id correctness: The added comment explains why the resolved map key is correct (it matches chunk.moduleIds in the output bundle, whereas meta.importId can be a bare specifier for package-sourced references). The new @vinext-test/client-package fixture with CSS and the build-time assertPackageClientReferenceCssDeps assertion directly validate that the assets manifest is correct for the package-source case. This was the main concern — now covered both by explanation and by test.

  • Wasted dev server: Browser-specific projects now use server: null and the webServer array filters nulls. No unnecessary dev server startup in CI for these projects.

  • Comments: Lazy getter rationale, await Promise.resolve() explanation, transform ordering explanation, and the top-level test file comment are all added.

  • Removed ?? {}: The unnecessary defensive fallback on clientReferenceMetaMap is gone.

  • linkFixtureNodeModules refactored: Per-entry symlinking with .vite-temp exclusion supports injecting the fake @vinext-test/client-package without conflicting with Vite's temp directory.

All CI checks pass including both new browser-specific projects (chrome: 50s, webkit: 3m). The webkit job timing is reasonable.

Two minor observations (not blocking):

  1. The findCssAssetContaining helper uses exception-based control flow (throw new Error("CSS asset not found")) for a normal "not found" case. A null return would be more idiomatic, but it works fine and is test-only code.

  2. The local standalone Playwright config (playwright.nextjs-compat.config.ts) adds browser-specific projects but doesn't configure a timeout for them. Since the main config sets test.setTimeout(60_000) in the test file itself this is fine, but worth noting if someone runs the standalone config and hits the default 30s timeout on a slower machine.

LGTM — the core fix is correct, well-tested (including the package-source edge case), and the CI integration is clean.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 12, 2026

Review submitted with approval. The tweaks commit addressed all the substantive concerns from the first review:

  • The groupChunkId = id correctness is now both explained in comments and validated by a test with a package-sourced client component (@vinext-test/client-package) that asserts CSS deps appear in the assets manifest
  • Browser-specific projects no longer waste CI time starting an unused dev server (server: null)
  • All the missing comments were added
  • The defensive ?? {} fallback was removed
  • CI is fully green including both new browser-specific E2E projects

github run

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