Skip to content

fix(build): share one RSC compatibility ID across all plugin instances - #1814

Merged
james-elicx merged 1 commit into
mainfrom
fix/rsc-compat-id-shared
Jun 7, 2026
Merged

fix(build): share one RSC compatibility ID across all plugin instances#1814
james-elicx merged 1 commit into
mainfrom
fix/rsc-compat-id-shared

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Follow-up to #1810 (build-ID coordination), addressing the reviewer's note there:

rscCompatibilityId still diverges per instance. createRscCompatibilityId() returns a fresh randomUUID() per plugin instance when no deploymentId is set, so a hybrid app+pages build still gets two different RSC-compat IDs.

Why it matters

The RSC compatibility token is baked into the client bundle and echoed by the server via the X-Vinext-RSC-Compatibility-Id response header. Browser navigation rejects RSC payloads whose token differs (deploy-skew protection) without exposing the raw build ID.

createRscCompatibilityId() falls back to randomUUID() per plugin instance when no deploymentId is pinned. A single vinext build can instantiate vinext() more than once — the App Router buildApp() (RSC + SSR + client) and the separate Pages Router vite.build() for hybrid app+pages apps — so each instance minted its own token. For App Router today the client and server both come from the buildApp() instance so they happen to agree, but the divergence with the Pages instance is a latent hazard and inconsistent with the build-ID coordination just landed.

Fix

Mirror the build-ID approach exactly:

  1. Single source of truth — relocate createRscCompatibilityId from index.ts into config/next-config.ts, beside resolveBuildId / resolveDeploymentId, and export it.
  2. Resolve once — the CLI computes the token once (reusing deploymentId when configured, else one random UUID) and publishes it via __VINEXT_SHARED_RSC_COMPATIBILITY_ID.
  3. Always adopt — the plugin adopts the shared token whenever the env var is set. The var is only ever set by the build CLI, so dev and standalone createRscCompatibilityId() resolution are unchanged.

Verified on the hybrid app-router-cloudflare example: the App Router server bundle and client bundle now carry the same RSC-compat token (distinct from the build ID and the draft-mode secret, as intended).

Tests

Adds adopts __VINEXT_SHARED_RSC_COMPATIBILITY_ID across the App Router build to tests/app-router-production-build.test.ts — sets the shared token, runs buildApp(), and asserts it lands in both the server and client output (the token can be code-split into a shared chunk, so the test scans the full output trees).

✓ tests/app-router-production-build.test.ts (6 tests)
✓ tests/app-rsc-cache-busting / next-config / app-browser-entry / deploy (600 tests)

Follow-up to #1810. createRscCompatibilityId() falls back to a fresh
randomUUID() per plugin instance when no deploymentId is pinned, so a hybrid
app+pages build baked two different RSC-compat tokens — the App Router
buildApp() instance and the separate Pages Router vite.build() instance each
minted their own. The token gates client-side RSC payload acceptance (the
X-Vinext-RSC-Compatibility-Id header), so cross-instance divergence is a
latent deploy-skew hazard.

Coordinate it the same way as the build ID: relocate createRscCompatibilityId
to config/next-config.ts (single source of truth, beside resolveBuildId /
resolveDeploymentId), have the CLI resolve it once and publish it via
__VINEXT_SHARED_RSC_COMPATIBILITY_ID, and have the plugin always adopt it when
set. Reuses deploymentId when configured (already stable). The env var is only
ever set by the build CLI, so dev and standalone resolution are unchanged.

Adds a coordination test asserting the shared token lands in both the server
and client output.
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

1 similar comment
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Jun 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@1814
npm i https://pkg.pr.new/vinext@1814

commit: 30ee2e8

@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@ask-bonk ask-bonk Bot left a comment

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.

/bigbonk review

Reviewed the change. It correctly mirrors the build-ID coordination just landed in #1810, and is the right fix for the per-instance divergence the #1810 reviewer flagged. No blocking issues found.

