diff --git a/packages/vinext/src/client/navigation-runtime.ts b/packages/vinext/src/client/navigation-runtime.ts index ca536dc86..e91c9127a 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 = { @@ -114,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")) ); } diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index e86ebd07a..23c670c06 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 ? { @@ -2249,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/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/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 new file mode 100644 index 000000000..80581cd2d --- /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 rewrite 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 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();