Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion packages/vinext/src/client/navigation-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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"))
);
}

Expand Down
22 changes: 22 additions & 0 deletions packages/vinext/src/server/app-browser-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -314,6 +315,22 @@ function parseEncodedJsonHeader<T>(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<AppRouterState> | MpaNavigationState,
): value is Promise<AppRouterState> {
Expand Down Expand Up @@ -1819,6 +1836,10 @@ function bootstrapHydration(rscStream: ReadableStream<Uint8Array>): 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
? {
Expand Down Expand Up @@ -2249,6 +2270,7 @@ function bootstrapHydration(rscStream: ReadableStream<Uint8Array>): void {
clearNavigationCaches: clearClientNavigationCaches,
commitHashNavigation: (href, historyUpdateMode, scroll) =>
historyController.commitHashOnlyNavigation(href, historyUpdateMode, scroll),
getRscStateTreeHeaderValue: () => createNavigationStateTreeHeaderValue(getBrowserRouterState()),
navigate: navigateRsc,
});

Expand Down
16 changes: 15 additions & 1 deletion packages/vinext/src/shims/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions packages/vinext/src/shims/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
51 changes: 51 additions & 0 deletions tests/e2e/app-router/nextjs-compat/rsc-query-routing.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
7 changes: 7 additions & 0 deletions tests/fixtures/app-basic/app/redirect/dest/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function Page() {
return (
<div>
<h1>Redirect Dest</h1>
</div>
);
}
14 changes: 14 additions & 0 deletions tests/fixtures/app-basic/app/redirect/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Link from "next/link";

export default function Home() {
return (
<div>
{/* disable prefetch to align the dev/prod fetching behavior,
it's easier for writing tests */}
Go to{" "}
<Link prefetch={false} href="/redirect/source">
Redirect Link
</Link>
</div>
);
}
7 changes: 7 additions & 0 deletions tests/fixtures/app-basic/app/rewrite/dest/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function Page() {
return (
<div>
<h1>Rewrite Dest</h1>
</div>
);
}
14 changes: 14 additions & 0 deletions tests/fixtures/app-basic/app/rewrite/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Link from "next/link";

export default function Home() {
return (
<div>
{/* disable prefetch to align the dev/prod fetching behavior,
it's easier for writing tests */}
Go to{" "}
<Link prefetch={false} href="/rewrite/source">
Rewrite Link
</Link>
</div>
);
}
13 changes: 13 additions & 0 deletions tests/fixtures/app-basic/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
];
},

Expand Down Expand Up @@ -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
Expand Down
87 changes: 84 additions & 3 deletions tests/link-navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -54,14 +57,15 @@ 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,
rsc: undefined,
},
functions: {
navigate,
...(getRscStateTreeHeaderValue ? { getRscStateTreeHeaderValue } : {}),
},
};
}
Expand Down Expand Up @@ -1041,6 +1045,7 @@ describe("Pages Router Link onClick semantics", () => {

async function renderIsolatedLink(options: {
appNavigation?: boolean;
getRscStateTreeHeaderValue?: () => string;
href: string;
nodeEnv: string;
props?: Record<string, unknown>;
Expand Down Expand Up @@ -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", {
Expand Down Expand Up @@ -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();

Expand Down
Loading