Verified

  • Single source of truth. createRscCompatibilityId moves from index.ts into config/next-config.ts beside resolveBuildId/resolveDeploymentId, and is exported. randomUUID is already imported there; in index.ts randomUUID is still used for draftModeSecret (line 782), so no orphaned import.
  • Type narrowing is sound. Signature changed from ResolvedNextConfig to Pick<ResolvedNextConfig, "deploymentId">. The function only reads deploymentId, so the narrower type is accurate and both call sites (CLI with resolvedNextConfig, plugin with nextConfig) satisfy it.
  • Resolve-once / always-adopt is preserved. The plugin keeps the original memoization placement (the ??= became an explicit === undefined guard outside the if (!nextConfig) block). So it still resolves once per plugin instance across the multi-environment config hook calls, and adopts the shared token across instances. length > 0 guards an empty string, matching the build-ID adoption shape.
  • CLI placement matches build-ID exactly, and the "not cleaned up intentionally — vinext build runs once then exits" rationale (cli.ts:462-464) covers the new var too.
  • End-to-end flow is consistent: CLI sets __VINEXT_SHARED_RSC_COMPATIBILITY_ID -> plugin adopts into rscCompatibilityId -> baked as the __VINEXT_RSC_COMPATIBILITY_ID define (index.ts:1339) -> read at runtime in app-rsc-cache-busting.ts:77 and echoed in X-Vinext-RSC-Compatibility-Id. Server and client bake the same value.
  • Test mirrors the existing build-ID test (distinct sentinel value, env save/restore in finally, scans full server+client trees for the code-split chunk case). Ran it locally: passes (6.9s). vp check clean on all 5 changed files. CI is green.

Non-blocking observations

  1. No deploymentId test for the compat ID. The build-ID suite has an "adopts shared ID even when generateBuildId is set" test proving the shared value wins over a non-deterministic per-instance resolver. The compat ID has only one non-deterministic input (the missing-deploymentId random-UUID fallback), which the new test covers. When deploymentId is pinned, CLI and plugin both produce the same deterministic value, so they can't diverge — there's no equivalent "side-effecting resolver" hazard. So no extra test is strictly required, but a small deploymentId-pinned assertion (shared var ignored / value equals deploymentId) would document that branch symmetrically with the build-ID suite. Optional.

  2. Known parity caveat (not introduced here): this guarantees one consistent token per build, but the token is still non-deterministic across separate vinext build invocations when no deploymentId is set — so the deploy-skew check only protects within a single build's output, not across rebuilds of the same commit. That's inherent to the random-UUID fallback and matches the documented build-ID behavior; pinning deploymentId is the escape hatch. Worth keeping in mind, nothing to change in this PR.

LGTM.

@ask-bonk

ask-bonk Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #1814.

Summary

The change is correct and ready to merge. It cleanly mirrors the build-ID coordination from #1810 to fix the per-instance RSC-compat token divergence the #1810 reviewer flagged.

What I verified:

  • createRscCompatibilityId relocated to config/next-config.ts and exported; no orphaned randomUUID import in index.ts (still used for draftModeSecret)
  • Type narrowed to Pick<ResolvedNextConfig, "deploymentId"> — accurate, both call sites satisfy it
  • Resolve-once memoization preserved (the ??= became an equivalent === undefined guard), with always-adopt-when-set semantics matching the build-ID shape
  • End-to-end flow consistent: CLI shares → plugin adopts → baked as __VINEXT_RSC_COMPATIBILITY_ID define → echoed in the response header for both server and client
  • New test mirrors the existing build-ID test pattern; passes locally (6.9s); vp check clean on all 5 files; CI green

Non-blocking notes (no changes required):

  1. Could add a deploymentId-pinned assertion for symmetry with the build-ID suite (optional — that branch is deterministic and can't diverge)
  2. Documented the inherent caveat that the token is still non-deterministic across separate builds without a pinned deploymentId (matches build-ID behavior; deploymentId is the escape hatch)

LGTM.

github run

@james-elicx
james-elicx merged commit 465e6c7 into main Jun 7, 2026
50 checks passed
@james-elicx
james-elicx deleted the fix/rsc-compat-id-shared branch June 7, 2026 21:02
@github-actions github-actions Bot mentioned this pull request Jun 7, 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.

1 participant