From 17d1c60461f5b059dc0945aa635637fe25c15cac Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:55:17 +1000 Subject: [PATCH 1/4] fix(app-router): include router state in RSC navigation cache keys 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. --- .../vinext/src/server/app-browser-entry.ts | 21 ++++++++ .../nextjs-compat/rsc-query-routing.spec.ts | 51 +++++++++++++++++++ .../app-basic/app/redirect/dest/page.tsx | 7 +++ .../fixtures/app-basic/app/redirect/page.tsx | 14 +++++ .../app-basic/app/rewrite/dest/page.tsx | 7 +++ tests/fixtures/app-basic/app/rewrite/page.tsx | 14 +++++ tests/fixtures/app-basic/next.config.ts | 13 +++++ 7 files changed, 127 insertions(+) create mode 100644 tests/e2e/app-router/nextjs-compat/rsc-query-routing.spec.ts create mode 100644 tests/fixtures/app-basic/app/redirect/dest/page.tsx create mode 100644 tests/fixtures/app-basic/app/redirect/page.tsx create mode 100644 tests/fixtures/app-basic/app/rewrite/dest/page.tsx create mode 100644 tests/fixtures/app-basic/app/rewrite/page.tsx diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index e86ebd07a..3dcd7e24c 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -183,6 +183,7 @@ import { ACTION_REDIRECT_HEADER, ACTION_REDIRECT_STATUS_HEADER, ACTION_REDIRECT_TYPE_HEADER, + NEXT_ROUTER_STATE_TREE_HEADER, VINEXT_CLIENT_REUSE_MANIFEST_HEADER, VINEXT_PARAMS_HEADER, VINEXT_RSC_REDIRECT_HEADER, @@ -314,6 +315,22 @@ function parseEncodedJsonHeader(value: string | null): T | null { } } +function createNavigationStateTreeHeaderValue(state: AppRouterState): string { + const snapshot = state.navigationSnapshot; + // Next.js always sends an encoded current router tree on RSC navigations. + // Vinext's server does not parse the value, but the cache-busting hash must + // still vary on a stable representation of the visible router state. + return encodeURIComponent( + JSON.stringify([ + state.routeId, + state.rootLayoutTreePath, + state.layoutIds, + snapshot.pathname, + snapshot.searchParams.toString(), + ]), + ); +} + function isRouterStatePromise( value: AppRouterState | Promise | MpaNavigationState, ): value is Promise { @@ -1819,6 +1836,10 @@ function bootstrapHydration(rscStream: ReadableStream): void { renderMode: navigationKind === "refresh" ? APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI : undefined, }); + requestHeaders.set( + NEXT_ROUTER_STATE_TREE_HEADER, + createNavigationStateTreeHeaderValue(routerStateAtNavStart), + ); const rscUrl = await createRscRequestUrl(url.pathname + url.search, requestHeaders); const visitedResponseCandidate = shouldBypassNavigationCache ? { diff --git a/tests/e2e/app-router/nextjs-compat/rsc-query-routing.spec.ts b/tests/e2e/app-router/nextjs-compat/rsc-query-routing.spec.ts new file mode 100644 index 000000000..7879fae32 --- /dev/null +++ b/tests/e2e/app-router/nextjs-compat/rsc-query-routing.spec.ts @@ -0,0 +1,51 @@ +// Ported from Next.js: test/e2e/app-dir/rsc-query-routing/rsc-query-routing.test.ts +// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/rsc-query-routing/rsc-query-routing.test.ts +import { expect, test } from "@playwright/test"; +import { waitForAppRouterHydration } from "../../helpers"; + +const BASE = "http://localhost:4174"; + +test.describe("rsc-query-routing", () => { + test("should contain rsc query in rsc request when redirect the page", async ({ page }) => { + await page.goto(`${BASE}/redirect`); + await waitForAppRouterHydration(page); + + const rscRequestUrls: string[] = []; + page.on("request", (req) => { + if (req.url().includes("?_rsc=")) { + rscRequestUrls.push(req.url()); + } + }); + + // Click redirect link + await page.locator("a").click(); + + // Wait for the page load to be completed + await expect(page.locator("h1")).toHaveText("Redirect Dest"); + + // The redirect source and dest urls should both contain the rsc query + expect(rscRequestUrls[0]).toContain("/redirect/source"); + expect(rscRequestUrls[1]).toContain("/redirect/dest"); + }); + + test("should contain rsc query in rsc request when rewrite the page", async ({ page }) => { + await page.goto(`${BASE}/rewrite`); + await waitForAppRouterHydration(page); + + const rscRequestUrls: string[] = []; + page.on("request", (req) => { + if (req.url().includes("?_rsc=")) { + rscRequestUrls.push(req.url()); + } + }); + + // Click redirect link + await page.locator("a").click(); + + // Wait for the page load to be completed + await expect(page.locator("h1")).toHaveText("Rewrite Dest"); + + // The rewrite source url should contain the rsc query + expect(rscRequestUrls[0]).toContain("/rewrite/source"); + }); +}); diff --git a/tests/fixtures/app-basic/app/redirect/dest/page.tsx b/tests/fixtures/app-basic/app/redirect/dest/page.tsx new file mode 100644 index 000000000..7a65dbfde --- /dev/null +++ b/tests/fixtures/app-basic/app/redirect/dest/page.tsx @@ -0,0 +1,7 @@ +export default function Page() { + return ( +
+

Redirect Dest

+
+ ); +} diff --git a/tests/fixtures/app-basic/app/redirect/page.tsx b/tests/fixtures/app-basic/app/redirect/page.tsx new file mode 100644 index 000000000..29832321c --- /dev/null +++ b/tests/fixtures/app-basic/app/redirect/page.tsx @@ -0,0 +1,14 @@ +import Link from "next/link"; + +export default function Home() { + return ( +
+ {/* disable prefetch to align the dev/prod fetching behavior, + it's easier for writing tests */} + Go to{" "} + + Redirect Link + +
+ ); +} diff --git a/tests/fixtures/app-basic/app/rewrite/dest/page.tsx b/tests/fixtures/app-basic/app/rewrite/dest/page.tsx new file mode 100644 index 000000000..1b2e20c2b --- /dev/null +++ b/tests/fixtures/app-basic/app/rewrite/dest/page.tsx @@ -0,0 +1,7 @@ +export default function Page() { + return ( +
+

Rewrite Dest

+
+ ); +} diff --git a/tests/fixtures/app-basic/app/rewrite/page.tsx b/tests/fixtures/app-basic/app/rewrite/page.tsx new file mode 100644 index 000000000..f17e731e4 --- /dev/null +++ b/tests/fixtures/app-basic/app/rewrite/page.tsx @@ -0,0 +1,14 @@ +import Link from "next/link"; + +export default function Home() { + return ( +
+ {/* disable prefetch to align the dev/prod fetching behavior, + it's easier for writing tests */} + Go to{" "} + + Rewrite Link + +
+ ); +} diff --git a/tests/fixtures/app-basic/next.config.ts b/tests/fixtures/app-basic/next.config.ts index eb613c16f..c1461e4ff 100644 --- a/tests/fixtures/app-basic/next.config.ts +++ b/tests/fixtures/app-basic/next.config.ts @@ -84,6 +84,13 @@ const nextConfig: NextConfig = { destination: "/about", permanent: false, }, + // Ported from Next.js: test/e2e/app-dir/rsc-query-routing/next.config.js + // https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/rsc-query-routing/next.config.js + { + source: "/redirect/source", + destination: "/redirect/dest", + permanent: true, + }, ]; }, @@ -152,6 +159,12 @@ const nextConfig: NextConfig = { source: "/rewritten-use-pathname", destination: "/nextjs-compat/hooks-search", }, + // Ported from Next.js: test/e2e/app-dir/rsc-query-routing/next.config.js + // https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/rsc-query-routing/next.config.js + { + source: "/rewrite/source", + destination: "/rewrite/dest", + }, ], fallback: [ // Used by Vitest: app-router.test.ts — fallback rewrite gated on a From c49f265ef35d7f9db24041f926e78771a9131fbb Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:24:03 +1000 Subject: [PATCH 2/4] fix(app-router): send router state tree on prefetch RSC requests 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. --- packages/vinext/src/client/navigation-runtime.ts | 8 ++++++++ packages/vinext/src/server/app-browser-entry.ts | 1 + packages/vinext/src/shims/navigation.ts | 11 +++++++++++ .../nextjs-compat/rsc-query-routing.spec.ts | 2 +- 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/client/navigation-runtime.ts b/packages/vinext/src/client/navigation-runtime.ts index ca536dc86..73696f316 100644 --- a/packages/vinext/src/client/navigation-runtime.ts +++ b/packages/vinext/src/client/navigation-runtime.ts @@ -61,6 +61,14 @@ export type NavigationRuntimeFunctions = { */ notifyLinkNavigationStart?: () => void; pingVisibleLinks?: () => void; + /** + * Returns the Next-Router-State-Tree header value for the current router + * state. Used by prefetch to compute a state-aware RSC URL hash alongside + * navigation. Vinext's server does not parse this value — it only contributes + * to the cache-busting hash — but sending a stable value keeps prefetch and + * navigation URLs derived from the same variant contract. + */ + getRscStateTreeHeaderValue?: () => string; }; export type NavigationRuntimeBootstrap = { diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 3dcd7e24c..23c670c06 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -2270,6 +2270,7 @@ function bootstrapHydration(rscStream: ReadableStream): void { clearNavigationCaches: clearClientNavigationCaches, commitHashNavigation: (href, historyUpdateMode, scroll) => historyController.commitHashOnlyNavigation(href, historyUpdateMode, scroll), + getRscStateTreeHeaderValue: () => createNavigationStateTreeHeaderValue(getBrowserRouterState()), navigate: navigateRsc, }); diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 0e11ed52a..4cedd7025 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -37,6 +37,7 @@ import { } from "../server/app-rsc-cache-busting.js"; import { hasPendingAppRouterPageRedirect } from "../server/app-browser-mpa-navigation.js"; import { + NEXT_ROUTER_STATE_TREE_HEADER, VINEXT_DYNAMIC_STALE_TIME_HEADER, VINEXT_MOUNTED_SLOTS_HEADER, VINEXT_PARAMS_HEADER, @@ -2080,6 +2081,16 @@ const _appRouter: AppRouterInstance = { if (mountedSlotsHeader) { headers.set(VINEXT_MOUNTED_SLOTS_HEADER, mountedSlotsHeader); } + // Set Next-Router-State-Tree so the prefetch URL hash varies on the same + // state projection as navigation RSC requests. This keeps prefetch and + // navigation cache keys derived from the same variant inputs, matching + // Next.js behavior where the current router tree is always sent on flight + // requests (both navigation and prefetch). + const prefetchRuntime = getNavigationRuntime(); + const prefetchStateTree = prefetchRuntime?.functions.getRscStateTreeHeaderValue?.(); + if (prefetchStateTree) { + headers.set(NEXT_ROUTER_STATE_TREE_HEADER, prefetchStateTree); + } const rscUrl = await createRscRequestUrl(fullHref, headers); const cacheKey = AppElementsWire.encodeCacheKey(rscUrl, interceptionContext); const prefetched = getPrefetchedUrls(); diff --git a/tests/e2e/app-router/nextjs-compat/rsc-query-routing.spec.ts b/tests/e2e/app-router/nextjs-compat/rsc-query-routing.spec.ts index 7879fae32..80581cd2d 100644 --- a/tests/e2e/app-router/nextjs-compat/rsc-query-routing.spec.ts +++ b/tests/e2e/app-router/nextjs-compat/rsc-query-routing.spec.ts @@ -39,7 +39,7 @@ test.describe("rsc-query-routing", () => { } }); - // Click redirect link + // Click rewrite link await page.locator("a").click(); // Wait for the page load to be completed From cf0271ad5e7463c3f4d6c07c3c81edfae4c3a9bf Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:37:36 +1000 Subject: [PATCH 3/4] fix(app-router): include router state tree in Link prefetch RSC URLs The previous commit sent Next-Router-State-Tree on imperative prefetch and navigation RSC requests, but 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. --- packages/vinext/src/shims/link.tsx | 16 +++++- tests/link-navigation.test.ts | 87 ++++++++++++++++++++++++++++-- 2 files changed, 99 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index a6e16db0b..c3ba20663 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -43,7 +43,7 @@ import { stripRscSuffix, } from "../server/app-rsc-cache-busting.js"; import { APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL } from "../server/app-rsc-render-mode.js"; -import { VINEXT_MOUNTED_SLOTS_HEADER } from "../server/headers.js"; +import { NEXT_ROUTER_STATE_TREE_HEADER, VINEXT_MOUNTED_SLOTS_HEADER } from "../server/headers.js"; import { isDangerousScheme, reportBlockedDangerousNavigation } from "./url-safety.js"; import { canLinkIntentPrefetch, @@ -451,6 +451,15 @@ function prefetchUrl(href: string, mode: LinkPrefetchMode, priority: "low" | "hi if (mountedSlotsHeader) { headers.set(VINEXT_MOUNTED_SLOTS_HEADER, mountedSlotsHeader); } + // Set Next-Router-State-Tree so the prefetch URL hash varies on the same + // state projection as navigation RSC requests. This keeps prefetch and + // navigation cache keys derived from the same variant inputs, matching + // Next.js behavior where the current router tree is always sent on flight + // requests (both navigation and prefetch). + const stateTree = getNavigationRuntime()?.functions.getRscStateTreeHeaderValue?.(); + if (stateTree) { + headers.set(NEXT_ROUTER_STATE_TREE_HEADER, stateTree); + } // Distinguish the same visible URL when it is prefetched from different // request contexts such as /feed vs /gallery or different mounted slots. const rscUrl = await createRscRequestUrl(fullHref, headers); @@ -490,6 +499,11 @@ function prefetchUrl(href: string, mode: LinkPrefetchMode, priority: "low" | "hi if (mountedSlotsHeader) { shellHeaders.set(VINEXT_MOUNTED_SLOTS_HEADER, mountedSlotsHeader); } + const shellStateTree = + getNavigationRuntime()?.functions.getRscStateTreeHeaderValue?.(); + if (shellStateTree) { + shellHeaders.set(NEXT_ROUTER_STATE_TREE_HEADER, shellStateTree); + } const shellRscUrl = await createRscRequestUrl(fullHref, shellHeaders); const shellResponse = await fetch(shellRscUrl, { headers: shellHeaders, diff --git a/tests/link-navigation.test.ts b/tests/link-navigation.test.ts index 8968c8d4c..0d0ce0821 100644 --- a/tests/link-navigation.test.ts +++ b/tests/link-navigation.test.ts @@ -9,7 +9,10 @@ import { type LinkPrefetchRouterMode, } from "../packages/vinext/src/shims/link-prefetch.js"; import { APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL } from "../packages/vinext/src/server/app-rsc-render-mode.js"; -import { VINEXT_RSC_RENDER_MODE_HEADER } from "../packages/vinext/src/server/headers.js"; +import { + NEXT_ROUTER_STATE_TREE_HEADER, + VINEXT_RSC_RENDER_MODE_HEADER, +} from "../packages/vinext/src/server/headers.js"; import type { VinextLinkPrefetchRoute } from "../packages/vinext/src/client/vinext-next-data.js"; type CapturedEffect = () => void | (() => void); @@ -54,7 +57,7 @@ const linkPrefetchRoutes = [ { canPrefetchLoadingShell: false, patternParts: ["clothing", ":product"], isDynamic: true }, ] satisfies VinextLinkPrefetchRoute[]; -function createTestNavigationRuntime(navigate: unknown) { +function createTestNavigationRuntime(navigate: unknown, getRscStateTreeHeaderValue?: () => string) { return { bootstrap: { routeManifest: null, @@ -62,6 +65,7 @@ function createTestNavigationRuntime(navigate: unknown) { }, functions: { navigate, + ...(getRscStateTreeHeaderValue ? { getRscStateTreeHeaderValue } : {}), }, }; } @@ -1041,6 +1045,7 @@ describe("Pages Router Link onClick semantics", () => { async function renderIsolatedLink(options: { appNavigation?: boolean; + getRscStateTreeHeaderValue?: () => string; href: string; nodeEnv: string; props?: Record; @@ -1082,7 +1087,9 @@ async function renderIsolatedLink(options: { search: "", }; const navigationRuntime = - options.appNavigation === false ? undefined : createTestNavigationRuntime(navigate); + options.appNavigation === false + ? undefined + : createTestNavigationRuntime(navigate, options.getRscStateTreeHeaderValue); vi.stubGlobal("fetch", fetch); vi.stubGlobal("document", { @@ -1231,6 +1238,80 @@ describe("Link prefetch scheduling", () => { } }); + it("sets Next-Router-State-Tree on visible Link prefetches so _rsc is state-aware", async () => { + const observer = stubIntersectionObserver(); + const stateTreeValue = encodeURIComponent( + JSON.stringify(["route-id", "root-layout-path", ["layout-1"], "/current", ""]), + ); + + const result = await renderIsolatedLink({ + href: "/viewport-prefetch-target", + nodeEnv: "production", + getRscStateTreeHeaderValue: () => stateTreeValue, + }); + + try { + observer.dispatchIntersectingEntry(result.anchor); + await waitForFetchCalls(result.fetch, 1); + + const input = result.fetch.mock.calls[0]?.[0]; + expect(typeof input).toBe("string"); + if (typeof input !== "string") return; + const url = new URL(input, "https://example.com"); + expect(url.searchParams.has("_rsc")).toBe(true); + // A bare `?_rsc` has an empty value; a state-aware hash is non-empty. + expect(url.searchParams.get("_rsc")).not.toBe(""); + + const fetchInit = result.fetch.mock.calls[0]?.[1] as RequestInit | undefined; + expect(fetchInit?.headers).toBeInstanceOf(Headers); + if (!(fetchInit?.headers instanceof Headers)) { + throw new Error("Expected prefetch request headers"); + } + expect(fetchInit.headers.get(NEXT_ROUTER_STATE_TREE_HEADER)).toBe(stateTreeValue); + } finally { + result.restoreNodeEnv(); + } + }); + + it("sets Next-Router-State-Tree on prefetchInlining shell requests", async () => { + vi.stubEnv("__VINEXT_PREFETCH_INLINING", "true"); + const observer = stubIntersectionObserver(); + const stateTreeValue = encodeURIComponent( + JSON.stringify(["route-id", "root-layout-path", ["layout-1"], "/current", ""]), + ); + + const result = await renderIsolatedLink({ + href: "/intent-prefetch-target", + nodeEnv: "production", + getRscStateTreeHeaderValue: () => stateTreeValue, + }); + + result.fetch.mockImplementationOnce(() => Promise.resolve(new Response(""))); + + try { + observer.dispatchIntersectingEntry(result.anchor); + await waitForFetchCalls(result.fetch, 1); + + const firstInit = result.fetch.mock.calls[0]?.[1]; + expect(firstInit?.headers).toBeInstanceOf(Headers); + if (!(firstInit?.headers instanceof Headers)) { + throw new Error("Expected shell prefetch request headers"); + } + expect(firstInit.headers.get(NEXT_ROUTER_STATE_TREE_HEADER)).toBe(stateTreeValue); + expect(firstInit.headers.get(VINEXT_RSC_RENDER_MODE_HEADER)).toBe( + APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL, + ); + + const input = result.fetch.mock.calls[0]?.[0]; + expect(typeof input).toBe("string"); + if (typeof input !== "string") return; + const url = new URL(input, "https://example.com"); + expect(url.searchParams.get("_rsc")).not.toBe(""); + } finally { + result.restoreNodeEnv(); + } + }); + it("re-prefetches visible links after the prefetch cache is invalidated", async () => { const observer = stubIntersectionObserver(); From d93aba7a2d702ec064dfaf366896a77dc553b798 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:01:45 +1000 Subject: [PATCH 4/4] fix(app-router): validate getRscStateTreeHeaderValue in navigation runtime 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. --- packages/vinext/src/client/navigation-runtime.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/client/navigation-runtime.ts b/packages/vinext/src/client/navigation-runtime.ts index 73696f316..e91c9127a 100644 --- a/packages/vinext/src/client/navigation-runtime.ts +++ b/packages/vinext/src/client/navigation-runtime.ts @@ -122,7 +122,8 @@ function isNavigationRuntimeFunctions(value: unknown): value is NavigationRuntim isOptionalRuntimeFunction(Reflect.get(value, "navigateExternal")) && isOptionalRuntimeFunction(Reflect.get(value, "navigate")) && isOptionalRuntimeFunction(Reflect.get(value, "notifyLinkNavigationStart")) && - isOptionalRuntimeFunction(Reflect.get(value, "pingVisibleLinks")) + isOptionalRuntimeFunction(Reflect.get(value, "pingVisibleLinks")) && + isOptionalRuntimeFunction(Reflect.get(value, "getRscStateTreeHeaderValue")) ); }