Skip to content

fix(app-router): include router state in RSC navigation cache keys#2099

Open
NathanDrake2406 wants to merge 4 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/rsc-query-routing
Open

fix(app-router): include router state in RSC navigation cache keys#2099
NathanDrake2406 wants to merge 4 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/rsc-query-routing

Conversation

@NathanDrake2406

@NathanDrake2406 NathanDrake2406 commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Overview

Field Details
Goal Match Next.js RSC query routing semantics for redirect and rewrite client navigations.
Core change Add a stable Next-Router-State-Tree value before computing App Router RSC navigation and prefetch URLs.
Main boundary The request header and _rsc URL hash are created from the same state snapshot.
Primary files packages/vinext/src/server/app-browser-entry.ts, packages/vinext/src/shims/link.tsx, packages/vinext/src/shims/navigation.ts, packages/vinext/src/client/navigation-runtime.ts, tests/e2e/app-router/nextjs-compat/rsc-query-routing.spec.ts, tests/link-navigation.test.ts
Expected impact Ordinary App Router RSC navigations and prefetches now use non-empty _rsc cache-busting values, so redirected and rewritten request URLs match the upstream observable contract.

Why

App Router Flight requests need a cache key that reflects router state. Next.js sends Next-Router-State-Tree on normal RSC navigations and prefetches and uses that header when computing the _rsc query value. Vinext was missing that state input for ordinary navigations and prefetches, so source redirect/rewrite requests could be emitted as a bare ?_rsc and fail the upstream request-routing contract.

Area Principle / invariant What this PR changes
RSC navigation Request variant headers and cache-busting URLs must be computed from the same inputs. Sets Next-Router-State-Tree before calling createRscRequestUrl().
RSC prefetch Prefetch and navigation cache keys must derive from the same variant contract so prefetched payloads are reusable on navigation. Sets the same Next-Router-State-Tree value in <Link> automatic/intent prefetch, router.prefetch(), and the prefetch-inlining loading-shell path.
Next.js compatibility Redirect and rewrite soft navigations should expose the same source and destination RSC request URLs as Next.js. Ports the upstream rsc-query-routing behavior and assertions.
Fixture fidelity Regression fixtures should preserve upstream route paths and page text where possible. Adds /redirect, /redirect/dest, /rewrite, and /rewrite/dest fixture routes with matching link and heading content.

What changed

Scenario Before After
Redirect link to /redirect/source Source RSC request used a bare _rsc value, so the upstream ?_rsc= filter missed it. Source and destination RSC request URLs both contain ?_rsc=<hash>.
Rewrite link to /rewrite/source Source RSC request used a bare _rsc value, so the upstream assertion saw no matching request. Source RSC request URL contains ?_rsc=<hash>.
<Link> automatic/intent prefetch Prefetch RSC request used a bare _rsc value and omitted Next-Router-State-Tree. Prefetch RSC request URL contains ?_rsc=<hash> caused by the state-tree header.
router.prefetch() Same as <Link> prefetch: state-blind _rsc. Same state-aware _rsc contract as live navigation.
Maintainer review path
  1. packages/vinext/src/server/app-browser-entry.ts checks the state header projection and where it is added before URL hashing.
  2. packages/vinext/src/shims/link.tsx checks the <Link> prefetch path (normal and prefetch-inlining shell).
  3. packages/vinext/src/shims/navigation.ts checks imperative router.prefetch().
  4. packages/vinext/src/client/navigation-runtime.ts checks the runtime validation boundary.
  5. tests/e2e/app-router/nextjs-compat/rsc-query-routing.spec.ts checks the ported upstream browser assertions.
  6. tests/link-navigation.test.ts checks that Link prefetch URLs carry Next-Router-State-Tree and a non-empty _rsc value.
  7. tests/fixtures/app-basic/next.config.ts and the new /redirect and /rewrite fixture pages check the route structure used by the spec.
