Files
cloudflare__vinext/tests/link-navigation.test.ts
James Anderson f1efcc1e60 fix(cloudflare): harden canonical RSC warmup end to end (#3040)
* fix(cloudflare): validate reusable warm responses

* fix(cloudflare): retry staged warmup failures safely

* fix(cloudflare): extend staged propagation retries

* fix(build): filter Pages-owned RSC warm paths

* fix(cloudflare): keep CDN warmup correct with custom adapters and i18n

* fix(cloudflare): reject unusable CDN warm responses

* fix(cloudflare): keep CDN warmup bound to the deployed build

* fix(router): reuse warmed RSC on locale-prefixed hybrid routes

* fix(cloudflare): verify exact build and variant during CDN warmup

* fix(router): keep warmed RSC ownership rewrite-safe

* test(cloudflare): prove browser reuse of warmed RSC variants

* test(cloudflare): bind RSC warm proof to each deploy attempt

* fix(rsc): keep deferred loading shells contextual

* fix(build): keep CDN warm discovery route-correct

* fix(cache): preserve valid CDN policy during RSC warmup

* fix(cloudflare): warm the deployed Worker route host

* fix(cloudflare): preserve staged warmup guarantees

* fix(build): discover localized Pages warmup paths

* fix(cloudflare): wait for staged Worker before CDN warmup

* fix(cloudflare): preserve strict warmup deploy invariants

* fix(build): fail closed during CDN warm discovery
2026-08-24 23:03:14 +01:00

3689 lines
124 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
import ReactDOMServer from "react-dom/server";
import type { ElementType, ReactNode } from "react";
import {
getLinkPrefetchDecision,
getLinkPrefetchHref,
type LinkPrefetchIntent,
type LinkPrefetchDecision,
type LinkPrefetchRouterMode,
} from "../packages/vinext/src/shims/link-prefetch.js";
import {
APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_SHELL,
APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
} from "../packages/vinext/src/server/app-rsc-render-mode.js";
import {
NEXT_ROUTER_PREFETCH_HEADER,
NEXT_ROUTER_SEGMENT_PREFETCH_HEADER,
VINEXT_DYNAMIC_STALE_TIME_HEADER,
VINEXT_INTERCEPTION_CONTEXT_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";
import type { RouteManifest } from "../packages/vinext/src/routing/app-route-graph.js";
type CapturedEffect = () => void | (() => void);
type CapturedClickEvent = {
altKey?: boolean;
button: number;
ctrlKey?: boolean;
currentTarget: { hasAttribute(name: string): boolean; target: string };
defaultPrevented: boolean;
metaKey?: boolean;
preventDefault(): void;
shiftKey?: boolean;
};
type CapturedIntentEvent = Pick<MouseEvent, "currentTarget">;
type CapturedAnchorProps = {
onClick?: (event: CapturedClickEvent) => void | Promise<void>;
onMouseEnter?: (event: CapturedIntentEvent) => void;
onTouchStart?: (event: CapturedIntentEvent) => void;
ref?: (node: HTMLAnchorElement | null) => void;
};
type CapturedPrefetchLinkElement = {
as?: string;
href?: string;
rel?: string;
};
const linkPrefetchRoutes = [
{ canPrefetchLoadingShell: false, patternParts: ["viewport-prefetch-target"], isDynamic: false },
{ canPrefetchLoadingShell: false, patternParts: ["intent-prefetch-target"], isDynamic: false },
{ canPrefetchLoadingShell: false, patternParts: ["touch-prefetch-target"], isDynamic: false },
{
canPrefetchLoadingShell: false,
patternParts: ["same-origin-intent-prefetch-target"],
isDynamic: false,
},
{
canPrefetchLoadingShell: true,
canUseCanonicalLoadingShell: true,
patternParts: ["blog", ":slug"],
isDynamic: true,
},
{ canPrefetchLoadingShell: false, patternParts: ["products", ":id"], isDynamic: true },
{ canPrefetchLoadingShell: false, patternParts: ["clothing", ":product"], isDynamic: true },
{
canPrefetchLoadingShell: false,
patternParts: ["teams", ":team", "dashboard"],
isDynamic: true,
requiresDynamicNavigationRequest: true,
},
{
canPrefetchLoadingShell: true,
patternParts: ["slow-intercept", "photo"],
isDynamic: false,
},
] satisfies VinextLinkPrefetchRoute[];
function createTestNavigationRuntime(
navigate: unknown,
routeManifest: RouteManifest | null = null,
) {
return {
bootstrap: {
routeManifest,
rsc: undefined,
},
functions: {
getPrefetchRouterState: () => ({
pathAndSearch: "/current",
routeId: "route:/current",
}),
navigate,
},
};
}
function pingVisibleLinksFromRuntime(): void {
const runtime: unknown = Reflect.get(window, Symbol.for("vinext.navigationRuntime"));
if (typeof runtime !== "object" || runtime === null || !("functions" in runtime)) return;
const { functions } = runtime;
if (typeof functions !== "object" || functions === null || !("pingVisibleLinks" in functions)) {
return;
}
const { pingVisibleLinks } = functions;
if (typeof pingVisibleLinks === "function") {
pingVisibleLinks();
}
}
type MockReactAnchorCaptureOptions = {
captureAnchor(type: unknown, props: unknown): void;
captureEffect?: (effect: CapturedEffect) => void;
startTransition?: (callback: () => void) => void;
};
// This is a tactical escape hatch for Link only. It intercepts React and JSX
// runtime output because the current E2E setup cannot honestly reach the
// production-only Link prefetch path. It mocks useEffect synchronously and
// captures element creation before reconciliation, so it cannot test commit
// scheduling, cleanup, re-renders, or conditional effect execution. Do not
// reuse it as a component harness.
function mockReactAnchorCaptureForLinkOnly_DO_NOT_REUSE(
options: MockReactAnchorCaptureOptions,
): void {
vi.doMock("react", async () => {
const actual = await vi.importActual<typeof import("react")>("react");
const createElement = ((
type: ElementType,
props: Record<string, unknown> | null,
...children: ReactNode[]
) => {
options.captureAnchor(type, props);
return actual.createElement(type, props, ...children);
}) as typeof actual.createElement;
const mockDefault = { ...actual, createElement };
if (options.captureEffect !== undefined) {
const useEffect = (effect: CapturedEffect) => {
options.captureEffect?.(effect);
};
return {
...actual,
createElement,
useEffect,
default: { ...mockDefault, useEffect },
};
}
if (options.startTransition !== undefined) {
return {
...actual,
createElement,
startTransition: options.startTransition,
default: { ...mockDefault, startTransition: options.startTransition },
};
}
return {
...actual,
createElement,
default: mockDefault,
};
});
vi.doMock("react/jsx-runtime", async () => {
const actual = await vi.importActual<typeof import("react/jsx-runtime")>("react/jsx-runtime");
return {
...actual,
jsx(type: ElementType, props: Record<string, unknown>, key?: string) {
options.captureAnchor(type, props);
return actual.jsx(type, props, key);
},
jsxs(type: ElementType, props: Record<string, unknown>, key?: string) {
options.captureAnchor(type, props);
return actual.jsxs(type, props, key);
},
};
});
vi.doMock("react/jsx-dev-runtime", async () => {
const actual =
await vi.importActual<typeof import("react/jsx-dev-runtime")>("react/jsx-dev-runtime");
const jsxRuntime =
await vi.importActual<typeof import("react/jsx-runtime")>("react/jsx-runtime");
return {
...actual,
jsxDEV(
type: ElementType,
props: Record<string, unknown>,
key?: string,
isStaticChildren?: boolean,
source?: Parameters<typeof actual.jsxDEV>[4],
self?: Parameters<typeof actual.jsxDEV>[5],
) {
options.captureAnchor(type, props);
if (typeof actual.jsxDEV === "function") {
return actual.jsxDEV(type, props, key, isStaticChildren ?? false, source, self);
}
return jsxRuntime.jsx(type, props, key);
},
};
});
}
async function flushPrefetchTasks(until?: () => boolean): Promise<void> {
// requestIdleCallback is mocked as sync, then prefetchUrl enters an async
// IIFE that may resolve lazy runtime modules before hashing headers and
// writing caches. Track those hashes explicitly so a detached prefetch
// cannot outlive the test and call the next test's global fetch mock.
for (let attempt = 0; attempt < 1_000; attempt++) {
await vi.dynamicImportSettled();
await Promise.all(pendingRscCacheBustingDigests);
await new Promise((resolve) => setTimeout(resolve, 0));
if (pendingRscCacheBustingDigests.size === 0 && (until === undefined || until())) {
return;
}
}
throw new Error(
`Timed out waiting for prefetch tasks (pending digests: ${pendingRscCacheBustingDigests.size}, condition: ${until?.() ?? "none"})`,
);
}
async function waitForFetchCalls(
fetch: { mock: { calls: unknown[] } },
expectedCalls: number,
): Promise<void> {
await flushPrefetchTasks(() => fetch.mock.calls.length >= expectedCalls);
}
async function waitForFetchCall(
fetch: { mock: { calls: unknown[][] } },
predicate: (call: unknown[]) => boolean,
): Promise<unknown[]> {
for (let attempt = 0; attempt < 100; attempt++) {
await flushPrefetchTasks();
const call = fetch.mock.calls.find(predicate);
if (call) return call;
await new Promise((resolve) => setTimeout(resolve, 0));
}
throw new Error("Timed out waiting for matching fetch call");
}
function expectCanonicalRscFetchCall(
call: unknown[] | undefined,
pathname: string,
initMatcher: unknown,
): void {
expect(call).toBeDefined();
const input = call?.[0];
expect(typeof input).toBe("string");
if (typeof input !== "string") return;
const url = new URL(input, "https://example.com");
expect(url.pathname).toBe(pathname);
expect(url.searchParams.has("_rsc")).toBe(true);
expect(call?.[1]).toEqual(initMatcher);
}
describe("Link prefetch pure decisions", () => {
it("decides whether Link should prefetch and with which priority", () => {
const cases = [
{
name: "dev + viewport",
input: {
nodeEnv: "development",
prefetch: undefined,
isDangerous: false,
intent: "viewport",
},
expected: { shouldPrefetch: false },
},
{
name: "dev + intent",
input: {
nodeEnv: "development",
prefetch: undefined,
isDangerous: false,
intent: "intent",
},
expected: { shouldPrefetch: false },
},
{
name: "prod + viewport",
input: {
nodeEnv: "production",
prefetch: undefined,
isDangerous: false,
intent: "viewport",
},
expected: { shouldPrefetch: true, priority: "low" },
},
{
name: "prod + intent",
input: { nodeEnv: "production", prefetch: undefined, isDangerous: false, intent: "intent" },
expected: { shouldPrefetch: true, priority: "high" },
},
{
name: "prod + app intent + prefetch=false",
input: { nodeEnv: "production", prefetch: false, isDangerous: false, intent: "intent" },
expected: { shouldPrefetch: false },
},
{
name: "prod + pages intent + prefetch=false",
input: {
nodeEnv: "production",
prefetch: false,
isDangerous: false,
intent: "intent",
routerMode: "pages",
},
expected: { shouldPrefetch: true, priority: "high" },
},
{
name: "prod + dangerous",
input: { nodeEnv: "production", prefetch: undefined, isDangerous: true, intent: "intent" },
expected: { shouldPrefetch: false },
},
] satisfies Array<{
name: string;
input: {
nodeEnv: string;
prefetch: boolean | undefined;
isDangerous: boolean;
intent: LinkPrefetchIntent;
routerMode?: LinkPrefetchRouterMode;
};
expected: LinkPrefetchDecision;
}>;
for (const testCase of cases) {
expect(getLinkPrefetchDecision(testCase.input), testCase.name).toEqual(testCase.expected);
}
});
it("normalizes only local or same-origin prefetch hrefs", () => {
const cases = [
{
name: "local path",
input: { href: "/local", basePath: "", currentOrigin: "https://example.com" },
expected: "/local",
},
{
name: "same-origin absolute URL",
input: {
href: "https://example.com/path",
basePath: "",
currentOrigin: "https://example.com",
},
expected: "/path",
},
{
name: "same-origin protocol-relative URL",
input: { href: "//example.com/path", basePath: "", currentOrigin: "https://example.com" },
expected: "/path",
},
{
name: "external absolute URL",
input: {
href: "https://external.com/path",
basePath: "",
currentOrigin: "https://example.com",
},
expected: null,
},
{
name: "external protocol-relative URL",
input: { href: "//external.com/path", basePath: "", currentOrigin: "https://example.com" },
expected: null,
},
{
name: "mailto URL",
input: {
href: "mailto:hello@example.com",
basePath: "",
currentOrigin: "https://example.com",
},
expected: null,
},
{
name: "tel URL",
input: { href: "tel:+123456789", basePath: "", currentOrigin: "https://example.com" },
expected: null,
},
{
name: "sms URL",
input: { href: "sms:+123456789", basePath: "", currentOrigin: "https://example.com" },
expected: null,
},
{
name: "same-origin with basePath",
input: {
href: "https://example.com/docs/path?tab=1#section",
basePath: "/docs",
currentOrigin: "https://example.com",
},
expected: "/path?tab=1#section",
},
{
name: "same-origin without required basePath",
input: {
href: "https://example.com/path",
basePath: "/docs",
currentOrigin: "https://example.com",
},
expected: null,
},
] satisfies Array<{
name: string;
input: Parameters<typeof getLinkPrefetchHref>[0];
expected: string | null;
}>;
for (const testCase of cases) {
expect(getLinkPrefetchHref(testCase.input), testCase.name).toBe(testCase.expected);
}
});
});
const pendingRscCacheBustingDigests = new Set<Promise<ArrayBuffer>>();
let rscCacheBustingDigestDelayMs = 0;
beforeEach(() => {
rscCacheBustingDigestDelayMs = 0;
const digest = globalThis.crypto.subtle.digest.bind(globalThis.crypto.subtle);
vi.spyOn(globalThis.crypto.subtle, "digest").mockImplementation((algorithm, data) => {
const pending = (async () => {
if (rscCacheBustingDigestDelayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, rscCacheBustingDigestDelayMs));
}
return digest(algorithm, data);
})();
pendingRscCacheBustingDigests.add(pending);
void pending.then(
() => pendingRscCacheBustingDigests.delete(pending),
() => pendingRscCacheBustingDigests.delete(pending),
);
return pending;
});
});
afterEach(async () => {
try {
await flushPrefetchTasks();
} finally {
pendingRscCacheBustingDigests.clear();
vi.doUnmock("react");
vi.doUnmock("react/jsx-runtime");
vi.doUnmock("react/jsx-dev-runtime");
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.resetModules();
}
});
describe("Link App Router navigation scheduling", () => {
it.each([
{ locationMethod: "assign" as const, replace: false },
{ locationMethod: "replace" as const, replace: true },
])(
"uses document navigation in App-only builds when replace=$replace",
async ({ replace, locationMethod }) => {
const previousHasPagesRouter = process.env.__VINEXT_HAS_PAGES_ROUTER;
process.env.__VINEXT_HAS_PAGES_ROUTER = "false";
vi.resetModules();
try {
let capturedAnchorProps: CapturedAnchorProps | undefined;
mockReactAnchorCaptureForLinkOnly_DO_NOT_REUSE({
captureAnchor(type, props) {
if (type === "a" && props !== null && typeof props === "object") {
capturedAnchorProps = props;
}
},
});
const pushState = vi.fn();
const replaceState = vi.fn();
const dispatchEvent = vi.fn();
const locationAssign = vi.fn();
const locationReplace = vi.fn();
vi.stubGlobal("window", {
addEventListener: vi.fn(),
dispatchEvent,
history: { pushState, replaceState },
location: {
assign: locationAssign,
href: "https://example.com/current",
origin: "https://example.com",
replace: locationReplace,
},
scrollTo: vi.fn(),
});
const { default: IsolatedLink } = await import("../packages/vinext/src/shims/link.js");
const React = await vi.importActual<typeof import("react")>("react");
ReactDOMServer.renderToString(
React.createElement(
IsolatedLink,
{ href: "/target", prefetch: false, replace },
"target",
),
);
const onClick = capturedAnchorProps?.onClick;
if (onClick === undefined) {
throw new Error("Expected rendered Link anchor to expose an onClick handler");
}
const clickEvent = {
button: 0,
currentTarget: { hasAttribute: () => false, target: "" },
defaultPrevented: false,
preventDefault() {
this.defaultPrevented = true;
},
};
await onClick(clickEvent);
expect(clickEvent.defaultPrevented).toBe(true);
expect(
{ assign: locationAssign, replace: locationReplace }[locationMethod],
).toHaveBeenCalledWith("/target");
expect(pushState).not.toHaveBeenCalled();
expect(replaceState).not.toHaveBeenCalled();
expect(dispatchEvent).not.toHaveBeenCalled();
} finally {
if (previousHasPagesRouter === undefined) {
delete process.env.__VINEXT_HAS_PAGES_ROUTER;
} else {
process.env.__VINEXT_HAS_PAGES_ROUTER = previousHasPagesRouter;
}
}
},
);
it("clicking an RSC Link starts app-router navigation inside a React transition", async () => {
vi.resetModules();
let capturedAnchorProps: CapturedAnchorProps | undefined;
let transitionActive = false;
const transitionStates: boolean[] = [];
const startTransition = vi.fn((callback: () => void) => {
transitionActive = true;
try {
callback();
} finally {
transitionActive = false;
}
});
const captureAnchor = (type: unknown, props: unknown) => {
if (type === "a" && props !== null && typeof props === "object") {
capturedAnchorProps = props;
}
};
mockReactAnchorCaptureForLinkOnly_DO_NOT_REUSE({ captureAnchor, startTransition });
const navigate = vi.fn(async () => {
transitionStates.push(transitionActive);
});
vi.stubGlobal("window", {
[Symbol.for("vinext.navigationRuntime")]: {
bootstrap: {
routeManifest: null,
rsc: undefined,
},
functions: {
navigate,
},
},
addEventListener: vi.fn(),
history: {
pushState: vi.fn(),
replaceState: vi.fn(),
},
location: {
href: "https://example.com/current",
origin: "https://example.com",
},
scrollTo: vi.fn(),
});
// Load link.js BEFORE importActual("react"). Earlier these two imports ran
// in parallel via Promise.all, but that race made the mock occasionally not
// intercept link.tsx's transitive `import React from "react"` — when
// importActual won the race, "react" landed in the module cache as the
// actual module first, and link.tsx's import then resolved to that cached
// entry instead of the doMock factory. That caused React.startTransition
// inside Link to be the real implementation rather than the spy, so the
// assertion on `toHaveBeenCalledTimes(1)` would flake to 0.
// Sequencing the imports guarantees the doMock factory runs first.
const { default: IsolatedLink } = await import("../packages/vinext/src/shims/link.js");
const React = await vi.importActual<typeof import("react")>("react");
ReactDOMServer.renderToString(
React.createElement(IsolatedLink, { href: "/target", prefetch: false }, "target"),
);
const clickEvent = {
button: 0,
currentTarget: { hasAttribute: () => false, target: "" },
defaultPrevented: false,
preventDefault() {
this.defaultPrevented = true;
},
};
const onClick = capturedAnchorProps?.onClick;
expect(onClick).toBeTypeOf("function");
if (onClick === undefined) {
throw new Error("Expected rendered Link anchor to expose an onClick handler");
}
await onClick(clickEvent);
expect(clickEvent.defaultPrevented).toBe(true);
expect(startTransition).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith(
"/target",
0,
"navigate",
"push",
undefined,
false,
undefined,
expect.objectContaining({
commitId: null,
hash: null,
id: expect.any(Number),
}),
"transition",
false,
);
expect(transitionStates).toEqual([true]);
});
it("preserves the current query when an App Router Link changes only the hash", async () => {
// Ported from Next.js: test/e2e/app-dir/navigation/navigation.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/navigation/navigation.test.ts
const pushState = vi.fn();
const replaceState = vi.fn();
const result = await renderIsolatedLink({
href: "#h1",
nodeEnv: "production",
props: { prefetch: false },
windowOverrides: {
history: { pushState, replaceState, state: {} },
location: {
href: "https://example.com/nested-relative-query-and-hash?here=ok#h2",
origin: "https://example.com",
pathname: "/nested-relative-query-and-hash",
search: "?here=ok",
},
},
});
try {
const onClick = result.capturedAnchorProps.onClick;
expect(onClick).toBeTypeOf("function");
if (onClick === undefined) {
throw new Error("Expected rendered Link anchor to expose an onClick handler");
}
await onClick({
button: 0,
currentTarget: { hasAttribute: () => false, target: "" },
defaultPrevented: false,
preventDefault() {
this.defaultPrevented = true;
},
});
expect(pushState).toHaveBeenCalledWith(
null,
"",
"/nested-relative-query-and-hash?here=ok#h1",
);
expect(result.navigate).not.toHaveBeenCalled();
} finally {
result.restoreNodeEnv();
}
});
it("lets the browser handle native URI schemes without app-router navigation", async () => {
const userOnClick = vi.fn();
const hrefs = ["mailto:hello@example.com", "tel:+123456789", "sms:+123456789"];
for (const href of hrefs) {
const result = await renderIsolatedLink({
href,
nodeEnv: "production",
props: { onClick: userOnClick, prefetch: false },
requireRef: false,
});
try {
const clickEvent = {
button: 0,
currentTarget: { hasAttribute: () => false, target: "" },
defaultPrevented: false,
preventDefault() {
this.defaultPrevented = true;
},
};
const onClick = result.capturedAnchorProps.onClick;
expect(onClick).toBeTypeOf("function");
if (onClick === undefined) {
throw new Error("Expected rendered Link anchor to expose an onClick handler");
}
await onClick(clickEvent);
expect(userOnClick).toHaveBeenCalledWith(clickEvent);
expect(clickEvent.defaultPrevented).toBe(false);
expect(result.navigate).not.toHaveBeenCalled();
} finally {
result.restoreNodeEnv();
}
}
});
it("lets the browser handle download links without app-router navigation", async () => {
vi.resetModules();
let capturedAnchorProps: CapturedAnchorProps | undefined;
const startTransition = vi.fn((callback: () => void) => {
callback();
});
const captureAnchor = (type: unknown, props: unknown) => {
if (type === "a" && props !== null && typeof props === "object") {
capturedAnchorProps = props;
}
};
mockReactAnchorCaptureForLinkOnly_DO_NOT_REUSE({ captureAnchor, startTransition });
const navigate = vi.fn(async () => {});
vi.stubGlobal("window", {
[Symbol.for("vinext.navigationRuntime")]: {
bootstrap: {
routeManifest: null,
rsc: undefined,
},
functions: {
navigate,
},
},
addEventListener: vi.fn(),
history: {
pushState: vi.fn(),
replaceState: vi.fn(),
},
location: {
href: "https://example.com/current",
origin: "https://example.com",
},
scrollTo: vi.fn(),
});
const { default: IsolatedLink } = await import("../packages/vinext/src/shims/link.js");
const React = await vi.importActual<typeof import("react")>("react");
const onClick = vi.fn();
const onNavigate = vi.fn();
// Ported from Next.js: test/e2e/link-on-navigate-prop/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/link-on-navigate-prop/index.test.ts
ReactDOMServer.renderToString(
React.createElement(
IsolatedLink,
{ download: true, href: "/file.pdf", onClick, onNavigate, prefetch: false },
"download",
),
);
const clickEvent = {
button: 0,
currentTarget: {
hasAttribute: (name: string) => name === "download",
target: "",
},
defaultPrevented: false,
preventDefault() {
this.defaultPrevented = true;
},
};
const linkOnClick = capturedAnchorProps?.onClick;
expect(linkOnClick).toBeTypeOf("function");
if (linkOnClick === undefined) {
throw new Error("Expected rendered Link anchor to expose an onClick handler");
}
await linkOnClick(clickEvent);
expect(onClick).toHaveBeenCalledTimes(1);
expect(clickEvent.defaultPrevented).toBe(false);
expect(onNavigate).not.toHaveBeenCalled();
expect(startTransition).not.toHaveBeenCalled();
expect(navigate).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Link onNavigate prop — Next.js 15 contract
//
// Ported from Next.js: test/e2e/link-on-navigate-prop/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/link-on-navigate-prop/index.test.ts
//
// The Next.js contract (see `.nextjs-ref/packages/next/src/client/link.tsx`
// `linkClicked`) is:
// 1. onClick always fires first (regardless of modifier, target, download,
// or external href).
// 2. onNavigate only fires when the Link is about to perform its own
// client-side navigation: skipped for modifier-key clicks, target=_blank,
// download links, and truly external URLs.
// 3. Calling `event.preventDefault()` inside onNavigate cancels the Link's
// navigation.
// 4. External URLs with the `replace` prop must call
// `window.location.replace()` instead of letting the browser push.
// ---------------------------------------------------------------------------
describe("Link onNavigate prop", () => {
type NavigateEventLike = {
preventDefault(): void;
defaultPrevented?: boolean;
url?: URL;
};
async function renderLinkAndClick(args: {
href: string;
props?: Record<string, unknown>;
clickEvent: Partial<{
altKey: boolean;
button: number;
ctrlKey: boolean;
metaKey: boolean;
shiftKey: boolean;
currentTarget: { hasAttribute(name: string): boolean; target: string };
}>;
locationOverrides?: Record<string, unknown>;
}) {
vi.resetModules();
let capturedAnchorProps: CapturedAnchorProps | undefined;
const startTransition = vi.fn((callback: () => void) => {
callback();
});
const captureAnchor = (type: unknown, props: unknown) => {
if (type === "a" && props !== null && typeof props === "object") {
capturedAnchorProps = props;
}
};
mockReactAnchorCaptureForLinkOnly_DO_NOT_REUSE({ captureAnchor, startTransition });
const navigate = vi.fn(async () => {});
const locationReplace = vi.fn();
const locationAssign = vi.fn();
const pushState = vi.fn();
const replaceState = vi.fn();
vi.stubGlobal("window", {
[Symbol.for("vinext.navigationRuntime")]: {
bootstrap: { routeManifest: null, rsc: undefined },
functions: { navigate },
},
addEventListener: vi.fn(),
dispatchEvent: vi.fn(),
history: { pushState, replaceState },
location: {
href: "https://example.com/current",
origin: "https://example.com",
replace: locationReplace,
assign: locationAssign,
...args.locationOverrides,
},
scrollTo: vi.fn(),
});
const { default: IsolatedLink } = await import("../packages/vinext/src/shims/link.js");
const React = await vi.importActual<typeof import("react")>("react");
ReactDOMServer.renderToString(
React.createElement(
IsolatedLink,
{ href: args.href, prefetch: false, ...args.props },
"target",
),
);
const onClickHandler = capturedAnchorProps?.onClick;
if (typeof onClickHandler !== "function") {
throw new Error("Expected rendered Link anchor to expose an onClick handler");
}
const clickEvent = {
button: 0,
currentTarget: { hasAttribute: () => false, target: "" },
defaultPrevented: false,
preventDefault() {
this.defaultPrevented = true;
},
...args.clickEvent,
};
await onClickHandler(clickEvent);
return {
clickEvent,
locationReplace,
locationAssign,
navigate,
startTransition,
pushState,
replaceState,
};
}
it("fires onClick and onNavigate for an internal click", async () => {
const onClick = vi.fn();
const onNavigate = vi.fn();
const result = await renderLinkAndClick({
href: "/subpage",
props: { onClick, onNavigate },
clickEvent: {},
});
expect(onClick).toHaveBeenCalledTimes(1);
expect(onNavigate).toHaveBeenCalledTimes(1);
expect(result.clickEvent.defaultPrevented).toBe(true);
expect(result.navigate).toHaveBeenCalledTimes(1);
});
it("passes a NavigateEvent exposing preventDefault to onNavigate", async () => {
let received: NavigateEventLike | undefined;
const onNavigate = vi.fn((event: NavigateEventLike) => {
received = event;
});
await renderLinkAndClick({
href: "/subpage",
props: { onNavigate },
clickEvent: {},
});
expect(typeof received?.preventDefault).toBe("function");
});
it("cancels navigation when onNavigate calls preventDefault", async () => {
const onClick = vi.fn();
const onNavigate = vi.fn((event: NavigateEventLike) => {
event.preventDefault();
});
const result = await renderLinkAndClick({
href: "/subpage",
props: { onClick, onNavigate },
clickEvent: {},
});
expect(onClick).toHaveBeenCalledTimes(1);
expect(onNavigate).toHaveBeenCalledTimes(1);
// Link still calls preventDefault on the click so the <a> doesn't navigate.
expect(result.clickEvent.defaultPrevented).toBe(true);
// ...but the client-side navigation must not happen.
expect(result.navigate).not.toHaveBeenCalled();
});
it("fires onClick but skips onNavigate when a modifier key is held", async () => {
const onClick = vi.fn();
const onNavigate = vi.fn();
const result = await renderLinkAndClick({
href: "/subpage",
props: { onClick, onNavigate },
clickEvent: { metaKey: true },
});
expect(onClick).toHaveBeenCalledTimes(1);
expect(onNavigate).not.toHaveBeenCalled();
// Browser default must run so the modifier-key shortcut still opens a tab.
expect(result.clickEvent.defaultPrevented).toBe(false);
expect(result.navigate).not.toHaveBeenCalled();
});
it("fires onClick but skips onNavigate for target=_blank", async () => {
const onClick = vi.fn();
const onNavigate = vi.fn();
const result = await renderLinkAndClick({
href: "/subpage",
props: { onClick, onNavigate, target: "_blank" },
clickEvent: {
currentTarget: { hasAttribute: () => false, target: "_blank" },
},
});
expect(onClick).toHaveBeenCalledTimes(1);
expect(onNavigate).not.toHaveBeenCalled();
expect(result.clickEvent.defaultPrevented).toBe(false);
expect(result.navigate).not.toHaveBeenCalled();
});
it("fires onClick but skips onNavigate for download links", async () => {
const onClick = vi.fn();
const onNavigate = vi.fn();
const result = await renderLinkAndClick({
href: "/zip.zip",
props: { download: true, onClick, onNavigate },
clickEvent: {
currentTarget: {
hasAttribute: (name: string) => name === "download",
target: "",
},
},
});
expect(onClick).toHaveBeenCalledTimes(1);
expect(onNavigate).not.toHaveBeenCalled();
expect(result.clickEvent.defaultPrevented).toBe(false);
expect(result.navigate).not.toHaveBeenCalled();
});
it("fires onClick but skips onNavigate for external URLs", async () => {
const onClick = vi.fn();
const onNavigate = vi.fn();
const result = await renderLinkAndClick({
href: "https://example.org/about",
props: { onClick, onNavigate },
clickEvent: {},
});
expect(onClick).toHaveBeenCalledTimes(1);
expect(onNavigate).not.toHaveBeenCalled();
// Without replace, the browser's default click navigation handles it.
expect(result.clickEvent.defaultPrevented).toBe(false);
expect(result.locationReplace).not.toHaveBeenCalled();
expect(result.navigate).not.toHaveBeenCalled();
});
it("calls location.replace for external URLs with the replace prop", async () => {
const onClick = vi.fn();
const onNavigate = vi.fn();
const result = await renderLinkAndClick({
href: "https://example.org/about",
props: { replace: true, onClick, onNavigate },
clickEvent: {},
});
expect(onClick).toHaveBeenCalledTimes(1);
expect(onNavigate).not.toHaveBeenCalled();
// Browser default would push — we must prevent it so the replace below
// doesn't end up creating a second history entry.
expect(result.clickEvent.defaultPrevented).toBe(true);
expect(result.locationReplace).toHaveBeenCalledTimes(1);
expect(result.locationReplace).toHaveBeenCalledWith("https://example.org/about");
});
});
// ───────────────────────────────────────────────────────────────────────────
// Pages Router onClick / preventDefault semantics
//
// Regression coverage for issue #1470: in new-link-behavior mode (the only
// supported behavior in Next.js 13+), the `onClick` prop passed to <Link>
// must fire on click, and `event.preventDefault()` inside that handler must
// cancel the resulting client-side navigation.
//
// Mirrors Next.js: test/e2e/new-link-behavior/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/new-link-behavior/index.test.ts
// ───────────────────────────────────────────────────────────────────────────
describe("Pages Router Link onClick semantics", () => {
async function renderPagesRouterLinkAndClick(args: {
href: string | { pathname?: string; query?: Record<string, string>; hash?: string };
props?: Record<string, unknown>;
currentHref?: string;
pagesRouterAsPath?: string;
locale?: string;
}) {
vi.resetModules();
let capturedAnchorProps: CapturedAnchorProps | undefined;
const captureAnchor = (type: unknown, props: unknown) => {
if (type === "a" && props !== null && typeof props === "object") {
capturedAnchorProps = props;
}
};
mockReactAnchorCaptureForLinkOnly_DO_NOT_REUSE({ captureAnchor });
// Stub Pages Router navigation at our own boundary instead of mocking
// `next/router` itself — keeps the mock surface to vinext-owned modules
// and avoids the dynamic-import-into-unknown-module timing pitfall.
const pagesRouterCalls: {
href: string;
as?: string;
replace: boolean;
shallow?: boolean;
interpolateDynamicRoute?: boolean;
}[] = [];
vi.doMock(
"../packages/vinext/src/client/pages-router-link-navigation.js",
async (importOriginal) => ({
...(await importOriginal<
typeof import("../packages/vinext/src/client/pages-router-link-navigation.js")
>()),
navigatePagesRouterLinkWithFallback: async ({
navigation,
}: {
navigation: {
href: string;
as?: string;
replace: boolean;
shallow?: boolean;
interpolateDynamicRoute?: boolean;
};
}) => {
pagesRouterCalls.push({
href: navigation.href,
...(navigation.as === undefined ? undefined : { as: navigation.as }),
replace: navigation.replace,
...(navigation.shallow ? { shallow: true } : undefined),
...(navigation.interpolateDynamicRoute ? { interpolateDynamicRoute: true } : undefined),
});
},
}),
);
// The handler still tries `await import("next/router")` before calling
// navigatePagesRouterLink. Stub it so the import resolves cleanly (the
// returned Router is never used because we mocked the navigation boundary).
vi.doMock("next/router", () => ({ default: { push() {}, replace() {} } }));
const pushState = vi.fn();
const replaceState = vi.fn();
const dispatchEvent = vi.fn();
const currentUrl = new URL(args.currentHref ?? "https://example.com/current");
const windowValue: Record<string, unknown> = {
// No vinext.navigationRuntime — that selects the Pages Router branch
// inside Link's click handler.
addEventListener: vi.fn(),
dispatchEvent,
history: { pushState, replaceState },
location: {
href: currentUrl.href,
origin: currentUrl.origin,
pathname: currentUrl.pathname,
search: currentUrl.search,
hash: currentUrl.hash,
},
scrollTo: vi.fn(),
__NEXT_DATA__: { props: {} },
};
if (args.pagesRouterAsPath !== undefined) {
windowValue.next = {
router: { asPath: args.pagesRouterAsPath, reload() {} },
};
}
if (args.locale !== undefined) {
windowValue.__VINEXT_LOCALE__ = args.locale;
windowValue.__VINEXT_LOCALES__ = ["en", "fr"];
windowValue.__VINEXT_DEFAULT_LOCALE__ = "en";
}
vi.stubGlobal("window", windowValue);
const { default: IsolatedLink } = await import("../packages/vinext/src/shims/link.js");
const { RouterContext } =
await import("../packages/vinext/src/shims/internal/router-context.js");
const React = await vi.importActual<typeof import("react")>("react");
ReactDOMServer.renderToString(
React.createElement(
RouterContext.Provider,
{ value: {} as never },
React.createElement(
IsolatedLink,
{ href: args.href, prefetch: false, ...args.props },
"target",
),
),
);
const onClickHandler = capturedAnchorProps?.onClick;
if (typeof onClickHandler !== "function") {
throw new Error("Expected rendered Link anchor to expose an onClick handler");
}
const clickEvent = {
button: 0,
currentTarget: { hasAttribute: () => false, target: "" },
defaultPrevented: false,
preventDefault() {
this.defaultPrevented = true;
},
};
await onClickHandler(clickEvent);
// The anchor's onClick handler is sync (`void handleClick(event)`) — it
// kicks off the async handleClick without awaiting it. Drain the
// microtask queue so the dynamic import + router push finish before we
// observe side effects.
for (let i = 0; i < 20; i++) await Promise.resolve();
return { clickEvent, pushState, replaceState, dispatchEvent, pagesRouterCalls };
}
it("fires the onClick prop on Link click", async () => {
let onClickCalls = 0;
let receivedEvent: { defaultPrevented?: boolean } | undefined;
const onClick = (event: { preventDefault(): void; defaultPrevented?: boolean }) => {
onClickCalls += 1;
receivedEvent = event;
};
const result = await renderPagesRouterLinkAndClick({
href: "/",
props: { onClick },
});
expect(onClickCalls).toBe(1);
// The onClick handler must have actually been invoked with the click
// event — Next.js parity: passing onClick to <Link> in the new-link
// behavior must run the user's handler on click.
expect(receivedEvent).toBe(result.clickEvent);
// The click's default is prevented so the browser does not perform a
// full-page navigation — Link takes over via the router.
expect(result.clickEvent.defaultPrevented).toBe(true);
// ...and the Pages Router navigation is actually scheduled.
expect(result.pagesRouterCalls).toEqual([{ href: "/", replace: false }]);
});
it("forwards shallow Pages links to the router", async () => {
const result = await renderPagesRouterLinkAndClick({
href: "/sha?hello=world",
props: { shallow: true },
});
expect(result.pagesRouterCalls).toEqual([
{ href: "/sha?hello=world", replace: false, shallow: true },
]);
});
it("resolves hash-only URL objects against the current locale-free asPath", async () => {
// Ported from Next.js:
// test/e2e/i18n-support-same-page-hash-change/i18n-support-same-page-hash-change.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/i18n-support-same-page-hash-change/i18n-support-same-page-hash-change.test.ts
const result = await renderPagesRouterLinkAndClick({
href: { hash: "#newhash" },
props: { locale: "fr" },
currentHref: "https://example.com/fr/about?tab=details#hash",
pagesRouterAsPath: "/about?tab=details",
locale: "fr",
});
expect(result.pagesRouterCalls).toEqual([
{ href: "/fr/about?tab=details#newhash", replace: false },
]);
});
it("resolves hash-only string hrefs against the current locale-free asPath", async () => {
// Ported from Next.js:
// test/e2e/i18n-support-same-page-hash-change/i18n-support-same-page-hash-change.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/i18n-support-same-page-hash-change/i18n-support-same-page-hash-change.test.ts
const result = await renderPagesRouterLinkAndClick({
href: "#newhash",
props: { locale: "fr" },
currentHref: "https://example.com/fr/about?tab=details#hash",
pagesRouterAsPath: "/about?tab=details",
locale: "fr",
});
expect(result.pagesRouterCalls).toEqual([
{ href: "/fr/about?tab=details#newhash", replace: false },
]);
});
it("preserves dynamic interpolation for query-only string hrefs on rewritten paths", async () => {
const result = await renderPagesRouterLinkAndClick({
href: "?params=1",
currentHref: "https://example.com/rewrite-navigation/0",
pagesRouterAsPath: "/rewrite-navigation/0",
});
expect(result.pagesRouterCalls).toEqual([
{
href: "/rewrite-navigation/0?params=1",
replace: false,
interpolateDynamicRoute: true,
},
]);
});
// Ported from Next.js: test/e2e/dynamic-routing/pages/index.js
// https://github.com/vercel/next.js/blob/canary/test/e2e/dynamic-routing/pages/index.js
it("passes the route pattern and interpolated URL when object hrefs fill dynamic segments", async () => {
const result = await renderPagesRouterLinkAndClick({
href: {
pathname: "/[a]/[b]/c",
query: { a: "a", b: "b", q: "q" },
},
});
expect(result.pagesRouterCalls).toEqual([
{
href: "/[a]/[b]/c?a=a&b=b&q=q",
as: "/a/b/c?q=q",
replace: false,
},
]);
});
it("preserves a basePath page when navigating to a hash link", async () => {
// Ported from Next.js: test/e2e/basepath/query-hash.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/basepath/query-hash.test.ts
const previousBasePath = process.env.__NEXT_ROUTER_BASEPATH;
process.env.__NEXT_ROUTER_BASEPATH = "/docs";
try {
const result = await renderPagesRouterLinkAndClick({
href: "#hashlink",
currentHref: "https://example.com/docs/hello",
});
expect(result.pagesRouterCalls).toEqual([{ href: "/hello#hashlink", replace: false }]);
} finally {
if (previousBasePath === undefined) {
delete process.env.__NEXT_ROUTER_BASEPATH;
} else {
process.env.__NEXT_ROUTER_BASEPATH = previousBasePath;
}
}
});
it("cancels client-side navigation when onClick calls preventDefault", async () => {
let observedAfterPreventDefault: boolean | undefined;
let observedHandlerCalls = 0;
const onClick = (event: { preventDefault(): void; defaultPrevented?: boolean }) => {
observedHandlerCalls += 1;
event.preventDefault();
observedAfterPreventDefault = event.defaultPrevented;
};
const result = await renderPagesRouterLinkAndClick({
href: "/about",
props: { onClick },
});
expect(observedHandlerCalls).toBe(1);
// Sanity: the user's onClick actually toggled defaultPrevented on the event.
expect(observedAfterPreventDefault).toBe(true);
expect(result.clickEvent.defaultPrevented).toBe(true);
// The user called preventDefault inside onClick: Link MUST honor it and
// skip its own navigation. No router push, no history mutation, no
// popstate dispatch.
expect(result.pagesRouterCalls).toEqual([]);
expect(result.pushState).not.toHaveBeenCalled();
expect(result.replaceState).not.toHaveBeenCalled();
expect(result.dispatchEvent).not.toHaveBeenCalled();
});
});
async function renderIsolatedLink(options: {
appNavigation?: boolean;
canonicalRsc?: boolean;
href: string;
nodeEnv: string;
props?: Record<string, unknown>;
requireRef?: boolean;
routeManifest?: RouteManifest;
windowOverrides?: Record<string, unknown>;
}) {
vi.resetModules();
const restoreNodeEnv = () => {
vi.unstubAllEnvs();
};
vi.stubEnv("NODE_ENV", options.nodeEnv);
vi.stubEnv("__VINEXT_CANONICAL_RSC_REQUESTS", options.canonicalRsc ? "1" : "");
const effects: CapturedEffect[] = [];
let capturedAnchorProps: CapturedAnchorProps | undefined;
const captureAnchor = (type: unknown, props: unknown) => {
if (type === "a" && props !== null && typeof props === "object") {
capturedAnchorProps = props;
}
};
mockReactAnchorCaptureForLinkOnly_DO_NOT_REUSE({
captureAnchor,
captureEffect(effect) {
effects.push(effect);
},
});
const fetch = vi.fn((_input: RequestInfo | URL, _init?: RequestInit) =>
Promise.resolve(new Response("")),
);
const navigate = vi.fn();
const pagePrefetchLinks: CapturedPrefetchLinkElement[] = [];
const location = {
href: "https://example.com/current",
origin: "https://example.com",
pathname: "/current",
search: "",
};
const navigationRuntime =
options.appNavigation === false
? undefined
: createTestNavigationRuntime(navigate, options.routeManifest ?? null);
vi.stubGlobal("fetch", fetch);
vi.stubGlobal("document", {
createElement: vi.fn(() => ({})),
getElementById: vi.fn(() => null),
getElementsByName: vi.fn(() => []),
head: {
appendChild: vi.fn((node: CapturedPrefetchLinkElement) => {
pagePrefetchLinks.push(node);
}),
},
});
vi.stubGlobal("window", {
...(navigationRuntime === undefined
? {}
: { [Symbol.for("vinext.navigationRuntime")]: navigationRuntime }),
addEventListener: vi.fn(),
dispatchEvent: vi.fn(),
history: {
pushState: vi.fn(),
replaceState: vi.fn(),
},
location,
__VINEXT_LINK_PREFETCH_ROUTES__: linkPrefetchRoutes,
requestIdleCallback: vi.fn((callback: () => void) => {
callback();
return 1;
}),
scrollTo: vi.fn(),
...options.windowOverrides,
});
const { default: IsolatedLink } = await import("../packages/vinext/src/shims/link.js");
const { RouterContext } = await import("../packages/vinext/src/shims/internal/router-context.js");
const React = await vi.importActual<typeof import("react")>("react");
try {
const link = React.createElement(
IsolatedLink,
{ href: options.href, ...options.props },
"target",
);
ReactDOMServer.renderToString(
options.appNavigation === false
? React.createElement(RouterContext.Provider, { value: {} as never }, link)
: link,
);
if (capturedAnchorProps === undefined) {
throw new Error("Expected rendered Link to expose anchor props");
}
if (options.requireRef !== false && capturedAnchorProps.ref === undefined) {
throw new Error("Expected rendered Link anchor to expose a ref");
}
const anchor = { href: options.href } as HTMLAnchorElement;
capturedAnchorProps.ref?.(anchor);
for (const effect of effects) {
effect();
}
return {
anchor,
capturedAnchorProps,
fetch,
navigate,
pagePrefetchLinks,
restoreNodeEnv,
};
} catch (error) {
restoreNodeEnv();
throw error;
}
}
describe("Link prefetch scheduling", () => {
function stubIntersectionObserver() {
let intersectionCallback: IntersectionObserverCallback | undefined;
const observe = vi.fn();
const unobserve = vi.fn();
class FakeIntersectionObserver {
readonly root = null;
readonly rootMargin = "250px";
readonly thresholds = [0];
constructor(callback: IntersectionObserverCallback) {
intersectionCallback = callback;
}
observe = observe;
unobserve = unobserve;
disconnect = vi.fn();
takeRecords = vi.fn(() => []);
}
vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver);
return {
observe,
unobserve,
dispatchIntersectingEntry(anchor: HTMLAnchorElement, isIntersecting = true) {
const rect = {
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
x: 0,
y: 0,
toJSON: () => ({}),
};
intersectionCallback?.(
[
{
boundingClientRect: rect,
intersectionRatio: isIntersecting ? 1 : 0,
intersectionRect: rect,
isIntersecting,
rootBounds: null,
target: anchor,
time: 0,
},
],
{} as IntersectionObserver,
);
},
};
}
it("starts App Router viewport prefetches before browser idle callbacks", async () => {
const observer = stubIntersectionObserver();
const requestIdleCallback = vi.fn(() => 1);
const result = await renderIsolatedLink({
href: "/viewport-prefetch-target",
nodeEnv: "production",
windowOverrides: { requestIdleCallback },
});
try {
observer.dispatchIntersectingEntry(result.anchor);
await waitForFetchCalls(result.fetch, 1);
expect(requestIdleCallback).not.toHaveBeenCalled();
expectCanonicalRscFetchCall(
result.fetch.mock.calls[0],
"/viewport-prefetch-target",
expect.objectContaining({
credentials: "include",
priority: "low",
}),
);
} finally {
result.restoreNodeEnv();
}
});
it("keeps shallow hybrid App Router links eligible for viewport prefetch", async () => {
vi.stubEnv("__VINEXT_HAS_PAGES_ROUTER", "true");
const observer = stubIntersectionObserver();
const result = await renderIsolatedLink({
href: "/viewport-prefetch-target",
nodeEnv: "production",
props: { shallow: true },
});
try {
expect(observer.observe).toHaveBeenCalledWith(result.anchor);
observer.dispatchIntersectingEntry(result.anchor);
await waitForFetchCalls(result.fetch, 1);
expectCanonicalRscFetchCall(
result.fetch.mock.calls[0],
"/viewport-prefetch-target",
expect.objectContaining({ priority: "low" }),
);
} finally {
result.restoreNodeEnv();
}
});
it("keeps shallow hybrid App Router links eligible for immediate intent prefetch", async () => {
vi.stubEnv("__VINEXT_HAS_PAGES_ROUTER", "true");
const requestAnimationFrame = vi.fn(() => 1);
const result = await renderIsolatedLink({
href: "/intent-prefetch-target",
nodeEnv: "production",
props: { shallow: true },
windowOverrides: { requestAnimationFrame },
});
try {
result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor });
await waitForFetchCalls(result.fetch, 1);
expect(requestAnimationFrame).not.toHaveBeenCalled();
expectCanonicalRscFetchCall(
result.fetch.mock.calls[0],
"/intent-prefetch-target",
expect.objectContaining({ priority: "high" }),
);
} finally {
result.restoreNodeEnv();
}
});
it("prefetches visible links in production with low priority", async () => {
const observer = stubIntersectionObserver();
const result = await renderIsolatedLink({
href: "/viewport-prefetch-target",
nodeEnv: "production",
});
try {
expect(observer.observe).toHaveBeenCalledWith(result.anchor);
observer.dispatchIntersectingEntry(result.anchor);
await waitForFetchCalls(result.fetch, 1);
expect(observer.unobserve).not.toHaveBeenCalledWith(result.anchor);
expectCanonicalRscFetchCall(
result.fetch.mock.calls[0],
"/viewport-prefetch-target",
expect.objectContaining({
credentials: "include",
priority: "low",
}),
);
const requestHeaders = new Headers(result.fetch.mock.calls[0]?.[1]?.headers);
expect(requestHeaders.get("next-router-prefetch")).toBe("1");
expect(requestHeaders.get("next-url")).toBe("/current");
expect(
JSON.parse(decodeURIComponent(requestHeaders.get("next-router-state-tree") ?? "")),
).toEqual({ pathAndSearch: "/current", routeId: "route:/current" });
} finally {
result.restoreNodeEnv();
}
});
it("does not viewport-prefetch same-page query and hash links", async () => {
const observer = stubIntersectionObserver();
const result = await renderIsolatedLink({
href: "/current?with-query-param#hash-160",
nodeEnv: "production",
});
try {
observer.dispatchIntersectingEntry(result.anchor);
await flushPrefetchTasks();
// Ported from Next.js: test/e2e/app-dir/navigation/navigation.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/navigation/navigation.test.ts
expect(result.fetch).not.toHaveBeenCalled();
} finally {
result.restoreNodeEnv();
}
});
it("starts App Router viewport prefetches before browser idle callbacks", async () => {
const observer = stubIntersectionObserver();
const requestIdleCallback = vi.fn(() => 1);
const result = await renderIsolatedLink({
href: "/viewport-prefetch-target",
nodeEnv: "production",
windowOverrides: { requestIdleCallback },
});
try {
observer.dispatchIntersectingEntry(result.anchor);
await waitForFetchCalls(result.fetch, 1);
expect(requestIdleCallback).not.toHaveBeenCalled();
expectCanonicalRscFetchCall(
result.fetch.mock.calls[0],
"/viewport-prefetch-target",
expect.objectContaining({
credentials: "include",
priority: "low",
}),
);
} finally {
result.restoreNodeEnv();
}
});
it("does not prefetch visible or hovered links for a bot user agent", async () => {
// Ported from Next.js:
// test/e2e/app-dir/app-prefetch/prefetching.test.ts
// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/app-prefetch/prefetching.test.ts
const observer = stubIntersectionObserver();
const result = await renderIsolatedLink({
href: "/viewport-prefetch-target",
nodeEnv: "production",
windowOverrides: {
navigator: {
userAgent: "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
},
},
});
try {
observer.dispatchIntersectingEntry(result.anchor);
result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor });
await flushPrefetchTasks();
expect(result.fetch).not.toHaveBeenCalled();
} finally {
result.restoreNodeEnv();
}
});
it("preserves Pages Router viewport, explicit, and intent prefetches for a bot user agent", async () => {
const botWindowOverrides = {
__NEXT_DATA__: {
__vinext: {
pageModuleUrl: "/_next/static/chunks/pages/current.js",
},
},
navigator: {
userAgent: "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
},
};
const defaultViewportObserver = stubIntersectionObserver();
const defaultViewportResult = await renderIsolatedLink({
appNavigation: false,
href: "/pages-bot-default-viewport-prefetch-target",
nodeEnv: "production",
windowOverrides: botWindowOverrides,
});
try {
defaultViewportObserver.dispatchIntersectingEntry(defaultViewportResult.anchor);
await flushPrefetchTasks();
expect(defaultViewportResult.pagePrefetchLinks).toEqual([
{
as: "document",
href: "/pages-bot-default-viewport-prefetch-target",
rel: "prefetch",
},
]);
} finally {
defaultViewportResult.restoreNodeEnv();
}
const explicitViewportObserver = stubIntersectionObserver();
const viewportResult = await renderIsolatedLink({
appNavigation: false,
href: "/pages-bot-explicit-viewport-prefetch-target",
nodeEnv: "production",
props: { prefetch: true },
windowOverrides: botWindowOverrides,
});
try {
explicitViewportObserver.dispatchIntersectingEntry(viewportResult.anchor);
await flushPrefetchTasks();
expect(viewportResult.pagePrefetchLinks).toEqual([
{
as: "document",
href: "/pages-bot-explicit-viewport-prefetch-target",
rel: "prefetch",
},
]);
} finally {
viewportResult.restoreNodeEnv();
}
const intentResult = await renderIsolatedLink({
appNavigation: false,
href: "/pages-bot-intent-prefetch-target",
nodeEnv: "production",
props: { prefetch: false },
windowOverrides: botWindowOverrides,
});
try {
intentResult.capturedAnchorProps.onMouseEnter?.({ currentTarget: intentResult.anchor });
await flushPrefetchTasks();
expect(intentResult.pagePrefetchLinks).toEqual([
{
as: "document",
href: "/pages-bot-intent-prefetch-target",
rel: "prefetch",
},
]);
} finally {
intentResult.restoreNodeEnv();
}
});
it("re-prefetches visible links after the prefetch cache is invalidated", async () => {
const observer = stubIntersectionObserver();
const result = await renderIsolatedLink({
href: "/viewport-prefetch-target",
nodeEnv: "production",
});
const { invalidatePrefetchCache } = await import("../packages/vinext/src/shims/navigation.js");
try {
observer.dispatchIntersectingEntry(result.anchor);
await flushPrefetchTasks();
expect(result.fetch).toHaveBeenCalledTimes(1);
invalidatePrefetchCache();
await flushPrefetchTasks();
expect(result.fetch).toHaveBeenCalledTimes(2);
expectCanonicalRscFetchCall(
result.fetch.mock.calls[1],
"/viewport-prefetch-target",
expect.objectContaining({
credentials: "include",
priority: "low",
}),
);
} finally {
result.restoreNodeEnv();
}
});
it("re-prefetches a visible Link when the exact cache entry has gone stale", async () => {
const observer = stubIntersectionObserver();
const result = await renderIsolatedLink({
href: "/viewport-prefetch-target",
nodeEnv: "production",
});
try {
// First visibility → initial prefetch
observer.dispatchIntersectingEntry(result.anchor);
await waitForFetchCalls(result.fetch, 1);
expect(result.fetch).toHaveBeenCalledTimes(1);
// Manually expire the cached entry
const { getPrefetchCache } = await import("../packages/vinext/src/shims/navigation.js");
const cache = getPrefetchCache();
const now = 1_000_000;
for (const [, entry] of cache) {
entry.expiresAt = now - 1;
}
vi.spyOn(Date, "now").mockReturnValue(now);
// Ping visible links again; the stale exact entry should be deleted and re-fetched
pingVisibleLinksFromRuntime();
await waitForFetchCalls(result.fetch, 2);
expect(result.fetch).toHaveBeenCalledTimes(2);
expectCanonicalRscFetchCall(
result.fetch.mock.calls[1],
"/viewport-prefetch-target",
expect.objectContaining({
credentials: "include",
priority: "low",
}),
);
} finally {
result.restoreNodeEnv();
}
});
it("does not re-prefetch a visible full-prefetch Link just because dynamic stale time is zero", async () => {
vi.stubEnv("__NEXT_CLIENT_ROUTER_DYNAMIC_STALETIME", "0");
vi.stubEnv("__NEXT_CLIENT_ROUTER_STATIC_STALETIME", "300");
vi.spyOn(Date, "now").mockReturnValue(1_000_000);
const observer = stubIntersectionObserver();
const result = await renderIsolatedLink({
href: "/viewport-prefetch-target",
nodeEnv: "production",
});
try {
observer.dispatchIntersectingEntry(result.anchor);
await waitForFetchCalls(result.fetch, 1);
expect(result.fetch).toHaveBeenCalledTimes(1);
vi.spyOn(Date, "now").mockReturnValue(1_000_001);
pingVisibleLinksFromRuntime();
await flushPrefetchTasks();
expect(result.fetch).toHaveBeenCalledTimes(1);
} finally {
result.restoreNodeEnv();
}
});
it("prefetches visible dynamic links in automatic production mode without seeding navigation cache", async () => {
const observer = stubIntersectionObserver();
const result = await renderIsolatedLink({
href: "/blog/hello",
nodeEnv: "production",
});
try {
expect(observer.observe).toHaveBeenCalledWith(result.anchor);
observer.dispatchIntersectingEntry(result.anchor);
await waitForFetchCalls(result.fetch, 1);
expect(observer.unobserve).not.toHaveBeenCalledWith(result.anchor);
expect(result.fetch).toHaveBeenCalledTimes(1);
expectCanonicalRscFetchCall(
result.fetch.mock.calls[0],
"/blog/hello",
expect.objectContaining({
credentials: "include",
priority: "low",
}),
);
const fetchInit = result.fetch.mock.calls[0]?.[1] as RequestInit | undefined;
expect((fetchInit?.headers as Headers | undefined)?.get(VINEXT_RSC_RENDER_MODE_HEADER)).toBe(
APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
);
expect((fetchInit?.headers as Headers | undefined)?.get(NEXT_ROUTER_PREFETCH_HEADER)).toBe(
"1",
);
expect(
(fetchInit?.headers as Headers | undefined)?.get(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER),
).toBe("1");
const { getPrefetchCache } = await import("../packages/vinext/src/shims/navigation.js");
const entry = Array.from(getPrefetchCache().values())[0];
expect(entry?.cacheForNavigation).toBe(false);
expect(entry?.optimisticRouteShell).toBe(true);
} finally {
result.restoreNodeEnv();
}
});
it("uses the canonical loading-shell variant for an ordinary automatic prefetch", async () => {
const observer = stubIntersectionObserver();
const result = await renderIsolatedLink({
href: "/blog/hello",
nodeEnv: "production",
canonicalRsc: true,
});
try {
observer.dispatchIntersectingEntry(result.anchor);
await waitForFetchCalls(result.fetch, 1);
expect(result.fetch).toHaveBeenCalledTimes(1);
const [input, init] = result.fetch.mock.calls[0]!;
expect(input).toBe("/blog/hello?_rsc=9qLBDIU2NgN178cB");
const headers = new Headers((init as RequestInit).headers);
expect(headers.get("accept")).toBe("text/x-component");
expect(headers.get("rsc")).toBe("1");
expect(headers.get(VINEXT_RSC_RENDER_MODE_HEADER)).toBe(
APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
);
expect(headers.get(NEXT_ROUTER_PREFETCH_HEADER)).toBe("1");
expect(headers.get(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER)).toBe("1");
expect(headers.get("next-router-state-tree")).toBeNull();
expect(headers.get("next-url")).toBeNull();
expect(headers.get("x-vinext-rsc-state-fingerprint")).toBeNull();
} finally {
result.restoreNodeEnv();
}
});
it("uses the canonical basePath root for an explicit full Link prefetch", async () => {
vi.stubEnv("__NEXT_ROUTER_BASEPATH", "/docs");
vi.stubEnv("__VINEXT_TRAILING_SLASH", "false");
const observer = stubIntersectionObserver();
const result = await renderIsolatedLink({
href: "/",
nodeEnv: "production",
canonicalRsc: true,
props: { prefetch: true },
});
try {
observer.dispatchIntersectingEntry(result.anchor);
await waitForFetchCalls(result.fetch, 1);
expect(result.fetch.mock.calls[0]?.[0]).toBe("/docs?_rsc");
} finally {
result.restoreNodeEnv();
}
});
it("preserves contextual Link prefetch when canonical sharing is disabled", async () => {
const observer = stubIntersectionObserver();
const result = await renderIsolatedLink({
href: "/blog/hello",
nodeEnv: "production",
});
try {
observer.dispatchIntersectingEntry(result.anchor);
await waitForFetchCalls(result.fetch, 1);
expect(result.fetch).toHaveBeenCalledTimes(1);
const input = result.fetch.mock.calls[0]?.[0];
expect(typeof input).toBe("string");
if (typeof input === "string") expect(input).toContain("/blog/hello?_rsc=");
} finally {
result.restoreNodeEnv();
}
});
it("prefetches visible links with search params as non-consumable shells", async () => {
const observer = stubIntersectionObserver();
const result = await renderIsolatedLink({
href: "/viewport-prefetch-target?searchParam=a_PPR",
nodeEnv: "production",
});
try {
observer.dispatchIntersectingEntry(result.anchor);
await waitForFetchCalls(result.fetch, 1);
// Ported from Next.js:
// test/e2e/app-dir/segment-cache/search-params/segment-cache-search-params.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/segment-cache/search-params/segment-cache-search-params.test.ts
expectCanonicalRscFetchCall(
result.fetch.mock.calls[0],
"/viewport-prefetch-target",
expect.objectContaining({
credentials: "include",
priority: "low",
}),
);
const fetchedInput = result.fetch.mock.calls[0]?.[0];
expect(typeof fetchedInput).toBe("string");
const fetchedUrl = new URL(fetchedInput as string, "https://example.com");
expect(fetchedUrl.searchParams.get("searchParam")).toBe("a_PPR");
const fetchInit = result.fetch.mock.calls[0]?.[1] as RequestInit | undefined;
expect((fetchInit?.headers as Headers | undefined)?.get(VINEXT_RSC_RENDER_MODE_HEADER)).toBe(
APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_SHELL,
);
expect((fetchInit?.headers as Headers | undefined)?.get(NEXT_ROUTER_PREFETCH_HEADER)).toBe(
"1",
);
const { getPrefetchCache } = await import("../packages/vinext/src/shims/navigation.js");
const entry = Array.from(getPrefetchCache().values())[0];
expect(entry?.cacheForNavigation).toBe(false);
expect(entry?.optimisticRouteShell).toBe(true);
expect(entry?.searchAgnosticShell).toBe(true);
} finally {
result.restoreNodeEnv();
}
});
it("prefetches a loading shell when a search-agnostic shell already covers another query", async () => {
const observer = stubIntersectionObserver();
const result = await renderIsolatedLink({
href: "/viewport-prefetch-target?searchParam=c_PPR",
nodeEnv: "production",
});
try {
const { getPrefetchCache, prefetchRscResponse } =
await import("../packages/vinext/src/shims/navigation.js");
prefetchRscResponse(
"/viewport-prefetch-target?searchParam=a_PPR&_rsc=first",
Promise.resolve(new Response("target-page-with-search-param")),
null,
null,
undefined,
{
cacheForNavigation: false,
optimisticRouteShell: true,
searchAgnosticShell: true,
},
);
await Array.from(getPrefetchCache().values())[0]?.pending;
observer.dispatchIntersectingEntry(result.anchor);
await waitForFetchCalls(result.fetch, 1);
// Ported from Next.js:
// test/e2e/app-dir/segment-cache/search-params/segment-cache-search-params.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/segment-cache/search-params/segment-cache-search-params.test.ts
expectCanonicalRscFetchCall(
result.fetch.mock.calls[0],
"/viewport-prefetch-target",
expect.objectContaining({
credentials: "include",
priority: "low",
}),
);
const fetchedInput = result.fetch.mock.calls[0]?.[0];
expect(typeof fetchedInput).toBe("string");
const fetchedUrl = new URL(fetchedInput as string, "https://example.com");
expect(fetchedUrl.searchParams.get("searchParam")).toBe("c_PPR");
const fetchInit = result.fetch.mock.calls[0]?.[1] as RequestInit | undefined;
expect((fetchInit?.headers as Headers | undefined)?.get(VINEXT_RSC_RENDER_MODE_HEADER)).toBe(
APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
);
const entries = Array.from(getPrefetchCache().values());
expect(entries.some((entry) => entry.searchAgnosticShell === true)).toBe(true);
expect(entries.at(-1)?.searchAgnosticShell).not.toBe(true);
} finally {
result.restoreNodeEnv();
}
});
it("starts App Router viewport prefetches without waiting for browser idle", async () => {
const observer = stubIntersectionObserver();
const requestIdleCallback = vi.fn();
const result = await renderIsolatedLink({
href: "/viewport-prefetch-target",
nodeEnv: "production",
windowOverrides: { requestIdleCallback },
});
try {
observer.dispatchIntersectingEntry(result.anchor);
await waitForFetchCalls(result.fetch, 1);
expect(requestIdleCallback).not.toHaveBeenCalled();
expectCanonicalRscFetchCall(
result.fetch.mock.calls[0],
"/viewport-prefetch-target",
expect.objectContaining({
credentials: "include",
priority: "low",
}),
);
} finally {
result.restoreNodeEnv();
}
});
it("full-prefetches visible dynamic links without a loading shell boundary for client params", async () => {
const observer = stubIntersectionObserver();
const result = await renderIsolatedLink({
href: "/clothing/1",
nodeEnv: "production",
});
try {
expect(observer.observe).toHaveBeenCalledWith(result.anchor);
observer.dispatchIntersectingEntry(result.anchor);
await waitForFetchCalls(result.fetch, 1);
// Ported from Next.js:
// test/e2e/app-dir/segment-cache/client-params/client-params.test.ts
// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/segment-cache/client-params/client-params.test.ts
expectCanonicalRscFetchCall(
result.fetch.mock.calls[0],
"/clothing/1",
expect.objectContaining({
credentials: "include",
priority: "low",
}),
);
const fetchInit = result.fetch.mock.calls[0]?.[1] as RequestInit | undefined;
expect((fetchInit?.headers as Headers | undefined)?.get(VINEXT_RSC_RENDER_MODE_HEADER)).toBe(
null,
);
const { getPrefetchCache } = await import("../packages/vinext/src/shims/navigation.js");
const entry = Array.from(getPrefetchCache().values())[0];
expect(entry?.cacheForNavigation).toBe(true);
expect(entry?.optimisticRouteShell).toBe(false);
} finally {
result.restoreNodeEnv();
}
});
it("keeps Cache Components encoded dynamic paths learning-only across Link re-prefetches", async () => {
// Ported from Next.js:
// test/e2e/app-dir/segment-cache/encoded-slash-params/encoded-slash-params.test.ts
// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/segment-cache/encoded-slash-params/encoded-slash-params.test.ts
vi.stubEnv("__NEXT_CACHE_COMPONENTS", "true");
const observer = stubIntersectionObserver();
const result = await renderIsolatedLink({
href: "/products/foo%2Fbar",
nodeEnv: "production",
});
try {
observer.dispatchIntersectingEntry(result.anchor);
await waitForFetchCalls(result.fetch, 1);
expect(result.fetch).toHaveBeenCalledTimes(1);
expectCanonicalRscFetchCall(
result.fetch.mock.calls[0],
"/products/foo%2Fbar",
expect.objectContaining({
credentials: "include",
priority: "low",
}),
);
const fetchInit = result.fetch.mock.calls[0]?.[1] as RequestInit | undefined;
const headers = fetchInit?.headers as Headers | undefined;
expect(headers?.get(VINEXT_RSC_RENDER_MODE_HEADER)).toBe(
APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
);
expect(headers?.get(NEXT_ROUTER_PREFETCH_HEADER)).toBe("1");
expect(headers?.get(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER)).toBe("1");
const { getPrefetchCache } = await import("../packages/vinext/src/shims/navigation.js");
const entry = Array.from(getPrefetchCache().values())[0];
expect(entry?.cacheForNavigation).toBe(false);
expect(entry?.optimisticRouteShell).toBe(true);
// A Link remount after back navigation asks to prefetch the same href
// again. Preserve the encoded pathname identity and reuse the existing
// learning-only route entry instead of issuing another route-tree fetch.
observer.dispatchIntersectingEntry(result.anchor);
await flushPrefetchTasks();
expect(result.fetch).toHaveBeenCalledTimes(1);
await entry?.pending;
expect(entry?.expiresAt).toEqual(expect.any(Number));
observer.dispatchIntersectingEntry(result.anchor);
await flushPrefetchTasks();
expect(result.fetch).toHaveBeenCalledTimes(1);
vi.spyOn(Date, "now").mockReturnValue((entry?.expiresAt ?? 0) + 1);
observer.dispatchIntersectingEntry(result.anchor);
await waitForFetchCalls(result.fetch, 2);
expect(result.fetch).toHaveBeenCalledTimes(2);
} finally {
result.restoreNodeEnv();
}
});
it("uses the static stale time for reusable automatic dynamic full prefetches", async () => {
vi.stubEnv("__NEXT_CLIENT_ROUTER_DYNAMIC_STALETIME", "0");
vi.stubEnv("__NEXT_CLIENT_ROUTER_STATIC_STALETIME", "300");
vi.spyOn(Date, "now").mockReturnValue(1_000_000);
const observer = stubIntersectionObserver();
const result = await renderIsolatedLink({
href: "/products/1",
nodeEnv: "production",
});
try {
observer.dispatchIntersectingEntry(result.anchor);
await waitForFetchCalls(result.fetch, 1);
const { getPrefetchCache } = await import("../packages/vinext/src/shims/navigation.js");
const entry = Array.from(getPrefetchCache().values())[0];
await entry?.pending;
expect(entry?.cacheForNavigation).toBe(true);
expect(entry?.expiresAt).toBe(1_300_000);
} finally {
result.restoreNodeEnv();
}
});
it("honors a zero response stale time for automatic dynamic full prefetches", async () => {
vi.stubEnv("__NEXT_CLIENT_ROUTER_DYNAMIC_STALETIME", "0");
vi.stubEnv("__NEXT_CLIENT_ROUTER_STATIC_STALETIME", "300");
vi.spyOn(Date, "now").mockReturnValue(1_000_000);
const observer = stubIntersectionObserver();
const result = await renderIsolatedLink({
href: "/products/1",
nodeEnv: "production",
});
result.fetch.mockResolvedValueOnce(
new Response("", { headers: { [VINEXT_DYNAMIC_STALE_TIME_HEADER]: "0" } }),
);
try {
observer.dispatchIntersectingEntry(result.anchor);
await waitForFetchCalls(result.fetch, 1);
const { getPrefetchCache } = await import("../packages/vinext/src/shims/navigation.js");
const entry = Array.from(getPrefetchCache().values())[0];
await entry?.pending;
expect(entry?.cacheForNavigation).toBe(true);
expect(entry?.expiresAt).toBe(1_000_000);
} finally {
result.restoreNodeEnv();
}
});
it("full-prefetches visible dynamic links when prefetch is explicitly true", async () => {
const observer = stubIntersectionObserver();
const result = await renderIsolatedLink({
href: "/blog/hello",
nodeEnv: "production",
props: { prefetch: true },
});
try {
expect(observer.observe).toHaveBeenCalledWith(result.anchor);
observer.dispatchIntersectingEntry(result.anchor);
await waitForFetchCalls(result.fetch, 1);
expect(observer.unobserve).not.toHaveBeenCalledWith(result.anchor);
expectCanonicalRscFetchCall(
result.fetch.mock.calls[0],
"/blog/hello",
expect.objectContaining({
credentials: "include",
priority: "low",
}),
);
const fetchInit = result.fetch.mock.calls[0]?.[1] as RequestInit | undefined;
expect(
(fetchInit?.headers as Headers | undefined)?.get(VINEXT_RSC_RENDER_MODE_HEADER),
).toBeNull();
const shellFetchCall = await waitForFetchCall(result.fetch, (call) => {
const init = call[1] as RequestInit | undefined;
return (
(init?.headers as Headers | undefined)?.get?.(VINEXT_RSC_RENDER_MODE_HEADER) ===
APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL
);
});
const shellFetchInit = shellFetchCall?.[1] as RequestInit | undefined;
expect(
(shellFetchInit?.headers as Headers | undefined)?.get(VINEXT_RSC_RENDER_MODE_HEADER),
).toBe(APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL);
} finally {
result.restoreNodeEnv();
}
});
it("uses a loading shell before full-prefetching explicit links with search params", async () => {
const observer = stubIntersectionObserver();
const result = await renderIsolatedLink({
href: "/blog/hello?searchParam=b_full",
nodeEnv: "production",
props: { prefetch: true },
});
try {
observer.dispatchIntersectingEntry(result.anchor);
await waitForFetchCalls(result.fetch, 2);
expectCanonicalRscFetchCall(
result.fetch.mock.calls[0],
"/blog/hello",
expect.objectContaining({
credentials: "include",
priority: "low",
}),
);
const shellUrl = new URL(result.fetch.mock.calls[0]?.[0] as string, "https://example.com");
expect(shellUrl.searchParams.get("searchParam")).toBe("b_full");
const shellFetchInit = result.fetch.mock.calls[0]?.[1] as RequestInit | undefined;
expect(
(shellFetchInit?.headers as Headers | undefined)?.get(VINEXT_RSC_RENDER_MODE_HEADER),
).toBe(APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL);
expectCanonicalRscFetchCall(
result.fetch.mock.calls[1],
"/blog/hello",
expect.objectContaining({
credentials: "include",
priority: "low",
}),
);
const fullUrl = new URL(result.fetch.mock.calls[1]?.[0] as string, "https://example.com");
expect(fullUrl.searchParams.get("searchParam")).toBe("b_full");
const fullFetchInit = result.fetch.mock.calls[1]?.[1] as RequestInit | undefined;
expect(
(fullFetchInit?.headers as Headers | undefined)?.get(VINEXT_RSC_RENDER_MODE_HEADER),
).toBeNull();
} finally {
result.restoreNodeEnv();
}
});
it("does not request a loading shell before an explicit full prefetch", async () => {
vi.stubEnv("__VINEXT_PREFETCH_INLINING", "true");
const observer = stubIntersectionObserver();
const result = await renderIsolatedLink({
href: "/blog/hello",
nodeEnv: "production",
props: { prefetch: true },
});
try {
observer.dispatchIntersectingEntry(result.anchor);
await waitForFetchCalls(result.fetch, 1);
await flushPrefetchTasks();
// Ported from Next.js:
// test/e2e/app-dir/instant-navigation-testing-api/instant-navigation-testing-api.test.ts
// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/instant-navigation-testing-api/instant-navigation-testing-api.test.ts
expect(result.fetch).toHaveBeenCalledTimes(1);
const fetchInit = result.fetch.mock.calls[0]?.[1] as RequestInit | undefined;
expect((fetchInit?.headers as Headers | undefined)?.get("next-router-prefetch")).toBeNull();
expect(
(fetchInit?.headers as Headers | undefined)?.get("next-router-state-tree"),
).toBeTruthy();
expect(
(fetchInit?.headers as Headers | undefined)?.get(VINEXT_RSC_RENDER_MODE_HEADER),
).toBeNull();
} finally {
result.restoreNodeEnv();
}
});
it("refetches instead of promoting an exact learning-only entry for a full Link prefetch", async () => {
const observer = stubIntersectionObserver();
const result = await renderIsolatedLink({
href: "/blog/hello",
nodeEnv: "production",
props: { prefetch: true },
});
const navigation = await import("../packages/vinext/src/shims/navigation.js");
let resolveLearningPrefetch!: (response: Response) => void;
result.fetch.mockImplementationOnce(
() =>
new Promise<Response>((resolve) => {
resolveLearningPrefetch = resolve;
}),
);
try {
navigation.appRouterInstance.prefetch("/blog/hello");
await waitForFetchCalls(result.fetch, 1);
const partialEntry = Array.from(navigation.getPrefetchCache().values())[0];
expect(partialEntry?.outcome).toBe("pending");
expect(partialEntry?.cacheForNavigation).toBe(false);
expect(partialEntry?.optimisticRouteShell).toBe(true);
observer.dispatchIntersectingEntry(result.anchor);
await waitForFetchCalls(result.fetch, 2);
await flushPrefetchTasks();
// Next.js refetches partial segments when a stronger Full strategy can
// provide more content instead of promoting the weaker cache entry:
// packages/next/src/client/components/segment-cache/scheduler.ts
expect(result.fetch).toHaveBeenCalledTimes(3);
const fullEntry = Array.from(navigation.getPrefetchCache().values()).find(
(entry) => entry.cacheForNavigation === true,
);
expect(fullEntry).not.toBe(partialEntry);
expect(fullEntry?.cacheForNavigation).toBe(true);
expect(fullEntry?.optimisticRouteShell).toBe(false);
resolveLearningPrefetch(new Response("", { status: 500 }));
await flushPrefetchTasks();
expect(Array.from(navigation.getPrefetchCache().values())).toContain(fullEntry);
} finally {
result.restoreNodeEnv();
}
});
it("does not prefetch visible links in development", async () => {
// Next.js disables App Router viewport prefetching in development:
// https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/links.ts
const observe = vi.fn();
const unobserve = vi.fn();
class FakeIntersectionObserver {
observe = observe;
unobserve = unobserve;
}
vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver);
const result = await renderIsolatedLink({
href: "/dev-prefetch-target",
nodeEnv: "development",
});
try {
expect(observe).not.toHaveBeenCalled();
expect(result.fetch).not.toHaveBeenCalled();
} finally {
result.restoreNodeEnv();
}
});
it("does not prefetch on mouse intent in development while preserving the user handler", async () => {
const userOnMouseEnter = vi.fn();
const result = await renderIsolatedLink({
href: "/dev-mouse-intent-prefetch-target",
nodeEnv: "development",
props: { onMouseEnter: userOnMouseEnter },
});
try {
result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor });
await flushPrefetchTasks();
expect(userOnMouseEnter).toHaveBeenCalledTimes(1);
expect(result.fetch).not.toHaveBeenCalled();
} finally {
result.restoreNodeEnv();
}
});
it("does not prefetch on touch intent in development while preserving the user handler", async () => {
const userOnTouchStart = vi.fn();
const result = await renderIsolatedLink({
href: "/dev-touch-intent-prefetch-target",
nodeEnv: "development",
props: { onTouchStart: userOnTouchStart },
});
try {
result.capturedAnchorProps.onTouchStart?.({ currentTarget: result.anchor });
await flushPrefetchTasks();
expect(userOnTouchStart).toHaveBeenCalledTimes(1);
expect(result.fetch).not.toHaveBeenCalled();
} finally {
result.restoreNodeEnv();
}
});
it("prefetches on mouse intent in production while preserving the user handler", async () => {
// Next.js triggers intent prefetch from Link onMouseEnter:
// https://github.com/vercel/next.js/blob/canary/packages/next/src/client/app-dir/link.tsx
const userOnMouseEnter = vi.fn();
const result = await renderIsolatedLink({
href: "/intent-prefetch-target",
nodeEnv: "production",
props: { onMouseEnter: userOnMouseEnter },
});
try {
expect(result.capturedAnchorProps.onMouseEnter).toBeTypeOf("function");
result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor });
await flushPrefetchTasks();
expect(userOnMouseEnter).toHaveBeenCalledTimes(1);
expectCanonicalRscFetchCall(
result.fetch.mock.calls[0],
"/intent-prefetch-target",
expect.objectContaining({
credentials: "include",
priority: "high",
}),
);
} finally {
result.restoreNodeEnv();
}
});
it("checks high-priority intent prefetch before queued click navigation can consume the cache", async () => {
const idleCallbacks: Array<() => void> = [];
const requestIdleCallback = vi.fn((callback: () => void) => {
idleCallbacks.push(callback);
return idleCallbacks.length;
});
const result = await renderIsolatedLink({
href: "/intent-prefetch-target",
nodeEnv: "production",
windowOverrides: { requestIdleCallback },
});
const { createRscRequestHeaders, createRscRequestUrl } =
await import("../packages/vinext/src/server/app-rsc-cache-busting.js");
const { consumePrefetchResponse, getPrefetchCache, getPrefetchedUrls } =
await import("../packages/vinext/src/shims/navigation.js");
const rscUrl = await createRscRequestUrl("/intent-prefetch-target", createRscRequestHeaders());
const snapshot = {
buffer: new TextEncoder().encode("flight").buffer,
contentType: "text/x-component",
mountedSlotsHeader: null,
paramsHeader: null,
renderedPathAndSearch: null,
url: rscUrl,
};
try {
getPrefetchCache().set(rscUrl, {
cacheForNavigation: true,
outcome: "cache-seeded",
snapshot,
timestamp: Date.now(),
});
getPrefetchedUrls().add(rscUrl);
result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor });
await flushPrefetchTasks();
expect(requestIdleCallback).not.toHaveBeenCalled();
expect(idleCallbacks).toEqual([]);
expect(result.fetch).not.toHaveBeenCalled();
// Simulate the click navigation consuming the existing viewport prefetch.
expect(consumePrefetchResponse(rscUrl, null, null)).toEqual(snapshot);
for (const callback of idleCallbacks) {
callback();
}
await flushPrefetchTasks();
expect(result.fetch).not.toHaveBeenCalled();
} finally {
result.restoreNodeEnv();
}
});
it("does not reprefetch a visible link after navigation makes it the current URL", async () => {
const observer = stubIntersectionObserver();
const result = await renderIsolatedLink({
href: "/intent-prefetch-target",
nodeEnv: "production",
});
const { getPrefetchCache, getPrefetchedUrls } =
await import("../packages/vinext/src/shims/navigation.js");
try {
observer.dispatchIntersectingEntry(result.anchor);
await waitForFetchCalls(result.fetch, 1);
getPrefetchCache().clear();
getPrefetchedUrls().clear();
result.fetch.mockClear();
window.location.href = "https://example.com/intent-prefetch-target";
window.location.pathname = "/intent-prefetch-target";
window.location.search = "";
pingVisibleLinksFromRuntime();
await flushPrefetchTasks();
expect(result.fetch).not.toHaveBeenCalled();
} finally {
result.restoreNodeEnv();
}
});
it("awaits an automatic loading-shell prefetch before upgrading to a full payload", async () => {
const observer = stubIntersectionObserver();
let resolveShell: ((response: Response) => void) | undefined;
let releaseShellBody: (() => void) | undefined;
const shellPromise = new Promise<Response>((resolve) => {
resolveShell = resolve;
});
const shellBody = new ReadableStream<Uint8Array>({
start(controller) {
releaseShellBody = () => {
controller.close();
};
},
});
const result = await renderIsolatedLink({
href: "/blog/hello",
nodeEnv: "production",
props: { unstable_dynamicOnHover: true },
});
const { getPrefetchCache } = await import("../packages/vinext/src/shims/navigation.js");
result.fetch
.mockImplementationOnce(() => shellPromise)
.mockImplementationOnce(() => Promise.resolve(new Response("full")));
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 prefetch request headers");
}
expect(firstInit.headers.get(VINEXT_RSC_RENDER_MODE_HEADER)).toBe(
APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
);
result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor });
await flushPrefetchTasks();
expect(result.fetch).toHaveBeenCalledTimes(1);
if (resolveShell === undefined) {
throw new Error("Expected shell prefetch resolver");
}
resolveShell(new Response(shellBody));
await flushPrefetchTasks();
expect(result.fetch).toHaveBeenCalledTimes(1);
if (releaseShellBody === undefined) {
throw new Error("Expected shell body release");
}
releaseShellBody();
await waitForFetchCalls(result.fetch, 2);
await flushPrefetchTasks();
const secondInit = result.fetch.mock.calls[1]?.[1];
expect(secondInit?.headers).toBeInstanceOf(Headers);
if (!(secondInit?.headers instanceof Headers)) {
throw new Error("Expected full prefetch request headers");
}
expect(secondInit.headers.get(VINEXT_RSC_RENDER_MODE_HEADER)).toBeNull();
expect(
[...getPrefetchCache().values()].some(
(entry) => entry.cacheForNavigation === false && entry.optimisticRouteShell === true,
),
).toBe(true);
await Promise.all(
[...getPrefetchCache().values()].flatMap((entry) =>
entry.pending === undefined ? [] : [entry.pending.catch(() => {})],
),
);
} finally {
result.restoreNodeEnv();
}
});
it("uses a shell-only automatic prefetch for dynamic routes requiring fresh navigation", async () => {
vi.stubEnv("__VINEXT_PREFETCH_INLINING", "true");
const observer = stubIntersectionObserver();
const result = await renderIsolatedLink({
href: "/teams/vercel/dashboard",
nodeEnv: "production",
});
try {
observer.dispatchIntersectingEntry(result.anchor);
await waitForFetchCalls(result.fetch, 1);
await flushPrefetchTasks();
expect(result.fetch).toHaveBeenCalledTimes(1);
expectCanonicalRscFetchCall(
result.fetch.mock.calls[0],
"/teams/vercel/dashboard",
expect.objectContaining({
credentials: "include",
priority: "low",
}),
);
const fetchInit = result.fetch.mock.calls[0]?.[1] as RequestInit | undefined;
expect((fetchInit?.headers as Headers | undefined)?.get(VINEXT_RSC_RENDER_MODE_HEADER)).toBe(
APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
);
} finally {
result.restoreNodeEnv();
}
});
it("automatically prefetches intercepted loading shells with their source context", async () => {
const observer = stubIntersectionObserver();
const interception = {
id: "interception:slot:modal:/slow-intercept->/slow-intercept/photo",
interceptingRouteId: "route:/slow-intercept",
ownerLayoutId: "layout:/slow-intercept",
slotId: "slot:modal:/slow-intercept",
sourcePattern: "/slow-intercept",
sourcePatternParts: ["slow-intercept"],
targetPattern: "/slow-intercept/photo",
targetPatternParts: ["slow-intercept", "photo"],
targetRouteId: "route:/slow-intercept/photo",
} as const;
const routeManifest: RouteManifest = {
graphVersion: "test",
segmentGraph: {
boundaries: new Map(),
defaults: new Map(),
interceptions: new Map([[interception.id, interception]]),
interceptionsBySlotId: new Map(),
layouts: new Map(),
pages: new Map(),
rootBoundaries: new Map(),
routeHandlers: new Map(),
routes: new Map(),
slotBindings: new Map(),
slots: new Map(),
templates: new Map(),
},
};
const result = await renderIsolatedLink({
canonicalRsc: true,
href: "/slow-intercept/photo",
nodeEnv: "production",
routeManifest,
windowOverrides: {
location: {
href: "https://example.com/slow-intercept",
origin: "https://example.com",
pathname: "/slow-intercept",
search: "",
},
},
});
try {
observer.dispatchIntersectingEntry(result.anchor);
await waitForFetchCalls(result.fetch, 1);
expectCanonicalRscFetchCall(
result.fetch.mock.calls[0],
"/slow-intercept/photo",
expect.objectContaining({ credentials: "include", priority: "low" }),
);
const fetchInit = result.fetch.mock.calls[0]?.[1] as RequestInit | undefined;
const headers = fetchInit?.headers as Headers | undefined;
const input = result.fetch.mock.calls[0]?.[0];
expect(typeof input).toBe("string");
if (typeof input === "string") {
expect(new URL(input, "https://example.com").searchParams.get("_rsc")).not.toBe("");
}
expect(headers?.get(VINEXT_RSC_RENDER_MODE_HEADER)).toBe(
APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
);
expect(headers?.get(VINEXT_INTERCEPTION_CONTEXT_HEADER)).toBe("/slow-intercept");
} finally {
result.restoreNodeEnv();
}
});
it("gates prefetchInlining full payloads behind a deduped route-tree request", async () => {
// Ported from Next.js:
// test/e2e/app-dir/segment-cache/max-prefetch-inlining/max-prefetch-inlining.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/segment-cache/max-prefetch-inlining/max-prefetch-inlining.test.ts
vi.stubEnv("__VINEXT_PREFETCH_INLINING", "true");
const observer = stubIntersectionObserver();
const result = await renderIsolatedLink({
href: "/viewport-prefetch-target",
nodeEnv: "production",
});
try {
let releaseRouteTree: ((response: Response) => void) | undefined;
const routeTreeResponse = new Promise<Response>((resolve) => {
releaseRouteTree = resolve;
});
result.fetch
.mockImplementationOnce(() => routeTreeResponse)
.mockImplementation(() => Promise.resolve(new Response("")));
observer.dispatchIntersectingEntry(result.anchor);
await waitForFetchCalls(result.fetch, 1);
const routeTreeFetchInit = result.fetch.mock.calls[0]?.[1] as RequestInit | undefined;
const routeTreeHeaders = routeTreeFetchInit?.headers as Headers | undefined;
expect(routeTreeHeaders?.get(VINEXT_RSC_RENDER_MODE_HEADER)).toBeNull();
expect(routeTreeHeaders?.get(NEXT_ROUTER_PREFETCH_HEADER)).toBe("1");
expect(routeTreeHeaders?.get(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER)).toBe("/_tree");
const { getPrefetchCache } = await import("../packages/vinext/src/shims/navigation.js");
const routeTreeEntry = Array.from(getPrefetchCache().values()).find(
(entry) => entry.prefetchKind === "route-tree",
);
expect(routeTreeEntry?.cacheForNavigation).toBe(false);
expect(routeTreeEntry?.optimisticRouteShell).toBe(false);
observer.dispatchIntersectingEntry(result.anchor);
await flushPrefetchTasks();
expect(result.fetch).toHaveBeenCalledTimes(1);
releaseRouteTree?.(new Response(""));
await waitForFetchCalls(result.fetch, 2);
expect(result.fetch).toHaveBeenCalledTimes(2);
const fullFetchInit = result.fetch.mock.calls[1]?.[1] as RequestInit | undefined;
const fullHeaders = fullFetchInit?.headers as Headers | undefined;
expect(fullHeaders?.get(VINEXT_RSC_RENDER_MODE_HEADER)).toBeNull();
expect(fullHeaders?.get(NEXT_ROUTER_PREFETCH_HEADER)).toBe("1");
expect(fullHeaders?.get(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER)).toBe("/__PAGE__");
} finally {
await flushPrefetchTasks();
result.restoreNodeEnv();
}
});
it("prefetches root-param routes through a concrete route tree before the page segment", async () => {
// Ported from Next.js:
// test/e2e/app-dir/segment-cache/vary-params/root-params-segment-prefetch.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/segment-cache/vary-params/root-params-segment-prefetch.test.ts
vi.stubEnv("__NEXT_CACHE_COMPONENTS", "true");
const observer = stubIntersectionObserver();
const result = await renderIsolatedLink({
href: "/root-param/aaa",
nodeEnv: "production",
windowOverrides: {
__VINEXT_LINK_PREFETCH_ROUTES__: [
{
canPrefetchLoadingShell: true,
patternParts: ["root-param", ":value"],
isDynamic: true,
hasRootParams: true,
},
],
},
});
try {
let releaseRouteTree: ((response: Response) => void) | undefined;
const routeTreeResponse = new Promise<Response>((resolve) => {
releaseRouteTree = resolve;
});
result.fetch
.mockImplementationOnce(() => routeTreeResponse)
.mockImplementation(() => Promise.resolve(new Response("concrete root-param page")));
observer.dispatchIntersectingEntry(result.anchor);
await waitForFetchCalls(result.fetch, 1);
const routeTreeInit = result.fetch.mock.calls[0]?.[1] as RequestInit | undefined;
const routeTreeHeaders = routeTreeInit?.headers as Headers | undefined;
expect(routeTreeHeaders?.get(NEXT_ROUTER_PREFETCH_HEADER)).toBe("1");
expect(routeTreeHeaders?.get(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER)).toBe("/_tree");
releaseRouteTree?.(new Response(""));
await waitForFetchCalls(result.fetch, 2);
expectCanonicalRscFetchCall(
result.fetch.mock.calls[1],
"/root-param/aaa",
expect.objectContaining({ credentials: "include", priority: "low" }),
);
const pageInit = result.fetch.mock.calls[1]?.[1] as RequestInit | undefined;
const pageHeaders = pageInit?.headers as Headers | undefined;
expect(pageHeaders?.get(NEXT_ROUTER_PREFETCH_HEADER)).toBe("1");
expect(pageHeaders?.get(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER)).toBe("/__PAGE__");
const pageRequestUrl = result.fetch.mock.calls[1]?.[0];
expect(typeof pageRequestUrl).toBe("string");
expect(pageRequestUrl as string).not.toContain("%5Bvalue%5D");
const { getPrefetchCache } = await import("../packages/vinext/src/shims/navigation.js");
const navigationEntry = Array.from(getPrefetchCache().values()).find(
(entry) => entry.prefetchKind === "navigation",
);
expect(navigationEntry?.cacheForNavigation).toBe(true);
} finally {
await flushPrefetchTasks();
result.restoreNodeEnv();
}
});
it("upgrades automatic dynamic links to full prefetch on unstable_dynamicOnHover intent", async () => {
const observer = stubIntersectionObserver();
const result = await renderIsolatedLink({
href: "/blog/hello",
nodeEnv: "production",
props: { unstable_dynamicOnHover: true },
});
const { invalidatePrefetchCache } = await import("../packages/vinext/src/shims/navigation.js");
try {
observer.dispatchIntersectingEntry(result.anchor);
await waitForFetchCalls(result.fetch, 1);
expectCanonicalRscFetchCall(
result.fetch.mock.calls[0],
"/blog/hello",
expect.objectContaining({
credentials: "include",
priority: "low",
}),
);
result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor });
await waitForFetchCalls(result.fetch, 2);
expect(result.fetch).toHaveBeenCalledTimes(2);
const hoverFetchInit = result.fetch.mock.calls[1]?.[1] as RequestInit | undefined;
expect(
(hoverFetchInit?.headers as Headers | undefined)?.get(VINEXT_RSC_RENDER_MODE_HEADER),
).toBeNull();
const { getPrefetchCache } = await import("../packages/vinext/src/shims/navigation.js");
const entries = Array.from(getPrefetchCache().values());
expect(entries).toEqual(
expect.arrayContaining([
expect.objectContaining({ cacheForNavigation: true, optimisticRouteShell: false }),
expect.objectContaining({ cacheForNavigation: false, prefetchKind: "loading-shell" }),
]),
);
invalidatePrefetchCache();
await waitForFetchCalls(result.fetch, 4);
await flushPrefetchTasks();
expect(result.fetch).toHaveBeenCalledTimes(4);
expectCanonicalRscFetchCall(
result.fetch.mock.calls[3],
"/blog/hello",
expect.objectContaining({
credentials: "include",
priority: "low",
}),
);
} finally {
result.restoreNodeEnv();
}
});
it("prefetches on touch intent in production while preserving the user handler", async () => {
rscCacheBustingDigestDelayMs = 25;
const userOnTouchStart = vi.fn();
const result = await renderIsolatedLink({
href: "/touch-prefetch-target",
nodeEnv: "production",
props: { onTouchStart: userOnTouchStart },
});
try {
expect(result.capturedAnchorProps.onTouchStart).toBeTypeOf("function");
result.capturedAnchorProps.onTouchStart?.({ currentTarget: result.anchor });
await waitForFetchCalls(result.fetch, 1);
expect(userOnTouchStart).toHaveBeenCalledTimes(1);
expectCanonicalRscFetchCall(
result.fetch.mock.calls[0],
"/touch-prefetch-target",
expect.objectContaining({
credentials: "include",
priority: "high",
}),
);
} finally {
result.restoreNodeEnv();
}
});
it("does not prefetch external absolute URLs on production intent", async () => {
const userOnMouseEnter = vi.fn();
const result = await renderIsolatedLink({
href: "https://external.example/prefetch-target",
nodeEnv: "production",
props: { onMouseEnter: userOnMouseEnter },
});
try {
result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor });
await flushPrefetchTasks();
expect(userOnMouseEnter).toHaveBeenCalledTimes(1);
expect(result.fetch).not.toHaveBeenCalled();
} finally {
result.restoreNodeEnv();
}
});
it("does not prefetch native URI schemes on production intent", async () => {
const result = await renderIsolatedLink({
href: "mailto:hello@example.com",
nodeEnv: "production",
});
try {
result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor });
await flushPrefetchTasks();
expect(result.fetch).not.toHaveBeenCalled();
} finally {
result.restoreNodeEnv();
}
});
it("normalizes same-origin absolute URLs before production intent prefetch", async () => {
const result = await renderIsolatedLink({
href: "https://example.com/same-origin-intent-prefetch-target",
nodeEnv: "production",
});
try {
result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor });
const rscCall = await waitForFetchCall(result.fetch, (call) => {
const input = call[0];
if (typeof input !== "string") return false;
return (
new URL(input, "https://example.com").pathname === "/same-origin-intent-prefetch-target"
);
});
expectCanonicalRscFetchCall(
rscCall,
"/same-origin-intent-prefetch-target",
expect.objectContaining({
credentials: "include",
priority: "high",
}),
);
expect(
result.fetch.mock.calls.some((call) => {
const input = call[0];
return (
typeof input === "string" &&
input.startsWith("https://example.com/same-origin-intent-prefetch-target")
);
}),
).toBe(false);
} finally {
result.restoreNodeEnv();
}
});
it("does not prefetch external protocol-relative URLs on production intent", async () => {
const result = await renderIsolatedLink({
href: "//external.example/protocol-relative-prefetch-target",
nodeEnv: "production",
});
try {
result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor });
await flushPrefetchTasks();
expect(result.fetch).not.toHaveBeenCalled();
} finally {
result.restoreNodeEnv();
}
});
it("does not App Router prefetch on intent when prefetch is false", async () => {
const userOnMouseEnter = vi.fn();
const result = await renderIsolatedLink({
href: "/disabled-intent-prefetch-target",
nodeEnv: "production",
props: { onMouseEnter: userOnMouseEnter, prefetch: false },
});
try {
result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor });
await flushPrefetchTasks();
expect(userOnMouseEnter).toHaveBeenCalledTimes(1);
expect(result.fetch).not.toHaveBeenCalled();
} finally {
result.restoreNodeEnv();
}
});
it("prefetches Pages Router links on mouse intent when prefetch is false", async () => {
const userOnMouseEnter = vi.fn();
const result = await renderIsolatedLink({
appNavigation: false,
href: "/pages-disabled-mouse-intent-prefetch-target",
nodeEnv: "production",
props: { onMouseEnter: userOnMouseEnter, prefetch: false },
windowOverrides: {
__NEXT_DATA__: {
__vinext: {
pageModuleUrl: "/_next/static/chunks/pages/current.js",
},
},
},
});
try {
result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor });
await flushPrefetchTasks();
expect(userOnMouseEnter).toHaveBeenCalledTimes(1);
expect(result.fetch).not.toHaveBeenCalled();
expect(result.pagePrefetchLinks).toEqual([
{
as: "document",
href: "/pages-disabled-mouse-intent-prefetch-target",
rel: "prefetch",
},
]);
} finally {
result.restoreNodeEnv();
}
});
it("prefetches Pages Router links on touch intent when prefetch is false", async () => {
const userOnTouchStart = vi.fn();
const result = await renderIsolatedLink({
appNavigation: false,
href: "/pages-disabled-touch-intent-prefetch-target",
nodeEnv: "production",
props: { onTouchStart: userOnTouchStart, prefetch: false },
windowOverrides: {
__NEXT_DATA__: {
__vinext: {
pageModuleUrl: "/_next/static/chunks/pages/current.js",
},
},
},
});
try {
result.capturedAnchorProps.onTouchStart?.({ currentTarget: result.anchor });
await flushPrefetchTasks();
expect(userOnTouchStart).toHaveBeenCalledTimes(1);
expect(result.fetch).not.toHaveBeenCalled();
expect(result.pagePrefetchLinks).toEqual([
{
as: "document",
href: "/pages-disabled-touch-intent-prefetch-target",
rel: "prefetch",
},
]);
} finally {
result.restoreNodeEnv();
}
});
it("cancels a queued Pages Router intent prefetch when the link is clicked", async () => {
let scheduledFrame: (() => void) | null = null;
const result = await renderIsolatedLink({
appNavigation: false,
href: "/pages-click-intent-prefetch-target",
nodeEnv: "production",
props: {
onClick: (event: CapturedClickEvent) => event.preventDefault(),
},
windowOverrides: {
__NEXT_DATA__: {
__vinext: {
pageModuleUrl: "/_next/static/chunks/pages/current.js",
},
},
requestAnimationFrame: vi.fn((callback: () => void) => {
scheduledFrame = callback;
return 1;
}),
cancelAnimationFrame: vi.fn(() => {
scheduledFrame = null;
}),
},
});
try {
result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor });
expect(scheduledFrame).not.toBeNull();
const clickEvent = {
button: 0,
currentTarget: { hasAttribute: () => false, target: "" },
defaultPrevented: false,
preventDefault() {
this.defaultPrevented = true;
},
} satisfies CapturedClickEvent;
await result.capturedAnchorProps.onClick?.(clickEvent);
await flushPrefetchTasks();
expect(clickEvent.defaultPrevented).toBe(true);
expect(scheduledFrame).toBeNull();
expect(result.fetch).not.toHaveBeenCalled();
expect(result.pagePrefetchLinks).toEqual([]);
} finally {
result.restoreNodeEnv();
}
});
it("does not prefetch shallow Pages Router links on intent", async () => {
const result = await renderIsolatedLink({
appNavigation: false,
href: "/pages-shallow-intent-prefetch-target?hello=world",
nodeEnv: "production",
props: { shallow: true },
windowOverrides: {
__NEXT_DATA__: {
__vinext: {
pageModuleUrl: "/_next/static/chunks/pages/current.js",
},
},
},
});
try {
result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor });
result.capturedAnchorProps.onTouchStart?.({ currentTarget: result.anchor });
await flushPrefetchTasks();
expect(result.fetch).not.toHaveBeenCalled();
expect(result.pagePrefetchLinks).toEqual([]);
} finally {
result.restoreNodeEnv();
}
});
it("keeps registered non-SSG Pages Router Link prefetches chunk-only", async () => {
const observer = stubIntersectionObserver();
const aboutLoader = vi.fn(async () => ({ default: null }));
const pagesWindowOverrides = {
__NEXT_DATA__: {
buildId: "build-id",
__vinext: {
pageModuleUrl: "/_next/static/chunks/pages/current.js",
},
},
__VINEXT_PAGE_LOADERS__: {
"/about": aboutLoader,
},
__VINEXT_PAGE_PATTERNS__: ["/about"],
__VINEXT_PAGES_SSG_PATTERNS__: [],
__VINEXT_PAGES_SSP_PATTERNS__: [],
};
const result = await renderIsolatedLink({
appNavigation: false,
href: "/about",
nodeEnv: "production",
windowOverrides: pagesWindowOverrides,
});
try {
observer.dispatchIntersectingEntry(result.anchor, true);
await flushPrefetchTasks();
result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor });
await flushPrefetchTasks();
expect(aboutLoader).toHaveBeenCalled();
expect(result.fetch).not.toHaveBeenCalled();
expect(result.pagePrefetchLinks).toEqual([]);
} finally {
result.restoreNodeEnv();
}
});
it("prefetches implicit dynamic Pages Router links through a concrete route URL", async () => {
const observer = stubIntersectionObserver();
const dynamicLoader = vi.fn(async () => ({ default: null }));
const routePattern = "/link-dynamic/[a]/[b]/c";
const result = await renderIsolatedLink({
appNavigation: false,
href: `${routePattern}?a=a&b=b&q=q`,
nodeEnv: "production",
windowOverrides: {
__NEXT_DATA__: { buildId: "build-id" },
__VINEXT_PAGE_LOADERS__: { [routePattern]: dynamicLoader },
__VINEXT_PAGE_PATTERNS__: [routePattern],
__VINEXT_PAGES_SSG_PATTERNS__: [routePattern],
__VINEXT_PAGES_SSP_PATTERNS__: [],
},
});
try {
observer.dispatchIntersectingEntry(result.anchor, true);
await waitForFetchCalls(result.fetch, 1);
expect(dynamicLoader).toHaveBeenCalled();
expect(result.fetch).toHaveBeenCalledWith(
"/_next/data/build-id/link-dynamic/a/b/c.json?a=a&b=b&q=q",
expect.objectContaining({
headers: expect.objectContaining({
Accept: "application/json",
purpose: "prefetch",
"x-nextjs-data": "1",
}),
}),
);
} finally {
result.restoreNodeEnv();
}
});
it("prefetches masked Pages Router links by route while probing middleware by display URL", async () => {
const observer = stubIntersectionObserver();
const actualLoader = vi.fn(async () => ({ default: null }));
const result = await renderIsolatedLink({
appNavigation: false,
href: "/actual",
nodeEnv: "production",
props: { as: "/masked" },
windowOverrides: {
__NEXT_DATA__: {
buildId: "build-id",
__vinext: {
hasMiddleware: true,
pageModuleUrl: "/_next/static/chunks/pages/current.js",
},
},
__VINEXT_MIDDLEWARE_MATCHER__: ["/masked"],
__VINEXT_PAGE_LOADERS__: {
"/actual": actualLoader,
},
__VINEXT_PAGE_PATTERNS__: ["/actual"],
__VINEXT_PAGES_SSG_PATTERNS__: [],
__VINEXT_PAGES_SSP_PATTERNS__: ["/actual"],
},
});
try {
result.fetch.mockResolvedValue(
new Response("{}", {
headers: { "x-middleware-skip": "1" },
}),
);
observer.dispatchIntersectingEntry(result.anchor, true);
await waitForFetchCalls(result.fetch, 1);
result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor });
await waitForFetchCalls(result.fetch, 2);
expect(actualLoader).toHaveBeenCalled();
expect(result.fetch).toHaveBeenCalledTimes(2);
for (const call of result.fetch.mock.calls) {
expect(call[0]).toBe("/_next/data/build-id/masked.json");
expect(call[1]?.headers).toMatchObject({
Accept: "application/json",
purpose: "prefetch",
"x-middleware-prefetch": "1",
"x-nextjs-data": "1",
});
}
expect(result.pagePrefetchLinks).toEqual([]);
} finally {
result.restoreNodeEnv();
}
});
it("refetches middleware-matched dynamic Pages Router data on hover after viewport prefetch", async () => {
// Ported from Next.js:
// test/e2e/middleware-rewrites/test/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-rewrites/test/index.test.ts
const observer = stubIntersectionObserver();
const dynamicLoader = vi.fn(async () => ({ default: null }));
const result = await renderIsolatedLink({
appNavigation: false,
href: "/dynamic-no-cache/1",
nodeEnv: "production",
windowOverrides: {
__NEXT_DATA__: {
buildId: "build-id",
__vinext: {
hasMiddleware: true,
pageModuleUrl: "/_next/static/chunks/pages/current.js",
},
},
__VINEXT_MIDDLEWARE_MATCHER__: ["/:path*"],
__VINEXT_PAGE_LOADERS__: {
"/dynamic-no-cache/[id]": dynamicLoader,
},
__VINEXT_PAGE_PATTERNS__: ["/dynamic-no-cache/[id]"],
__VINEXT_PAGES_SSG_PATTERNS__: ["/dynamic-no-cache/[id]"],
__VINEXT_PAGES_SSP_PATTERNS__: [],
},
});
try {
result.fetch.mockResolvedValue(
new Response("{}", {
headers: { "x-middleware-cache": "no-cache" },
}),
);
observer.dispatchIntersectingEntry(result.anchor, true);
await waitForFetchCalls(result.fetch, 1);
result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor });
await waitForFetchCalls(result.fetch, 2);
expect(dynamicLoader).toHaveBeenCalled();
expect(result.fetch).toHaveBeenCalledTimes(2);
for (const call of result.fetch.mock.calls) {
expect(call[0]).toBe("/_next/data/build-id/dynamic-no-cache/1.json");
expect(call[1]?.headers).toMatchObject({
Accept: "application/json",
purpose: "prefetch",
"x-middleware-prefetch": "1",
"x-nextjs-data": "1",
});
}
expect(result.pagePrefetchLinks).toEqual([]);
} finally {
result.restoreNodeEnv();
}
});
it("prefetches masked static Pages Router links through the display middleware probe", async () => {
const observer = stubIntersectionObserver();
const actualLoader = vi.fn(async () => ({ default: null }));
const result = await renderIsolatedLink({
appNavigation: false,
href: "/actual",
nodeEnv: "production",
props: { as: "/masked" },
windowOverrides: {
__NEXT_DATA__: {
buildId: "build-id",
__vinext: {
hasMiddleware: true,
pageModuleUrl: "/_next/static/chunks/pages/current.js",
},
},
__VINEXT_MIDDLEWARE_MATCHER__: ["/masked"],
__VINEXT_PAGE_LOADERS__: {
"/actual": actualLoader,
},
__VINEXT_PAGE_PATTERNS__: ["/actual"],
__VINEXT_PAGES_SSG_PATTERNS__: ["/actual"],
__VINEXT_PAGES_SSP_PATTERNS__: [],
},
});
try {
observer.dispatchIntersectingEntry(result.anchor, true);
await waitForFetchCalls(result.fetch, 1);
expect(actualLoader).toHaveBeenCalled();
expect(result.fetch).toHaveBeenCalledTimes(1);
expect(result.fetch.mock.calls[0][0]).toBe("/_next/data/build-id/masked.json");
expect(result.fetch.mock.calls[0][1]?.headers).toMatchObject({
Accept: "application/json",
purpose: "prefetch",
"x-middleware-prefetch": "1",
"x-nextjs-data": "1",
});
expect(result.pagePrefetchLinks).toEqual([]);
} finally {
result.restoreNodeEnv();
}
});
it("does not duplicate Pages Router viewport prefetch after visibility changes", async () => {
const observer = stubIntersectionObserver();
const result = await renderIsolatedLink({
appNavigation: false,
href: "/pages-viewport-prefetch-target",
nodeEnv: "production",
windowOverrides: {
__NEXT_DATA__: {
__vinext: {
pageModuleUrl: "/_next/static/chunks/pages/current.js",
},
},
},
});
try {
observer.dispatchIntersectingEntry(result.anchor, true);
await flushPrefetchTasks();
observer.dispatchIntersectingEntry(result.anchor, false);
await flushPrefetchTasks();
observer.dispatchIntersectingEntry(result.anchor, true);
await flushPrefetchTasks();
pingVisibleLinksFromRuntime();
await flushPrefetchTasks();
expect(result.fetch).not.toHaveBeenCalled();
expect(result.pagePrefetchLinks).toEqual([
{
as: "document",
href: "/pages-viewport-prefetch-target",
rel: "prefetch",
},
]);
} finally {
result.restoreNodeEnv();
}
});
it("does not observe visible links when prefetch is false", async () => {
const observe = vi.fn();
const unobserve = vi.fn();
class FakeIntersectionObserver {
observe = observe;
unobserve = unobserve;
}
vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver);
const result = await renderIsolatedLink({
href: "/disabled-viewport-prefetch-target",
nodeEnv: "production",
props: { prefetch: false },
});
try {
expect(observe).not.toHaveBeenCalled();
expect(result.fetch).not.toHaveBeenCalled();
} finally {
result.restoreNodeEnv();
}
});
it("preserves user intent handlers on dangerous inert links", async () => {
const userOnMouseEnter = vi.fn();
const userOnTouchStart = vi.fn();
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
const result = await renderIsolatedLink({
href: "javascript:alert(1)",
nodeEnv: "development",
props: {
onMouseEnter: userOnMouseEnter,
onTouchStart: userOnTouchStart,
},
requireRef: false,
});
try {
result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor });
result.capturedAnchorProps.onTouchStart?.({ currentTarget: result.anchor });
expect(userOnMouseEnter).toHaveBeenCalledTimes(1);
expect(userOnTouchStart).toHaveBeenCalledTimes(1);
expect(result.fetch).not.toHaveBeenCalled();
} finally {
consoleWarn.mockRestore();
result.restoreNodeEnv();
}
});
// Ported from Next.js: test/e2e/app-dir/javascript-urls/javascript-urls.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/javascript-urls/javascript-urls.test.ts
// The Next.js test asserts a console.error log appears whose message
// includes "has blocked a javascript: URL as a security precaution.".
//
// Coverage matrix (see issue #1576): the same Next.js E2E suite asserts the
// console.error for four Link-flavoured scenarios — App Router `href`,
// App Router `as`, Pages Router `href`, Pages Router `as`. The Link shim
// serves both routers, so each variant is exercised by toggling
// `appNavigation` and swapping `href` <-> `as`.
const dangerousLinkScenarios: Array<{
name: string;
appNavigation: boolean;
linkProps: { href: string; as?: string };
}> = [
{
name: "App Router Link with dangerous href",
appNavigation: true,
linkProps: { href: "javascript:alert(1)" },
},
{
name: "App Router Link with dangerous `as`",
appNavigation: true,
linkProps: { href: "/safe", as: "javascript:alert(1)" },
},
{
name: "Pages Router Link with dangerous href",
appNavigation: false,
linkProps: { href: "javascript:alert(1)" },
},
{
name: "Pages Router Link with dangerous `as`",
appNavigation: false,
linkProps: { href: "/safe", as: "javascript:alert(1)" },
},
];
for (const scenario of dangerousLinkScenarios) {
it(`emits a console.error matching Next.js when a ${scenario.name} is clicked`, async () => {
const userOnClick = vi.fn();
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
const result = await renderIsolatedLink({
appNavigation: scenario.appNavigation,
href: scenario.linkProps.href,
nodeEnv: "development",
props: {
...(scenario.linkProps.as !== undefined ? { as: scenario.linkProps.as } : {}),
onClick: userOnClick,
},
requireRef: false,
});
try {
const onClick = result.capturedAnchorProps.onClick;
expect(onClick).toBeTypeOf("function");
const clickEvent = {
button: 0,
currentTarget: { hasAttribute: () => false, target: "" },
defaultPrevented: false,
preventDefault() {
this.defaultPrevented = true;
},
} satisfies CapturedClickEvent;
await onClick?.(clickEvent);
// User onClick still fires so callers can run analytics/preventDefault.
expect(userOnClick).toHaveBeenCalledWith(clickEvent);
// Navigation never happens (App Router) / fetch never fires.
expect(result.navigate).not.toHaveBeenCalled();
expect(result.fetch).not.toHaveBeenCalled();
// Next.js parity: a console.error is emitted that includes the block
// message — the E2E suite asserts on `.includes(...)` against this text.
expect(
consoleError.mock.calls.some((call) =>
call.some(
(arg) =>
typeof arg === "string" &&
arg.includes("has blocked a javascript: URL as a security precaution."),
),
),
).toBe(true);
} finally {
consoleError.mockRestore();
consoleWarn.mockRestore();
result.restoreNodeEnv();
}
});
}
});