Validation
  • vp check
  • vp test run tests/link-navigation.test.ts tests/app-rsc-cache-busting.test.ts
  • vp run vinext#build
  • PLAYWRIGHT_PROJECT=app-router pnpm run test:e2e -- tests/e2e/app-router/nextjs-compat/rsc-query-routing.spec.ts
  • vp env exec --node 24 ./scripts/run-nextjs-deploy-suite.sh /Users/nathan/Projects/vinext/.refs/nextjs-v16.2.6 --retries 0 -c 1 --debug test/e2e/app-dir/rsc-query-routing/rsc-query-routing.test.ts
  • Pre-commit hook also ran staged checks, full check, and knip.
Risk / compatibility
  • Public API: no public API changes.
  • Runtime: RSC navigation and prefetch request URLs change from bare _rsc to hashed _rsc when no other variant header is present.
  • Cache/router/RSC: this is a cache-key compatibility fix. The server already treats Next-Router-State-Tree as an RSC vary input and does not parse the value.
  • Existing apps: low risk. The value is derived from the current visible router state and is sent on the existing App Router navigation and prefetch paths.
  • Intentional divergence: vinext serializes a compact visible-state projection instead of Next.js exact FlightRouterState tuple because vinext does not maintain that tuple shape, and the server only hashes the header value.
Non-goals
  • This does not replace vinext interception headers with Next.js Next-Url.
  • This does not change server-side redirect or rewrite ordering.

References

Link Why it matters
Next.js rsc-query-routing test Source behavior ported by this PR.
Next.js rsc-query-routing fixture config Redirect and rewrite route structure used by the upstream test.
Next.js fetchServerResponse Shows normal RSC navigation requests include Next-Router-State-Tree.
Next.js prepareFlightRouterStateForRequest Shows the router tree is URI-encoded JSON before being sent.
Next.js setCacheBustingSearchParam Shows _rsc is derived from router request headers.
Next.js adapter RSC rewrite handling Shows why preserving _rsc through rewrite-style request routing matters.

App Router client navigations could issue RSC requests with a bare _rsc query because no router-state variant header was present. That diverged from Next.js, where normal flight requests always include Next-Router-State-Tree and therefore get a non-empty cache-busting value.

Serialize the current visible vinext router state into Next-Router-State-Tree before computing the RSC request URL so the header and query hash stay in lockstep. Port the upstream rsc-query-routing redirect and rewrite coverage to assert the same observable request URLs.
@pkg-pr-new

pkg-pr-new Bot commented Jun 17, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: d93aba7

Prefetch RSC requests were missing the Next-Router-State-Tree header,
so their _rsc URL hash was computed without the router state projection.
This diverged from Next.js behavior, where all flight requests (both
navigation and prefetch) include the current router tree (confirmed in
the segment-cache prefetch path at next.js/packages/next/src/client/
components/segment-cache/cache.ts:2179).

Add a getRscStateTreeHeaderValue function to NavigationRuntimeFunctions
and wire it into the prefetch path so prefetch URLs are derived from
the same variant inputs as navigation requests.

Also fixes the minor comment typo in the rewrite test assertion.
The previous commit sent Next-Router-State-Tree on imperative prefetch and
navigation RSC requests, but <Link> automatic/intent prefetch still computed
its RSC URL without the header. That left automatic Link prefetch and live
navigation using different _rsc variant contracts.

Set the header before every createRscRequestUrl() call inside the Link
prefetch path, including the prefetchInlining loading-shell request, using
the same runtime helper that navigation uses. This makes prefetch and
navigation URLs derive from the same state snapshot.

Add unit tests that verify the header is present and the resulting _rsc
query value is a non-empty hash rather than a bare ?_rsc.
…ntime

The runtime type gained an optional getRscStateTreeHeaderValue function, but
the ambient-state validator in navigation-runtime.ts was not checking it. A
malformed non-function value could therefore pass validation and throw when
later accessed through getNavigationRuntime(). Add the optional-function check
so the validation boundary stays consistent with the type contract.
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review June 17, 2026 06:13
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