Files
cloudflare__vinext/tests/shims.test.ts
T

22587 lines
865 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vite-plus/test";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { PAGES_FIXTURE_DIR } from "./helpers.js";
import { isExternalUrl, isHashOnlyChange } from "../packages/vinext/src/shims/router.js";
import { extractVinextNextDataJson } from "../packages/vinext/src/client/vinext-next-data.js";
import { isValidModulePath } from "../packages/vinext/src/client/validate-module-path.js";
import vinext from "../packages/vinext/src/index.js";
import { safeJsonStringify } from "../packages/vinext/src/server/html.js";
import { buildPagesNextDataScript } from "../packages/vinext/src/server/pages-page-response.js";
import type { Plugin } from "vite-plus";
import type { NextRouter } from "../packages/vinext/src/shims/router.js";
import type {
CacheHandler,
CacheHandlerValue,
IncrementalCacheValue,
} from "../packages/vinext/src/shims/cache.js";
import { isWindows, normalizePathSeparators } from "../packages/vinext/src/utils/path.ts";
const FIXTURE_DIR = PAGES_FIXTURE_DIR;
function commitRenderedPagesRouterElement(element: unknown): void {
if (typeof document === "undefined" || document.documentElement === undefined) return;
const props = (element as { props?: unknown } | null)?.props;
const onCommit =
props && typeof props === "object" ? (props as { onCommit?: unknown }).onCommit : undefined;
if (typeof onCommit === "function") queueMicrotask(onCommit as () => void);
}
describe("vinext next data client helpers", () => {
it("extracts Next.js-compatible application/json data scripts", () => {
const json = '{"props":{"pageProps":{"nested":{"value":"}"}}},"page":"/","query":{}}';
expect(
extractVinextNextDataJson(
`<script id="__NEXT_DATA__" type="application/json">${json}</script>`,
),
).toBe(json);
});
it("extracts __NEXT_DATA__ after carriage-return whitespace", () => {
const json = '{"props":{},"page":"/","query":{}}';
expect(extractVinextNextDataJson(`<script>window.__NEXT_DATA__ = \r\n\t${json}</script>`)).toBe(
json,
);
});
});
describe("dynamic route href interpolation", () => {
it("preserves query and hash suffixes while interpolating", async () => {
const { interpolateDynamicRouteHref } =
await import("../packages/vinext/src/shims/internal/interpolate-as.js");
expect(interpolateDynamicRouteHref("/blog/[slug]?preview=1#section", "/blog/post")).toEqual({
href: "/blog/post?preview=1#section",
params: ["slug"],
query: { preview: "1" },
routePathname: "/blog/[slug]",
});
});
it("treats a query marker after the hash as fragment text", async () => {
const { interpolateDynamicRouteHref } =
await import("../packages/vinext/src/shims/internal/interpolate-as.js");
expect(interpolateDynamicRouteHref("/blog/[slug]#a?b=1", "/blog/post")).toEqual({
href: "/blog/post#a?b=1",
params: ["slug"],
query: {},
routePathname: "/blog/[slug]",
});
});
it("normalizes object-form query values for interpolation", async () => {
const { interpolateDynamicRouteHref } =
await import("../packages/vinext/src/shims/internal/interpolate-as.js");
expect(
interpolateDynamicRouteHref("/blog/[slug]?draft=true", "/blog/[slug]", {
slug: 42,
tag: ["one", 2],
missing: undefined,
}),
).toEqual({
href: "/blog/42?draft=true",
params: ["slug"],
query: { slug: "42", tag: ["one", "2"] },
routePathname: "/blog/[slug]",
});
});
});
describe("slot BFCache entry ordering", () => {
it("keeps the three most recent state-key entries", async () => {
const { updateBfcacheSlotEntryOrder } = await import("../packages/vinext/src/shims/slot.js");
let order: readonly string[] = [];
order = updateBfcacheSlotEntryOrder(order, "one", 3);
order = updateBfcacheSlotEntryOrder(order, "two", 3);
order = updateBfcacheSlotEntryOrder(order, "three", 3);
order = updateBfcacheSlotEntryOrder(order, "four", 3);
expect(order).toEqual(["four", "three", "two"]);
});
it("moves an existing entry to the front without evicting another entry", async () => {
const { updateBfcacheSlotEntryOrder } = await import("../packages/vinext/src/shims/slot.js");
let order: readonly string[] = [];
order = updateBfcacheSlotEntryOrder(order, "one", 3);
order = updateBfcacheSlotEntryOrder(order, "two", 3);
order = updateBfcacheSlotEntryOrder(order, "three", 3);
order = updateBfcacheSlotEntryOrder(order, "one", 3);
expect(order).toEqual(["one", "three", "two"]);
});
it("keeps a single retained entry when the limit is one", async () => {
const { updateBfcacheSlotEntryOrder } = await import("../packages/vinext/src/shims/slot.js");
expect(updateBfcacheSlotEntryOrder(["one", "two", "three"], "two", 1)).toEqual(["two"]);
});
it("normalizes non-finite or non-positive limits to a single entry", async () => {
const { updateBfcacheSlotEntryOrder } = await import("../packages/vinext/src/shims/slot.js");
expect(updateBfcacheSlotEntryOrder(["one", "two"], "two", Number.NaN)).toEqual(["two"]);
expect(updateBfcacheSlotEntryOrder(["one", "two"], "two", 0)).toEqual(["two"]);
expect(updateBfcacheSlotEntryOrder(["one", "two"], "two", 1.9)).toEqual(["two"]);
});
it("ignores the active key when scanning the previous order", async () => {
const { updateBfcacheSlotEntryOrder } = await import("../packages/vinext/src/shims/slot.js");
expect(updateBfcacheSlotEntryOrder(["one", "two", "three"], "two", 3)).toEqual([
"two",
"one",
"three",
]);
});
});
describe("next/navigation shim", () => {
it("exports usePathname, useSearchParams, useParams, useRouter", async () => {
const nav = await import("../packages/vinext/src/shims/navigation.js");
expect(typeof nav.usePathname).toBe("function");
expect(typeof nav.useSearchParams).toBe("function");
expect(typeof nav.useParams).toBe("function");
expect(typeof nav.useRouter).toBe("function");
});
// Next.js parity: next/navigation's useRouter reads AppRouterContext and
// throws when it is rendered outside the App Router provider.
// Ported from Next.js:
// https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/navigation.ts
it("useRouter() throws when AppRouterContext is not mounted", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const { useRouter } = await import("../packages/vinext/src/shims/navigation.js");
function Probe() {
useRouter();
return React.createElement("span", null, "unreachable");
}
expect(() => renderToStaticMarkup(React.createElement(Probe))).toThrow(
"invariant expected app router to be mounted",
);
});
// Regression test: within the App Router provider, useRouter() must preserve
// the mounted router instance as the source of method behavior and only layer
// contextual bfcacheId on top.
// Ported from: https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/navigation.ts
it("useRouter() preserves methods from the mounted AppRouterContext router", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const navigation = await import("../packages/vinext/src/shims/navigation.js");
const { AppRouterContext } =
await import("../packages/vinext/src/shims/internal/app-router-context.js");
const customRefresh = vi.fn();
const customMethod = vi.fn(() => "custom");
const mountedRouter = {
...navigation.appRouterInstance,
refresh: customRefresh,
customMethod,
};
const captured: any[] = [];
function Probe() {
const router = navigation.useRouter() as typeof mountedRouter;
captured.push(router);
router.refresh();
router.customMethod();
return React.createElement("span", null, "ok");
}
if (!AppRouterContext) {
throw new Error("Expected AppRouterContext to be available in the test renderer");
}
renderToStaticMarkup(
React.createElement(
AppRouterContext.Provider,
{ value: mountedRouter },
React.createElement(Probe),
),
);
expect(captured).toHaveLength(1);
expect(captured[0]).not.toBe(mountedRouter);
expect(captured[0].refresh).toBe(customRefresh);
expect(captured[0].customMethod).toBe(customMethod);
expect(captured[0].bfcacheId).toBe("_b_0_");
expect(customRefresh).toHaveBeenCalledTimes(1);
expect(customMethod).toHaveBeenCalledTimes(1);
});
it("appRouterInstance singleton exposes the expected navigation methods", async () => {
const { appRouterInstance: router } =
await import("../packages/vinext/src/shims/navigation.js");
expect(typeof router.push).toBe("function");
expect(typeof router.replace).toBe("function");
expect(typeof router.back).toBe("function");
expect(typeof router.forward).toBe("function");
expect(typeof router.refresh).toBe("function");
expect(typeof router.prefetch).toBe("function");
});
it("appRouterInstance exposes the stable bfcacheId placeholder", async () => {
const { appRouterInstance } = await import("../packages/vinext/src/shims/navigation.js");
expect(typeof appRouterInstance.bfcacheId).toBe("string");
expect(appRouterInstance.bfcacheId).toBe("0");
});
it("useRouter() reads bfcacheId from the nearest segment context", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const navigation = await import("../packages/vinext/src/shims/navigation.js");
const { AppRouterContext } =
await import("../packages/vinext/src/shims/internal/app-router-context.js");
const BfcacheIdMapContext = navigation.getBfcacheIdMapContext();
const BfcacheSegmentIdContext = navigation.getBfcacheSegmentIdContext();
if (!AppRouterContext || !BfcacheIdMapContext || !BfcacheSegmentIdContext) {
throw new Error("Expected bfcache contexts");
}
function Probe() {
return React.createElement("span", null, navigation.useRouter().bfcacheId);
}
expect(
renderToStaticMarkup(
React.createElement(
AppRouterContext.Provider,
{ value: navigation.appRouterInstance },
React.createElement(
BfcacheIdMapContext.Provider,
{ value: { "page:/x/1": "_b_7_" } },
React.createElement(
BfcacheSegmentIdContext.Provider,
{ value: "page:/x/1" },
React.createElement(Probe),
),
),
),
),
).toBe("<span>_b_7_</span>");
});
it("useRouter() treats an empty string segment id as a valid context key", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const navigation = await import("../packages/vinext/src/shims/navigation.js");
const { AppRouterContext } =
await import("../packages/vinext/src/shims/internal/app-router-context.js");
const BfcacheIdMapContext = navigation.getBfcacheIdMapContext();
const BfcacheSegmentIdContext = navigation.getBfcacheSegmentIdContext();
if (!AppRouterContext || !BfcacheIdMapContext || !BfcacheSegmentIdContext) {
throw new Error("Expected bfcache contexts");
}
function Probe() {
return React.createElement("span", null, navigation.useRouter().bfcacheId);
}
expect(
renderToStaticMarkup(
React.createElement(
AppRouterContext.Provider,
{ value: navigation.appRouterInstance },
React.createElement(
BfcacheIdMapContext.Provider,
{ value: { "": "_b_9_" } },
React.createElement(
BfcacheSegmentIdContext.Provider,
{ value: "" },
React.createElement(Probe),
),
),
),
),
).toBe("<span>_b_9_</span>");
});
it("useRouter() materializes the initial bfcacheId in Next-compatible format", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const navigation = await import("../packages/vinext/src/shims/navigation.js");
const { AppRouterContext } =
await import("../packages/vinext/src/shims/internal/app-router-context.js");
const BfcacheIdMapContext = navigation.getBfcacheIdMapContext();
const BfcacheSegmentIdContext = navigation.getBfcacheSegmentIdContext();
if (!AppRouterContext || !BfcacheIdMapContext || !BfcacheSegmentIdContext) {
throw new Error("Expected bfcache contexts");
}
function Probe() {
return React.createElement("span", null, navigation.useRouter().bfcacheId);
}
expect(
renderToStaticMarkup(
React.createElement(
AppRouterContext.Provider,
{ value: navigation.appRouterInstance },
React.createElement(
BfcacheIdMapContext.Provider,
{ value: { "page:/x/1": "0" } },
React.createElement(
BfcacheSegmentIdContext.Provider,
{ value: "page:/x/1" },
React.createElement(Probe),
),
),
),
),
).toBe("<span>_b_0_</span>");
});
// Next.js parity: refresh-reducer.ts invalidates the entire segment cache.
// Our equivalent is clearClientNavigationCaches(), which router.refresh()
// must call before re-fetching, or stale cached RSC payloads for sibling
// routes will still satisfy a subsequent client navigation. The clear must
// happen before the rscNavigate dispatch so it cannot race with prefetches
// kicked off during the transition's renders.
// Ported from: https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts
it("router.refresh() clears nav caches before dispatching the RSC re-fetch", async () => {
const previousWindow = (globalThis as any).window;
const calls: string[] = [];
const win = {
location: { href: "http://localhost/current" },
history: {
state: null,
pushState: () => {},
replaceState: () => {},
},
addEventListener: () => {},
[Symbol.for("vinext.navigationRuntime")]: {
bootstrap: {
routeManifest: null,
rsc: undefined,
},
functions: {
clearNavigationCaches: () => {
calls.push("clear");
},
navigate: async (_href: string, _depth: number, kind: string) => {
calls.push(`navigate:${kind}`);
},
},
},
};
(globalThis as any).window = win;
try {
vi.resetModules();
const { appRouterInstance } = await import("../packages/vinext/src/shims/navigation.js");
appRouterInstance.refresh();
// refresh() schedules the rscNavigate inside React.startTransition, so
// the navigate call lands after the synchronous clear but is dispatched
// in the same tick — yield once to let it flush.
await Promise.resolve();
await Promise.resolve();
expect(calls[0]).toBe("clear");
expect(calls).toContain("navigate:refresh");
expect(calls.indexOf("clear")).toBeLessThan(calls.indexOf("navigate:refresh"));
} finally {
(globalThis as any).window = previousWindow;
vi.resetModules();
}
});
it("attaches experimental_gesturePush only when the gesture transition define is set", async () => {
// In real builds `process.env.__NEXT_GESTURE_TRANSITION` is replaced with a
// boolean literal by the define in packages/vinext/src/index.ts; in vitest
// it reads the live env, so stub it and re-import the shim to exercise both
// sides of the gate.
try {
vi.stubEnv("__NEXT_GESTURE_TRANSITION", "");
vi.resetModules();
const { appRouterInstance: withoutDefine } =
await import("../packages/vinext/src/shims/navigation.js");
expect(typeof withoutDefine.experimental_gesturePush).toBe("undefined");
vi.stubEnv("__NEXT_GESTURE_TRANSITION", "true");
vi.resetModules();
const { appRouterInstance: withDefine } =
await import("../packages/vinext/src/shims/navigation.js");
expect(typeof withDefine.experimental_gesturePush).toBe("function");
} finally {
vi.unstubAllEnvs();
vi.resetModules();
}
});
// Next.js parity: upstream's gesturePush early-returns when
// `getCurrentAppRouterState() === null` — a gesture dispatched before
// hydration is a no-op. Without the guard, navigateClientSide's non-runtime
// fallback would perform a real history push instead.
it("experimental_gesturePush is a no-op before the navigation runtime is ready", async () => {
const previousWindow = (globalThis as any).window;
const historyCalls: string[] = [];
const win = {
location: { href: "http://localhost/current", origin: "http://localhost" },
history: {
state: null,
pushState: () => {
historyCalls.push("push");
},
replaceState: () => {
historyCalls.push("replace");
},
},
addEventListener: () => {},
// No Symbol.for("vinext.navigationRuntime") entry: pre-hydration state.
};
(globalThis as any).window = win;
try {
vi.stubEnv("__NEXT_GESTURE_TRANSITION", "true");
vi.resetModules();
const { appRouterInstance } = await import("../packages/vinext/src/shims/navigation.js");
if (!appRouterInstance.experimental_gesturePush) {
throw new Error("Expected experimental_gesturePush to be attached");
}
appRouterInstance.experimental_gesturePush("/next-page");
// navigateClientSide is async; flush microtasks so a (buggy) fallthrough
// into its history-push branch would be observable.
await Promise.resolve();
await Promise.resolve();
expect(historyCalls).toEqual([]);
expect(win.location.href).toBe("http://localhost/current");
} finally {
(globalThis as any).window = previousWindow;
vi.unstubAllEnvs();
vi.resetModules();
}
});
it("hash-only app router navigation preserves bfcache metadata without copying scroll restoration", async () => {
const previousWindow = (globalThis as any).window;
const previousDocument = (globalThis as any).document;
let historyState: unknown = {
__vinext_bfcacheIds: { "page:/current": "_b_1_" },
__vinext_bfcacheVersion: 2,
__vinext_previousNextUrl: "/feed",
customState: "drop-me",
};
const location = {
href: "http://localhost/current",
origin: "http://localhost",
pathname: "/current",
search: "",
hash: "",
assign: vi.fn(),
replace: vi.fn(),
};
const applyUrl = (url: string | URL | null | undefined) => {
if (url == null) return;
const next = new URL(String(url), location.href);
location.href = next.href;
location.pathname = next.pathname;
location.search = next.search;
location.hash = next.hash;
};
const pushState = vi.fn((data: unknown, _unused: string, url?: string | URL | null) => {
historyState = data;
applyUrl(url);
});
const replaceState = vi.fn((data: unknown, _unused: string, url?: string | URL | null) => {
historyState = data;
applyUrl(url);
});
const win = {
location,
history: {
get state() {
return historyState;
},
pushState,
replaceState,
},
scrollX: 12,
scrollY: 345,
scrollTo: vi.fn(),
addEventListener: vi.fn(),
dispatchEvent: vi.fn(),
__VINEXT_RSC_NAVIGATE__: vi.fn(),
};
const scrollIntoView = vi.fn();
(globalThis as any).window = win;
(globalThis as any).document = {
getElementById: vi.fn(() => ({ scrollIntoView })),
getElementsByName: vi.fn(() => []),
};
try {
vi.resetModules();
const { navigateClientSide } = await import("../packages/vinext/src/shims/navigation.js");
await navigateClientSide("#content", "push", true, true);
expect(replaceState).toHaveBeenLastCalledWith(
expect.objectContaining({ __vinext_scrollX: 12, __vinext_scrollY: 345 }),
"",
undefined,
);
expect(pushState).toHaveBeenCalledWith(
{
__vinext_bfcacheIds: { "page:/current": "_b_1_" },
__vinext_bfcacheVersion: 2,
__vinext_previousNextUrl: "/feed",
},
"",
"/current#content",
);
expect(scrollIntoView).toHaveBeenCalled();
} finally {
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
if (previousDocument === undefined) {
delete (globalThis as any).document;
} else {
(globalThis as any).document = previousDocument;
}
vi.resetModules();
}
});
it("treats streamed App Router redirect meta as an MPA navigation", async () => {
const previousWindow = (globalThis as any).window;
const previousDocument = (globalThis as any).document;
const location = {
href: "http://localhost/redirect/redirect-with-loading",
origin: "http://localhost",
pathname: "/redirect/redirect-with-loading",
search: "",
hash: "",
assign: vi.fn(),
replace: vi.fn(),
};
const win = {
location,
history: {
state: null,
pushState: vi.fn(),
replaceState: vi.fn(),
},
scrollX: 0,
scrollY: 0,
scrollTo: vi.fn(),
addEventListener: vi.fn(),
dispatchEvent: vi.fn(),
};
const getElementById = vi.fn((id: string) => (id === "__next-page-redirect" ? { id } : null));
const navigate = vi.fn(async () => {});
const navigateExternal = vi.fn(async () => {});
(globalThis as any).window = win;
(globalThis as any).document = {
getElementById,
getElementsByName: vi.fn(() => []),
};
try {
vi.resetModules();
const { NAVIGATION_RUNTIME_KEY } =
await import("../packages/vinext/src/client/navigation-runtime.js");
(win as any)[NAVIGATION_RUNTIME_KEY] = {
bootstrap: {
routeManifest: null,
rsc: undefined,
},
functions: {
navigate,
navigateExternal,
},
};
const { navigateClientSide } = await import("../packages/vinext/src/shims/navigation.js");
await navigateClientSide("/redirect/result", "replace", true, true);
expect(getElementById).toHaveBeenCalledWith("__next-page-redirect");
expect(navigateExternal).toHaveBeenCalledWith("/redirect/result", "replace");
expect(navigate).not.toHaveBeenCalled();
expect(location.replace).not.toHaveBeenCalled();
} finally {
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
if (previousDocument === undefined) {
delete (globalThis as any).document;
} else {
(globalThis as any).document = previousDocument;
}
vi.resetModules();
}
});
it("keeps pending render snapshot active when external history.pushState syncs the URL", async () => {
const previousWindow = (globalThis as any).window;
const win = {
location: {
pathname: "/current",
search: "?from=committed",
hash: "",
href: "http://localhost/current?from=committed",
origin: "http://localhost",
},
history: {
state: null,
pushState(_data: unknown, _unused: string, url?: string | URL | null) {
if (!url) return;
const parsed = new URL(url, win.location.href);
win.location.pathname = parsed.pathname;
win.location.search = parsed.search;
win.location.hash = parsed.hash;
win.location.href = parsed.href;
},
replaceState(_data: unknown, _unused: string, url?: string | URL | null) {
if (!url) return;
const parsed = new URL(url, win.location.href);
win.location.pathname = parsed.pathname;
win.location.search = parsed.search;
win.location.hash = parsed.hash;
win.location.href = parsed.href;
},
},
addEventListener: vi.fn(),
};
(globalThis as any).window = win;
try {
vi.resetModules();
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const navigation = await import("../packages/vinext/src/shims/navigation.js");
const Context = navigation.getClientNavigationRenderContext();
if (!Context) {
throw new Error("Expected client navigation render context");
}
navigation.activateNavigationSnapshot();
const snapshot = navigation.createClientNavigationRenderSnapshot(
"http://localhost/pending?from=snapshot",
{},
);
const readHookValues = () => {
let pathname: string | null = "";
let search = "";
function Probe() {
pathname = navigation.usePathname();
search = navigation.useSearchParams().toString();
return React.createElement("span", null, pathname);
}
renderToStaticMarkup(
React.createElement(Context.Provider, { value: snapshot }, React.createElement(Probe)),
);
return { pathname, search };
};
expect(readHookValues()).toEqual({
pathname: "/pending",
search: "from=snapshot",
});
win.history.pushState(null, "", "/ownerless?from=history");
expect(readHookValues()).toEqual({
pathname: "/pending",
search: "from=snapshot",
});
} finally {
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
}
});
it("preserves App Router history metadata when external history calls provide caller state", async () => {
// Matches Next.js' external History API wrapper behavior:
// https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/app-router.tsx#L114-L127
// Covered by Next.js shallow-routing tests for object, null, and undefined state:
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/shallow-routing/shallow-routing.test.ts
const previousWindow = (globalThis as any).window;
const historyPreviousNextUrlKey = "__vinext_previousNextUrl";
const historyTraversalIndexKey = "__vinext_historyIndex";
const win = {
location: {
pathname: "/photo/1",
search: "",
hash: "",
href: "http://localhost/photo/1",
origin: "http://localhost",
},
history: {
state: {
[historyPreviousNextUrlKey]: "/feed",
[historyTraversalIndexKey]: 4,
} as unknown,
pushState(data: unknown, _unused: string, url?: string | URL | null) {
this.state = data;
if (!url) return;
const parsed = new URL(url, win.location.href);
win.location.pathname = parsed.pathname;
win.location.search = parsed.search;
win.location.hash = parsed.hash;
win.location.href = parsed.href;
},
replaceState(data: unknown, _unused: string, url?: string | URL | null) {
this.state = data;
if (!url) return;
const parsed = new URL(url, win.location.href);
win.location.pathname = parsed.pathname;
win.location.search = parsed.search;
win.location.hash = parsed.hash;
win.location.href = parsed.href;
},
},
addEventListener: vi.fn(),
};
(globalThis as any).window = win;
try {
vi.resetModules();
await import("../packages/vinext/src/shims/navigation.js");
win.history.pushState({ myData: { foo: "bar" } }, "", "/photo/1?filter=active");
expect(win.history.state).toEqual({
[historyPreviousNextUrlKey]: "/feed",
[historyTraversalIndexKey]: 4,
myData: { foo: "bar" },
});
win.history.pushState(null, "", "/photo/1?filter=pending");
expect(win.history.state).toEqual({
[historyPreviousNextUrlKey]: "/feed",
[historyTraversalIndexKey]: 4,
});
win.history.replaceState(null, "", "/photo/1?filter=archived");
expect(win.history.state).toEqual({
[historyPreviousNextUrlKey]: "/feed",
[historyTraversalIndexKey]: 4,
});
win.history.replaceState(undefined, "", "/photo/1?filter=all");
expect(win.history.state).toEqual({
[historyPreviousNextUrlKey]: "/feed",
[historyTraversalIndexKey]: 4,
});
win.history.state = { [historyTraversalIndexKey]: 7 };
win.history.pushState({ next: true }, "", "/photo/1?filter=done");
expect(win.history.state).toEqual({
[historyTraversalIndexKey]: 7,
next: true,
});
} finally {
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
}
});
it("exports redirect, notFound, permanentRedirect", async () => {
const nav = await import("../packages/vinext/src/shims/navigation.js");
expect(typeof nav.redirect).toBe("function");
expect(typeof nav.notFound).toBe("function");
expect(typeof nav.permanentRedirect).toBe("function");
});
it("redirect() throws with correct digest", async () => {
const { redirect } = await import("../packages/vinext/src/shims/navigation.js");
try {
redirect("/login");
expect.unreachable("should have thrown");
} catch (e: any) {
expect(e.digest).toContain("NEXT_REDIRECT");
// URL is encodeURIComponent-encoded in the digest to prevent delimiter injection
expect(e.digest).toContain(encodeURIComponent("/login"));
}
});
// Ported from Next.js: packages/next/src/client/components/redirect.ts
// In Next.js, redirect() without an explicit type uses an empty sentinel so
// the context (action vs render) can resolve the default at the catch site.
it("redirect() without explicit type uses empty sentinel (context-dependent default)", async () => {
const { redirect } = await import("../packages/vinext/src/shims/navigation.js");
try {
redirect("/dashboard");
expect.unreachable("should have thrown");
} catch (e: any) {
const parts = e.digest.split(";");
expect(parts[0]).toBe("NEXT_REDIRECT");
// Empty string sentinel — the catch site determines push vs replace
expect(parts[1]).toBe("");
expect(decodeURIComponent(parts[2])).toBe("/dashboard");
}
});
it("redirect() with explicit 'push' type preserves it in digest", async () => {
const { redirect } = await import("../packages/vinext/src/shims/navigation.js");
try {
redirect("/dashboard", "push");
expect.unreachable("should have thrown");
} catch (e: any) {
const parts = e.digest.split(";");
expect(parts[1]).toBe("push");
}
});
it("redirect() with explicit 'replace' type preserves it in digest", async () => {
const { redirect } = await import("../packages/vinext/src/shims/navigation.js");
try {
redirect("/dashboard", "replace");
expect.unreachable("should have thrown");
} catch (e: any) {
const parts = e.digest.split(";");
expect(parts[1]).toBe("replace");
}
});
it("redirect() encodes semicolons in URL to prevent digest injection", async () => {
const { redirect } = await import("../packages/vinext/src/shims/navigation.js");
try {
redirect("http://example.com;301");
expect.unreachable("should have thrown");
} catch (e: any) {
const parts = e.digest.split(";");
// The URL field must not leak into the status code position
expect(parts).toHaveLength(3); // NEXT_REDIRECT, type, encoded-url
expect(parts[0]).toBe("NEXT_REDIRECT");
// Empty sentinel — no explicit type passed
expect(parts[1]).toBe("");
expect(decodeURIComponent(parts[2])).toBe("http://example.com;301");
}
});
it("permanentRedirect() accepts an optional type parameter", async () => {
const { permanentRedirect } = await import("../packages/vinext/src/shims/navigation.js");
try {
permanentRedirect("/new-page", "push");
expect.unreachable("should have thrown");
} catch (e: any) {
const parts = e.digest.split(";");
expect(parts[0]).toBe("NEXT_REDIRECT");
expect(parts[1]).toBe("push");
expect(decodeURIComponent(parts[2])).toBe("/new-page");
expect(parts[3]).toBe("308");
}
});
it("permanentRedirect() defaults to 'replace' when no type given", async () => {
const { permanentRedirect } = await import("../packages/vinext/src/shims/navigation.js");
try {
permanentRedirect("/new-page");
expect.unreachable("should have thrown");
} catch (e: any) {
const parts = e.digest.split(";");
expect(parts[1]).toBe("replace");
}
});
it("notFound() throws with correct digest", async () => {
const { notFound } = await import("../packages/vinext/src/shims/navigation.js");
try {
notFound();
expect.unreachable("should have thrown");
} catch (e: any) {
expect(e.digest).toBe("NEXT_HTTP_ERROR_FALLBACK;404");
}
});
it("forbidden() throws with correct digest", async () => {
const { forbidden } = await import("../packages/vinext/src/shims/navigation.js");
try {
forbidden();
expect.unreachable("should have thrown");
} catch (e: any) {
expect(e.digest).toBe("NEXT_HTTP_ERROR_FALLBACK;403");
}
});
it("unauthorized() throws with correct digest", async () => {
const { unauthorized } = await import("../packages/vinext/src/shims/navigation.js");
try {
unauthorized();
expect.unreachable("should have thrown");
} catch (e: any) {
expect(e.digest).toBe("NEXT_HTTP_ERROR_FALLBACK;401");
}
});
it("isHTTPAccessFallbackError detects all HTTP access fallback errors", async () => {
const {
notFound,
forbidden,
unauthorized,
isHTTPAccessFallbackError,
getAccessFallbackHTTPStatus,
} = await import("../packages/vinext/src/shims/navigation.js");
// Test notFound
try {
notFound();
} catch (e) {
expect(isHTTPAccessFallbackError(e)).toBe(true);
expect(getAccessFallbackHTTPStatus(e)).toBe(404);
}
// Test forbidden
try {
forbidden();
} catch (e) {
expect(isHTTPAccessFallbackError(e)).toBe(true);
expect(getAccessFallbackHTTPStatus(e)).toBe(403);
}
// Test unauthorized
try {
unauthorized();
} catch (e) {
expect(isHTTPAccessFallbackError(e)).toBe(true);
expect(getAccessFallbackHTTPStatus(e)).toBe(401);
}
// Test non-access error
expect(isHTTPAccessFallbackError(new Error("random"))).toBe(false);
expect(isHTTPAccessFallbackError(null)).toBe(false);
// Test legacy NEXT_NOT_FOUND format
const legacyErr = new Error("old");
(legacyErr as any).digest = "NEXT_NOT_FOUND";
expect(isHTTPAccessFallbackError(legacyErr)).toBe(true);
expect(getAccessFallbackHTTPStatus(legacyErr)).toBe(404);
});
it("setNavigationContext / useParams works on server side", async () => {
const { setNavigationContext, useParams } =
await import("../packages/vinext/src/shims/navigation.js");
setNavigationContext({
pathname: "/blog/test",
searchParams: new URLSearchParams(""),
params: { slug: "test" },
});
const params = useParams();
expect(params).toEqual({ slug: "test" });
setNavigationContext(null);
});
it("shares the hydrated navigation snapshot across browser module instances", async () => {
// Next.js derives usePathname/useSearchParams from PathnameContext in
// packages/next/src/client/components/app-router.tsx, so hydration does
// not have a per-module fallback to "/" for the first client snapshot.
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const previousWindow = globalThis.window;
const accessorsKey = Symbol.for("vinext.navigation.globalAccessors");
const hydrationKey = Symbol.for("vinext.navigation.clientHydrationContext");
const globalRecord = globalThis as Record<PropertyKey, unknown>;
const previousAccessors = globalRecord[accessorsKey];
const previousHydration = globalRecord[hydrationKey];
try {
delete globalRecord[accessorsKey];
delete globalRecord[hydrationKey];
(globalThis as any).window = {
addEventListener() {},
dispatchEvent() {
return true;
},
history: {
pushState() {},
replaceState() {},
},
location: {
href: "http://localhost/split-hydration?q=hello",
origin: "http://localhost",
pathname: "/split-hydration",
search: "?q=hello",
},
removeEventListener() {},
};
const setterPath = "../packages/vinext/src/shims/navigation.js?hydration-setter=issue-871";
const hookPath = "../packages/vinext/src/shims/navigation.js?hydration-hook=issue-871";
const setterMod = (await import(
setterPath
)) as typeof import("../packages/vinext/src/shims/navigation.js");
const hookMod = (await import(
hookPath
)) as typeof import("../packages/vinext/src/shims/navigation.js");
setterMod.setNavigationContext({
pathname: "/split-hydration",
searchParams: new URLSearchParams("q=hello"),
params: { slug: "hello" },
});
function Probe() {
const pathname = hookMod.usePathname();
const searchParams = hookMod.useSearchParams();
const params = hookMod.useParams<{ slug: string }>();
if (!params) throw new Error("expected app navigation params");
return React.createElement(
"span",
null,
`${pathname}|${searchParams.get("q") ?? ""}|${params.slug ?? ""}`,
);
}
expect(renderToStaticMarkup(React.createElement(Probe))).toBe(
"<span>/split-hydration|hello|hello</span>",
);
setterMod.setNavigationContext(null);
} finally {
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
if (previousAccessors === undefined) {
delete globalRecord[accessorsKey];
} else {
globalRecord[accessorsKey] = previousAccessors;
}
if (previousHydration === undefined) {
delete globalRecord[hydrationKey];
} else {
globalRecord[hydrationKey] = previousHydration;
}
}
});
it("keeps explicit app navigation context ahead of globally installed Pages compat state", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const previousWindow = globalThis.window;
const accessorKey = Symbol.for("vinext.navigation.pagesNavigationContextAccessor");
const hydrationKey = Symbol.for("vinext.navigation.clientHydrationContext");
const globalRecord = globalThis as Record<PropertyKey, unknown>;
const previousAccessor = globalRecord[accessorKey];
const previousHydration = globalRecord[hydrationKey];
try {
delete globalRecord[hydrationKey];
globalRecord[accessorKey] = () => ({
pathname: "/pages/[slug]",
searchParams: new URLSearchParams("from=pages"),
params: { slug: "pages" },
});
(globalThis as any).window = {
addEventListener() {},
dispatchEvent() {
return true;
},
history: {
pushState() {},
replaceState() {},
},
location: {
href: "http://localhost/app/alpha?from=app",
origin: "http://localhost",
pathname: "/app/alpha",
search: "?from=app",
},
removeEventListener() {},
};
const setterPath =
"../packages/vinext/src/shims/navigation.js?app-context-setter=pages-fallback";
const hookPath = "../packages/vinext/src/shims/navigation.js?app-context-hook=pages-fallback";
const setterMod = (await import(
setterPath
)) as typeof import("../packages/vinext/src/shims/navigation.js");
const hookMod = (await import(
hookPath
)) as typeof import("../packages/vinext/src/shims/navigation.js");
setterMod.setNavigationContext({
pathname: "/app/alpha",
searchParams: new URLSearchParams("from=app"),
params: { slug: "app" },
});
function Probe() {
const searchParams = hookMod.useSearchParams();
const params = hookMod.useParams<{ slug: string }>();
if (!params) throw new Error("expected app navigation params");
return React.createElement(
"span",
null,
`${searchParams.get("from") ?? ""}|${params.slug ?? ""}`,
);
}
expect(renderToStaticMarkup(React.createElement(Probe))).toBe("<span>app|app</span>");
setterMod.setNavigationContext(null);
} finally {
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
if (previousAccessor === undefined) {
delete globalRecord[accessorKey];
} else {
globalRecord[accessorKey] = previousAccessor;
}
if (previousHydration === undefined) {
delete globalRecord[hydrationKey];
} else {
globalRecord[hydrationKey] = previousHydration;
}
}
});
it("setClientParams provides referential stability for identical params", async () => {
const { setClientParams, getClientParams } =
await import("../packages/vinext/src/shims/navigation.js");
// Set params initially
setClientParams({ slug: "hello" });
const first = getClientParams();
// Set params with same values — should return same object reference
setClientParams({ slug: "hello" });
const second = getClientParams();
expect(first).toBe(second); // referential equality
// Set params with different values — should return new object
setClientParams({ slug: "world" });
const third = getClientParams();
expect(third).not.toBe(first);
expect(third).toEqual({ slug: "world" });
// Clean up
setClientParams({});
});
it("exports useSelectedLayoutSegment and useSelectedLayoutSegments", async () => {
const nav = await import("../packages/vinext/src/shims/navigation.js");
expect(typeof nav.useSelectedLayoutSegment).toBe("function");
expect(typeof nav.useSelectedLayoutSegments).toBe("function");
});
it("marks navigation hooks dynamic during fallback-shell prerendering", async () => {
const { createPprFallbackShellState, runWithPprFallbackShellState } =
await import("../packages/vinext/src/shims/ppr-fallback-shell.js");
const navigation = await import("../packages/vinext/src/shims/navigation.js");
const readHooks: Array<() => unknown> = [
() => navigation.usePathname(),
() => navigation.useSearchParams(),
() => navigation.useParams(),
() => navigation.useSelectedLayoutSegment(),
() => navigation.useSelectedLayoutSegments(),
];
for (const readHook of readHooks) {
const state = createPprFallbackShellState({
fallbackParamNames: ["slug"],
routePattern: "/blog/:slug",
});
runWithPprFallbackShellState(state, readHook);
expect(state.hasDynamicBoundary).toBe(true);
}
});
it("useSelectedLayoutSegment still works when provider and hook are loaded from different module instances", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const hookPath = "../packages/vinext/src/shims/navigation.js?hook-instance=a";
const providerPath =
"../packages/vinext/src/shims/layout-segment-context.tsx?provider-instance=b";
const hookMod: typeof import("../packages/vinext/src/shims/navigation.js") = await import(
hookPath
);
const providerMod: typeof import("../packages/vinext/src/shims/layout-segment-context.tsx") =
await import(providerPath);
function Probe() {
const segment = hookMod.useSelectedLayoutSegment();
return React.createElement("span", { "data-testid": "segment" }, segment ?? "null");
}
const html = renderToStaticMarkup(
React.createElement(providerMod.LayoutSegmentProvider, {
segmentMap: { children: ["explore"] },
// oxlint-disable-next-line react/no-children-prop
children: React.createElement(Probe),
}),
);
expect(html).toContain(">explore<");
});
it("ServerInsertedHTMLContext stays shared across multiple module instances", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const providerPath = "../packages/vinext/src/shims/navigation.js?inserted-html-provider=a";
const consumerPath = "../packages/vinext/src/shims/navigation.js?inserted-html-consumer=b";
const [providerMod, consumerMod]: [
typeof import("../packages/vinext/src/shims/navigation.js"),
typeof import("../packages/vinext/src/shims/navigation.js"),
] = await Promise.all([import(providerPath), import(consumerPath)]);
const providerCtx = providerMod.ServerInsertedHTMLContext;
const consumerCtx = consumerMod.ServerInsertedHTMLContext;
expect(providerCtx).toBeTruthy();
expect(consumerCtx).toBeTruthy();
const register = () => {};
let received: unknown = undefined;
function Probe() {
received = React.useContext(consumerCtx!);
return null;
}
renderToStaticMarkup(
React.createElement(providerCtx!.Provider, { value: register }, React.createElement(Probe)),
);
expect(received).toBe(register);
});
it("useSelectedLayoutSegments returns empty array outside React context", async () => {
const { useSelectedLayoutSegments } =
await import("../packages/vinext/src/shims/navigation.js");
// Outside a React tree, no LayoutSegmentProvider wraps us,
// so the context defaults to [].
const segments = useSelectedLayoutSegments();
expect(segments).toEqual([]);
});
it("useSelectedLayoutSegment returns null outside React context", async () => {
const { useSelectedLayoutSegment } = await import("../packages/vinext/src/shims/navigation.js");
// Outside a React tree, no LayoutSegmentProvider wraps us,
// so there are no child segments → null.
expect(useSelectedLayoutSegment()).toBeNull();
});
it("useSelectedLayoutSegments() accepts parallelRoutesKey and returns matching segments", async () => {
const nav = await import("../packages/vinext/src/shims/navigation.js");
// Outside a React tree, returns [] regardless of key.
const result = nav.useSelectedLayoutSegments("team");
expect(result).toEqual([]);
});
it("useSelectedLayoutSegment() accepts parallelRoutesKey and returns null outside React tree", async () => {
const nav = await import("../packages/vinext/src/shims/navigation.js");
const result = nav.useSelectedLayoutSegment("team");
expect(result).toBeNull();
});
it("useSelectedLayoutSegments(parallelRoutesKey) returns per-slot segments via segmentMap", async () => {
const React = await import("react");
const ReactDOMServer = await import("react-dom/server");
const { createElement } = React;
const { LayoutSegmentProvider } =
await import("../packages/vinext/src/shims/layout-segment-context.js");
const { useSelectedLayoutSegments, useSelectedLayoutSegment } =
await import("../packages/vinext/src/shims/navigation.js");
function TestComponent() {
const childrenSegs = useSelectedLayoutSegments();
const teamSegs = useSelectedLayoutSegments("team");
const teamSeg = useSelectedLayoutSegment("team");
return createElement(
"div",
null,
createElement("span", { id: "children" }, JSON.stringify(childrenSegs)),
createElement("span", { id: "team" }, JSON.stringify(teamSegs)),
createElement("span", { id: "team-singular" }, teamSeg ?? "null"),
);
}
const html = ReactDOMServer.renderToStaticMarkup(
createElement(LayoutSegmentProvider, {
segmentMap: { children: ["blog", "hello"], team: ["settings"] },
// oxlint-disable-next-line react/no-children-prop
children: createElement(TestComponent),
}),
);
// React SSR HTML-encodes " as &quot; in text content
const Q = "&quot;";
expect(html).toContain(`<span id="children">[${Q}blog${Q},${Q}hello${Q}]</span>`);
expect(html).toContain(`<span id="team">[${Q}settings${Q}]</span>`);
expect(html).toContain('<span id="team-singular">settings</span>');
});
it("useSelectedLayoutSegments(unknownKey) returns [] for missing slot", async () => {
const React = await import("react");
const ReactDOMServer = await import("react-dom/server");
const { createElement } = React;
const { LayoutSegmentProvider } =
await import("../packages/vinext/src/shims/layout-segment-context.js");
const { useSelectedLayoutSegments } =
await import("../packages/vinext/src/shims/navigation.js");
function TestComponent() {
const segs = useSelectedLayoutSegments("nonexistent");
return createElement("span", null, JSON.stringify(segs));
}
const html = ReactDOMServer.renderToStaticMarkup(
createElement(LayoutSegmentProvider, {
segmentMap: { children: ["blog"] },
// oxlint-disable-next-line react/no-children-prop
children: createElement(TestComponent),
}),
);
expect(html).toContain("[]");
});
// -------------------------------------------------------------------------
// unstable_rethrow + unstable_isUnrecognizedActionError
//
// Ported from Next.js:
// https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/unstable-rethrow.ts
// https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/unrecognized-action-error.ts
// -------------------------------------------------------------------------
it("exports unstable_rethrow and unstable_isUnrecognizedActionError", async () => {
const nav = await import("../packages/vinext/src/shims/navigation.js");
expect(typeof nav.unstable_rethrow).toBe("function");
expect(typeof nav.unstable_isUnrecognizedActionError).toBe("function");
expect(typeof nav.isRedirectError).toBe("function");
expect(typeof nav.isNextRouterError).toBe("function");
expect(typeof nav.UnrecognizedActionError).toBe("function");
});
it("unstable_rethrow re-throws redirect errors", async () => {
const { redirect, unstable_rethrow } =
await import("../packages/vinext/src/shims/navigation.js");
let captured: unknown = null;
try {
redirect("/login");
} catch (e) {
captured = e;
}
expect(captured).not.toBeNull();
expect(() => unstable_rethrow(captured)).toThrow();
try {
unstable_rethrow(captured);
} catch (rethrown) {
// Identity-preserving rethrow — must be the same error reference
expect(rethrown).toBe(captured);
}
});
it("unstable_rethrow re-throws notFound/forbidden/unauthorized errors", async () => {
const { notFound, forbidden, unauthorized, unstable_rethrow } =
await import("../packages/vinext/src/shims/navigation.js");
for (const trigger of [notFound, forbidden, unauthorized]) {
let captured: unknown = null;
try {
trigger();
} catch (e) {
captured = e;
}
expect(captured).not.toBeNull();
expect(() => unstable_rethrow(captured)).toThrow();
}
});
it("unstable_rethrow is a no-op for unrelated errors", async () => {
const { unstable_rethrow } = await import("../packages/vinext/src/shims/navigation.js");
// Plain Error: no digest, no cause
expect(() => unstable_rethrow(new Error("plain"))).not.toThrow();
expect(() => unstable_rethrow("string error")).not.toThrow();
expect(() => unstable_rethrow(null)).not.toThrow();
expect(() => unstable_rethrow(undefined)).not.toThrow();
expect(() => unstable_rethrow({ digest: "not-a-next-error" })).not.toThrow();
});
// -------------------------------------------------------------------------
// BailoutToCSRError + DynamicServerError parity
//
// Ported from Next.js:
// https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/lazy-dynamic/bailout-to-csr.ts
// https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/hooks-server-context.ts
// -------------------------------------------------------------------------
it("exports BailoutToCSRError + isBailoutToCSRError with the canonical digest", async () => {
const { BailoutToCSRError, isBailoutToCSRError } =
await import("../packages/vinext/src/shims/navigation.js");
const err = new BailoutToCSRError("test-reason");
expect(err.digest).toBe("BAILOUT_TO_CLIENT_SIDE_RENDERING");
expect(err.reason).toBe("test-reason");
expect(err.message).toBe("Bail out to client-side rendering: test-reason");
expect(isBailoutToCSRError(err)).toBe(true);
// Predicate matches by digest, not by instanceof — a foreign object with
// the canonical digest should also be detected (Next.js parity).
expect(isBailoutToCSRError({ digest: "BAILOUT_TO_CLIENT_SIDE_RENDERING" })).toBe(true);
// Negative cases.
expect(isBailoutToCSRError(new Error("plain"))).toBe(false);
expect(isBailoutToCSRError({ digest: "OTHER" })).toBe(false);
expect(isBailoutToCSRError(null)).toBe(false);
expect(isBailoutToCSRError(undefined)).toBe(false);
});
it("exports DynamicServerError + isDynamicServerError with the canonical digest", async () => {
const { DynamicServerError, isDynamicServerError } =
await import("../packages/vinext/src/shims/navigation.js");
const err = new DynamicServerError("cookies()");
expect(err.digest).toBe("DYNAMIC_SERVER_USAGE");
expect(err.description).toBe("cookies()");
expect(err.message).toBe("Dynamic server usage: cookies()");
expect(isDynamicServerError(err)).toBe(true);
// Predicate matches by digest — Next.js parity.
expect(isDynamicServerError({ digest: "DYNAMIC_SERVER_USAGE" })).toBe(true);
// Negative cases.
expect(isDynamicServerError(new Error("plain"))).toBe(false);
expect(isDynamicServerError({ digest: "OTHER" })).toBe(false);
expect(isDynamicServerError(null)).toBe(false);
expect(isDynamicServerError(undefined)).toBe(false);
});
// Mirrors the Next.js fixture
// .nextjs-ref/test/e2e/app-dir/unstable-rethrow/app/dynamic-error/page.tsx
// where `cookies()` throws a DynamicServerError inside a try/catch and
// unstable_rethrow must propagate it.
it("unstable_rethrow re-throws BailoutToCSRError (next/dynamic ssr:false bailout)", async () => {
const { BailoutToCSRError, unstable_rethrow } =
await import("../packages/vinext/src/shims/navigation.js");
const err = new BailoutToCSRError("Lazy(): No ssr");
expect(() => unstable_rethrow(err)).toThrow();
try {
unstable_rethrow(err);
} catch (rethrown) {
expect(rethrown).toBe(err);
}
});
it("unstable_rethrow re-throws DynamicServerError (cookies()/headers() in static render)", async () => {
const { DynamicServerError, unstable_rethrow } =
await import("../packages/vinext/src/shims/navigation.js");
const err = new DynamicServerError("Route used cookies()");
expect(() => unstable_rethrow(err)).toThrow();
try {
unstable_rethrow(err);
} catch (rethrown) {
expect(rethrown).toBe(err);
}
});
it("unstable_rethrow does NOT match the four server-only categories vinext does not implement", async () => {
// These categories (isDynamicPostpone, isPostpone,
// isHangingPromiseRejectionError, isPrerenderInterruptedError) are
// server-only Next.js internals tied to PPR / prerender-controller
// machinery vinext does not implement. They are deferred as follow-ups
// (see the JSDoc on unstable_rethrow). This test pins that intentional
// gap so the omission is visible to future maintainers.
const { unstable_rethrow } = await import("../packages/vinext/src/shims/navigation.js");
const postpone = { $$typeof: Symbol.for("react.postpone") };
expect(() => unstable_rethrow(postpone)).not.toThrow();
const hanging = Object.assign(new Error("hanging"), { digest: "HANGING_PROMISE_REJECTION" });
expect(() => unstable_rethrow(hanging)).not.toThrow();
const interrupted = Object.assign(new Error("interrupt"), {
digest: "NEXT_PRERENDER_INTERRUPTED",
});
expect(() => unstable_rethrow(interrupted)).not.toThrow();
// No fixed digest — message-shape based detection. We don't try to
// construct a faithful payload here; just confirm an arbitrary message
// doesn't match.
const dynamicPostpone = new Error("some random message");
expect(() => unstable_rethrow(dynamicPostpone)).not.toThrow();
});
it("unstable_rethrow recurses through error.cause to rethrow wrapped Next.js errors", async () => {
const { redirect, unstable_rethrow } =
await import("../packages/vinext/src/shims/navigation.js");
let inner: unknown = null;
try {
redirect("/login");
} catch (e) {
inner = e;
}
const wrapped = new Error("user wrapped this", { cause: inner });
expect(() => unstable_rethrow(wrapped)).toThrow();
try {
unstable_rethrow(wrapped);
} catch (rethrown) {
// The recursion should rethrow the original Next.js error, not the wrapper
expect(rethrown).toBe(inner);
}
});
it("unstable_isUnrecognizedActionError returns true for UnrecognizedActionError instances", async () => {
const { UnrecognizedActionError, unstable_isUnrecognizedActionError } =
await import("../packages/vinext/src/shims/navigation.js");
const err = new UnrecognizedActionError("missing action 'abc'");
expect(err.name).toBe("UnrecognizedActionError");
expect(unstable_isUnrecognizedActionError(err)).toBe(true);
});
it("unstable_isUnrecognizedActionError returns false for other errors", async () => {
const { unstable_isUnrecognizedActionError } =
await import("../packages/vinext/src/shims/navigation.js");
expect(unstable_isUnrecognizedActionError(new Error("other"))).toBe(false);
expect(unstable_isUnrecognizedActionError(null)).toBe(false);
expect(unstable_isUnrecognizedActionError(undefined)).toBe(false);
expect(unstable_isUnrecognizedActionError("string")).toBe(false);
expect(unstable_isUnrecognizedActionError({ name: "UnrecognizedActionError" })).toBe(false);
});
// -------------------------------------------------------------------------
// useUntrackedPathname
//
// Ported from Next.js:
// https://github.com/vercel/next.js/blob/v16.2.6/packages/next/src/client/components/navigation-untracked.ts
// -------------------------------------------------------------------------
it("useUntrackedPathname returns '/' on server when no navigation context is set", async () => {
const { useUntrackedPathname } =
await import("../packages/vinext/src/shims/internal/navigation-untracked.js");
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
let captured: string | null = "";
function Probe() {
captured = useUntrackedPathname();
return React.createElement("span", null, captured ?? "null");
}
renderToStaticMarkup(React.createElement(Probe));
expect(captured).toBe("/");
});
it("useUntrackedPathname returns pathname from server context", async () => {
const { useUntrackedPathname } =
await import("../packages/vinext/src/shims/internal/navigation-untracked.js");
const { setNavigationContext } = await import("../packages/vinext/src/shims/navigation.js");
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
setNavigationContext({
pathname: "/untracked/test",
searchParams: new URLSearchParams(""),
params: {},
});
let captured: string | null = "";
function Probe() {
captured = useUntrackedPathname();
return React.createElement("span", null, captured ?? "null");
}
try {
renderToStaticMarkup(React.createElement(Probe));
expect(captured).toBe("/untracked/test");
} finally {
setNavigationContext(null);
}
});
it("useUntrackedPathname prefers render snapshot during active navigation", async () => {
const previousWindow = globalThis.window;
const win = {
addEventListener() {},
dispatchEvent() {
return true;
},
history: {
pushState() {},
replaceState() {},
},
location: {
href: "http://localhost/committed",
origin: "http://localhost",
pathname: "/committed",
search: "",
},
removeEventListener() {},
};
(globalThis as any).window = win;
try {
vi.resetModules();
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const { useUntrackedPathname } =
await import("../packages/vinext/src/shims/internal/navigation-untracked.js");
const navigation = await import("../packages/vinext/src/shims/navigation.js");
const Context = navigation.getClientNavigationRenderContext();
if (!Context) {
throw new Error("Expected client navigation render context");
}
navigation.activateNavigationSnapshot();
const snapshot = navigation.createClientNavigationRenderSnapshot(
"http://localhost/pending-untracked?from=snapshot",
{},
);
let pathname: string | null = "";
function Probe() {
pathname = useUntrackedPathname();
return React.createElement("span", null, pathname ?? "null");
}
renderToStaticMarkup(
React.createElement(Context.Provider, { value: snapshot }, React.createElement(Probe)),
);
expect(pathname).toBe("/pending-untracked");
} finally {
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
}
});
it("useUntrackedPathname ignores stale render snapshot after navigation commits", async () => {
const previousWindow = globalThis.window;
const win = {
addEventListener() {},
dispatchEvent() {
return true;
},
history: {
pushState() {},
replaceState() {},
},
location: {
href: "http://localhost/committed",
origin: "http://localhost",
pathname: "/committed",
search: "",
},
removeEventListener() {},
};
(globalThis as any).window = win;
try {
vi.resetModules();
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const { useUntrackedPathname } =
await import("../packages/vinext/src/shims/internal/navigation-untracked.js");
const navigation = await import("../packages/vinext/src/shims/navigation.js");
const Context = navigation.getClientNavigationRenderContext();
if (!Context) {
throw new Error("Expected client navigation render context");
}
// Snapshot is present but NOT active (navigationSnapshotActiveCount === 0).
// This simulates a post-commit re-render where the provider still wraps
// the tree but the URL has since changed via user pushState/replaceState.
const snapshot = navigation.createClientNavigationRenderSnapshot(
"http://localhost/stale-provider?from=snapshot",
{},
);
let pathname: string | null = "";
function Probe() {
pathname = useUntrackedPathname();
return React.createElement("span", null, pathname ?? "null");
}
renderToStaticMarkup(
React.createElement(Context.Provider, { value: snapshot }, React.createElement(Probe)),
);
// Must return the committed pathname, not the stale provider snapshot.
expect(pathname).toBe("/committed");
} finally {
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
}
});
it("useUntrackedPathname returns Pages Router pathname when no App context is set", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const accessorKey = Symbol.for("vinext.navigation.pagesNavigationContextAccessor");
const globalRecord = globalThis as Record<PropertyKey, unknown>;
const previousAccessor = globalRecord[accessorKey];
try {
globalRecord[accessorKey] = () => ({
pathname: "/pages/untracked",
searchParams: new URLSearchParams(""),
params: {},
});
const hookPath =
"../packages/vinext/src/shims/internal/navigation-untracked.js?pages-untracked=1";
const { useUntrackedPathname } = (await import(
hookPath
)) as typeof import("../packages/vinext/src/shims/internal/navigation-untracked.js");
let captured: string | null = "";
function Probe() {
captured = useUntrackedPathname();
return React.createElement("span", null, captured ?? "null");
}
renderToStaticMarkup(React.createElement(Probe));
expect(captured).toBe("/pages/untracked");
} finally {
if (previousAccessor === undefined) {
delete globalRecord[accessorKey];
} else {
globalRecord[accessorKey] = previousAccessor;
}
}
});
// -------------------------------------------------------------------------
// react-server re-exports
// -------------------------------------------------------------------------
it("navigation.react-server re-exports unstable_rethrow and stubs unstable_isUnrecognizedActionError", async () => {
const rsc = await import("../packages/vinext/src/shims/navigation.react-server.js");
expect(typeof rsc.unstable_rethrow).toBe("function");
expect(typeof rsc.unstable_isUnrecognizedActionError).toBe("function");
// The RSC stub should throw a clear "client-only" error, matching Next.js.
expect(() => rsc.unstable_isUnrecognizedActionError()).toThrow(/client/i);
// unstable_rethrow itself is environment-agnostic — re-exported from
// ./navigation.js — and should behave identically to the client export.
const { redirect } = await import("../packages/vinext/src/shims/navigation.js");
let captured: unknown = null;
try {
redirect("/login");
} catch (e) {
captured = e;
}
expect(() => rsc.unstable_rethrow(captured)).toThrow();
});
});
// ---------------------------------------------------------------------------
// next/error shim — unstable_catchError
//
// Ported from Next.js:
// https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/catch-error.tsx
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/catch-error/
// ---------------------------------------------------------------------------
describe("next/error shim — unstable_catchError", () => {
// Ported from Next.js:
// packages/next/src/api/error.react-server.ts
// https://github.com/vercel/next.js/blob/v16.2.6/packages/next/src/api/error.react-server.ts
it("error.react-server throws the exact Client Components diagnostic", async () => {
const { unstable_catchError } =
await import("../packages/vinext/src/shims/error.react-server.js");
expect(() => unstable_catchError()).toThrow(
"`unstable_catchError` can only be used in Client Components.",
);
});
it("exports unstable_catchError as a function", async () => {
const mod = await import("../packages/vinext/src/shims/error.js");
expect(typeof mod.unstable_catchError).toBe("function");
});
it("returns a Component that renders children when no error occurs", async () => {
const React = (await import("react")).default;
const { renderToStaticMarkup } = await import("react-dom/server");
const { unstable_catchError } = await import("../packages/vinext/src/shims/error.js");
function Fallback(_props: { title: string }) {
return React.createElement("p", null, "should not render");
}
const Boundary = unstable_catchError<{ title: string }>(Fallback);
const html = renderToStaticMarkup(
React.createElement(
Boundary,
{ title: "ignored" },
React.createElement("span", { id: "ok" }, "hello"),
),
);
expect(html).toContain('id="ok"');
expect(html).toContain("hello");
});
it("rethrows Next.js navigation signals (redirect, notFound) instead of catching them", async () => {
const React = (await import("react")).default;
const { renderToStaticMarkup } = await import("react-dom/server");
const { unstable_catchError } = await import("../packages/vinext/src/shims/error.js");
const { redirect } = await import("../packages/vinext/src/shims/navigation.js");
function RedirectThrower(): React.ReactElement {
redirect("/login");
// Unreachable: `redirect()` throws. Annotated explicitly to satisfy
// the `ReactElement` return-type contract.
return React.createElement("span", null);
}
function Fallback() {
return React.createElement("p", null, "should not be reached");
}
const Boundary = unstable_catchError(Fallback);
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
// The boundary must let the redirect propagate up — renderToStaticMarkup
// should throw with the redirect digest.
let captured: unknown = null;
try {
renderToStaticMarkup(
React.createElement(Boundary, null, React.createElement(RedirectThrower)),
);
} catch (e) {
captured = e;
}
expect(captured).not.toBeNull();
expect((captured as { digest?: string }).digest).toContain("NEXT_REDIRECT");
} finally {
spy.mockRestore();
}
});
it("exposes the displayName matching Next.js (`unstable_catchError(...)`)", async () => {
const { unstable_catchError } = await import("../packages/vinext/src/shims/error.js");
const Fallback = function MyFallback() {
return null;
};
const Boundary = unstable_catchError(Fallback);
// Wrapper component carries the user fallback name for DevTools.
expect(Boundary.displayName).toBe("unstable_catchError(MyFallback)");
});
// Ported from Next.js:
// .nextjs-ref/test/e2e/app-dir/catch-error/catch-error.test.ts
// "should render fallback when null is thrown from a Client Component"
// "should render fallback when undefined is thrown from a Client Component"
//
// The boundary must accept null/undefined as `thrownValue` (not just Error
// instances) and route them to the fallback. Crucially, the rethrow guard
// (`isNextRouterError`) must not crash on null/undefined inputs.
it("class-component getDerivedStateFromError accepts null thrown values", async () => {
const React = (await import("react")).default;
const { renderToStaticMarkup } = await import("react-dom/server");
const { unstable_catchError } = await import("../packages/vinext/src/shims/error.js");
function Fallback() {
return null;
}
const Boundary = unstable_catchError(Fallback);
// The Boundary wrapper is a function component that calls hooks
// (usePathname, useContext). We must call it inside a React render
// so React's dispatcher is active.
let wrapperResult: React.ReactElement | null = null;
function Capture() {
wrapperResult = (Boundary as unknown as (p: Record<string, never>) => React.ReactElement)({});
return React.createElement("span");
}
renderToStaticMarkup(React.createElement(Capture));
const InnerCatchError = wrapperResult!.type as unknown as {
getDerivedStateFromError(e: unknown): { error: { thrownValue: unknown } | null };
};
const derivedNull = InnerCatchError.getDerivedStateFromError(null);
expect(derivedNull).toEqual({ error: { thrownValue: null } });
const derivedUndefined = InnerCatchError.getDerivedStateFromError(undefined);
expect(derivedUndefined).toEqual({ error: { thrownValue: undefined } });
// Exhaustive non-error primitives (strings, numbers, booleans) should
// also flow through to the fallback without throwing.
void React; // keep import for module side-effects parity with other tests
expect(InnerCatchError.getDerivedStateFromError("string")).toEqual({
error: { thrownValue: "string" },
});
expect(InnerCatchError.getDerivedStateFromError(0)).toEqual({
error: { thrownValue: 0 },
});
});
// class-component lifecycle catches non-router errors and renders the fallback
it("class-component lifecycle catches non-router errors and renders the fallback", async () => {
// React 19's renderToStaticMarkup does NOT invoke error boundaries during
// SSR — errors propagate up by design (boundaries only run during client
// commit). To validate behavior without spinning up a real browser, we
// exercise the lifecycle hooks directly: `getDerivedStateFromError` is
// the canonical predicate driving the class component's behavior, and
// its return value is the only thing the React runtime feeds into the
// next render.
const React = (await import("react")).default;
const { renderToStaticMarkup } = await import("react-dom/server");
const { unstable_catchError } = await import("../packages/vinext/src/shims/error.js");
const seenErrors: unknown[] = [];
function Fallback({
props,
errorInfo,
}: {
props: { title: string };
errorInfo: { error: unknown; reset: () => void; unstable_retry: () => void };
}) {
seenErrors.push(errorInfo.error);
const message =
errorInfo.error instanceof Error ? errorInfo.error.message : String(errorInfo.error);
return React.createElement(
"div",
null,
React.createElement("p", { id: "title" }, props.title),
React.createElement("p", { id: "msg" }, message),
);
}
// `as any` is necessary here because this test probes the inner
// `_CatchError` class component directly, where `Fallback` is used
// as a React component type (receives `{ props, errorInfo }` as a
// single props object per React.createElement semantics), matching
// the internal wrapper shape rather than the public API signature
// `(props: P, errorInfo: ErrorInfo) => React.ReactNode`.
const Boundary = unstable_catchError<{ title: string }>(Fallback as any);
// The Boundary wrapper is a function component that calls hooks.
// We must call it inside a React render so React's dispatcher is active.
let wrapperResult: React.ReactElement | null = null;
function Capture() {
wrapperResult = (
Boundary as unknown as (props: {
title: string;
children?: React.ReactNode;
}) => React.ReactElement
)({
title: "hello-title",
children: React.createElement("span", null, "child"),
});
return React.createElement("span");
}
renderToStaticMarkup(React.createElement(Capture));
const InnerCatchError = wrapperResult!.type as unknown as React.ComponentClass<{
fallback: typeof Fallback;
props: { title: string };
children?: React.ReactNode;
}> & {
getDerivedStateFromError(thrownValue: unknown): { error: { thrownValue: unknown } | null };
};
const props = {
fallback: Fallback,
props: { title: "hello-title" },
children: React.createElement("span", null, "child"),
};
// Cast through unknown to instantiate without engaging React's renderer.
const instance = new (InnerCatchError as unknown as new (p: typeof props) => InstanceType<
typeof InnerCatchError
> & {
state: { error: { thrownValue: unknown } | null };
render(): React.ReactNode;
})(props);
// Before an error: children render untouched.
expect(renderToStaticMarkup(instance.render() as React.ReactElement)).toContain("child");
// Simulate React calling getDerivedStateFromError with a thrown Error.
const thrown = new Error("boom");
const derived = InnerCatchError.getDerivedStateFromError(thrown);
expect(derived).toEqual({ error: { thrownValue: thrown } });
// Feed the derived state into the instance and render the fallback.
instance.state = derived as { error: { thrownValue: unknown } | null };
const fallbackOutput = renderToStaticMarkup(instance.render() as React.ReactElement);
expect(fallbackOutput).toContain("boom");
expect(fallbackOutput).toContain("hello-title");
expect(seenErrors[seenErrors.length - 1]).toBe(thrown);
});
it("class-component getDerivedStateFromError re-throws Next.js router errors", async () => {
const React = (await import("react")).default;
const { renderToStaticMarkup } = await import("react-dom/server");
const { unstable_catchError } = await import("../packages/vinext/src/shims/error.js");
const { redirect } = await import("../packages/vinext/src/shims/navigation.js");
function Fallback() {
return null;
}
const Boundary = unstable_catchError(Fallback);
// The Boundary wrapper is a function component that calls hooks.
// We must call it inside a React render so React's dispatcher is active.
let wrapperResult: React.ReactElement | null = null;
function Capture() {
wrapperResult = (Boundary as unknown as (p: Record<string, never>) => React.ReactElement)({});
return React.createElement("span");
}
renderToStaticMarkup(React.createElement(Capture));
const InnerCatchError = wrapperResult!.type as unknown as {
getDerivedStateFromError(e: unknown): unknown;
};
let captured: unknown = null;
try {
redirect("/login");
} catch (e) {
captured = e;
}
expect(() => InnerCatchError.getDerivedStateFromError(captured)).toThrow();
try {
InnerCatchError.getDerivedStateFromError(captured);
} catch (rethrown) {
// Identity-preserving rethrow.
expect(rethrown).toBe(captured);
}
});
it("unstable_retry on the client calls appRouterInstance.refresh and resets the boundary", async () => {
// Stub `window` with the minimum surface navigation.ts needs at
// module-load time (location, history, addEventListener). This must be
// installed BEFORE re-importing the shims, otherwise navigation.ts will
// initialize its client navigation state against a bare `{}` and crash.
// Mutable view over globalThis that allows assigning/deleting `window`.
const globalAny = globalThis as unknown as { window?: unknown };
const previousWindow = globalAny.window;
const win = {
location: {
pathname: "/",
search: "",
hash: "",
href: "http://localhost/",
origin: "http://localhost",
},
history: {
state: null,
pushState() {},
replaceState() {},
},
addEventListener() {},
dispatchEvent() {
return true;
},
removeEventListener() {},
scrollTo() {},
scrollX: 0,
scrollY: 0,
};
globalAny.window = win;
try {
vi.resetModules();
const React = (await import("react")).default;
const { renderToStaticMarkup } = await import("react-dom/server");
const { unstable_catchError } = await import("../packages/vinext/src/shims/error.js");
const refreshSpy = vi.fn();
function Fallback() {
return null;
}
const Boundary = unstable_catchError(Fallback);
// The Boundary wrapper is a function component that calls hooks.
// We must call it inside a React render so React's dispatcher is active.
let wrapperResult: React.ReactElement | null = null;
function Capture() {
wrapperResult = (Boundary as unknown as (p: Record<string, never>) => React.ReactElement)(
{},
);
return React.createElement("span");
}
renderToStaticMarkup(React.createElement(Capture));
const InnerCatchError = wrapperResult!.type as unknown as new (props: object) => {
state: { error: { thrownValue: unknown } | null };
unstable_retry: () => void;
};
const instance = new InnerCatchError({
fallback: Fallback,
props: {},
});
// Manually instantiating the class skips React's context machinery.
// Seed `this.context` with a mock App Router instance so the App Router
// branch in `unstable_retry` fires and calls `context.refresh()`.
(instance as unknown as { context: { refresh: typeof refreshSpy } }).context = {
refresh: refreshSpy,
};
// Seed an error so reset has something to clear, and replace setState
// with a spy so we can confirm the boundary self-resets.
instance.state = { error: { thrownValue: new Error("boom") } };
const setStateCalls: Array<{ error: { thrownValue: unknown } | null }> = [];
(instance as unknown as { setState: (partial: object) => void }).setState = (partial) => {
setStateCalls.push(partial as { error: { thrownValue: unknown } | null });
instance.state = { ...instance.state, ...(partial as object) } as typeof instance.state;
};
// startTransition runs synchronously here because there's no
// concurrent renderer in the test environment.
void React.startTransition;
instance.unstable_retry();
expect(refreshSpy).toHaveBeenCalledTimes(1);
expect(setStateCalls).toHaveLength(1);
expect(setStateCalls[0]).toEqual({ error: null });
} finally {
globalAny.window = previousWindow;
vi.resetModules();
}
});
// class component's `contextType`). When a non-null Pages Router instance
// is in context, `unstable_retry()` must throw the verbatim Next.js
// message instead of calling App Router's `refresh()`.
it("unstable_retry under Pages Router throws Next.js parity error message", async () => {
const React = (await import("react")).default;
const { renderToStaticMarkup } = await import("react-dom/server");
const { unstable_catchError } = await import("../packages/vinext/src/shims/error.js");
function Fallback() {
return null;
}
const Boundary = unstable_catchError(Fallback);
// The Boundary wrapper is a function component that calls hooks.
// We must call it inside a React render so React's dispatcher is active.
let wrapperResult: React.ReactElement | null = null;
function Capture() {
wrapperResult = (Boundary as unknown as (p: Record<string, never>) => React.ReactElement)({});
return React.createElement("span");
}
renderToStaticMarkup(React.createElement(Capture));
const InnerCatchError = wrapperResult!.type as unknown as new (props: object) => {
state: { error: { thrownValue: unknown } | null };
unstable_retry: () => void;
};
const instance = new InnerCatchError({
fallback: Fallback,
isPagesRouter: true,
props: {},
});
instance.state = { error: { thrownValue: new Error("boom") } };
void React; // keep React import for parity with sibling tests
expect(() => instance.unstable_retry()).toThrow(
"`unstable_retry()` can only be used in the App Router. Use `reset()` in the Pages Router.",
);
});
// Integration test: the boundary contract that useUntrackedPathname protects.
// The error boundary must clear its captured error when the pathname changes
// (navigation) and retain it when the pathname stays the same. This is the
// reason useUntrackedPathname exists: it provides a pathname that changes on
// navigation without subscribing the component to every URL change.
it("getDerivedStateFromProps clears the error when pathname changes and retains it when pathname stays the same", async () => {
const React = (await import("react")).default;
const { renderToStaticMarkup } = await import("react-dom/server");
const { unstable_catchError } = await import("../packages/vinext/src/shims/error.js");
function Fallback() {
return null;
}
const Boundary = unstable_catchError(Fallback);
// The Boundary wrapper is a function component that calls hooks.
// We must call it inside a React render so React's dispatcher is active.
let wrapperResult: React.ReactElement | null = null;
function Capture() {
wrapperResult = (Boundary as unknown as (p: Record<string, never>) => React.ReactElement)({});
return React.createElement("span");
}
renderToStaticMarkup(React.createElement(Capture));
// Structural type for the internal class component so we can call
// getDerivedStateFromProps without importing non-public exports.
const InnerCatchError = wrapperResult!.type as unknown as React.ComponentClass<{
fallback: typeof Fallback;
pathname: string | null;
props: Record<string, never>;
}> & {
getDerivedStateFromProps(
props: Pick<{ pathname: string | null }, "pathname">,
state: { error: { thrownValue: unknown } | null; previousPathname: string | null },
): { error: { thrownValue: unknown } | null; previousPathname: string | null };
};
const thrown = new Error("boom");
const initialState = {
error: { thrownValue: thrown },
previousPathname: "/initial",
};
// Same pathname: error must be retained
const samePath = InnerCatchError.getDerivedStateFromProps(
{ pathname: "/initial" },
initialState,
);
expect(samePath).toEqual({
error: { thrownValue: thrown },
previousPathname: "/initial",
});
// Different pathname: error must be cleared
const changedPath = InnerCatchError.getDerivedStateFromProps(
{ pathname: "/new" },
initialState,
);
expect(changedPath).toEqual({
error: null,
previousPathname: "/new",
});
// Null pathname to real pathname: error must be cleared.
// This matches the missing-params shell → real render transition.
const nullToReal = InnerCatchError.getDerivedStateFromProps(
{ pathname: "/real" },
{ error: { thrownValue: thrown }, previousPathname: null },
);
expect(nullToReal).toEqual({
error: null,
previousPathname: "/real",
});
});
});
// ---------------------------------------------------------------------------
// window.next debug/diagnostic global
//
// Next.js exposes a `window.next` object from both the Pages Router client
// bootstrap (packages/next/src/client/next.ts) and the App Router bootstrap
// (packages/next/src/client/app-bootstrap.ts). Pages Router test suites,
// userland code, and third-party libraries reach into it directly — most
// commonly `window.next.router.push(...)` and
// `window.next.router.events.on(...)`. Without this global, the Next.js
// deploy test suite reports ~422 console errors and 30+ runtime failures
// against vinext, with `TypeError: Cannot read properties of undefined
// (reading 'router')` the most cited.
//
// The installer helper lives in packages/vinext/src/client/window-next.ts
// and is invoked from both router bootstraps. The Pages Router shim
// (shims/router.ts) installs it as a top-level side effect, and the App
// Router browser entry (server/app-browser-entry.ts) installs it before
// hydration starts.
// ---------------------------------------------------------------------------
describe("window.next debug global", () => {
it("installWindowNext sets version, router, and appDir fields on window.next", async () => {
const previousWindow = (globalThis as any).window;
const win: any = {};
(globalThis as any).window = win;
try {
vi.resetModules();
const { installWindowNext } = await import("../packages/vinext/src/client/window-next.js");
const routerStub = {
push() {},
replace() {},
back() {},
forward() {},
refresh() {},
prefetch() {},
};
installWindowNext({ version: "test-version", appDir: true, router: routerStub });
expect(win.next).toBeDefined();
expect(win.next.version).toBe("test-version");
expect(win.next.appDir).toBe(true);
expect(win.next.router).toBe(routerStub);
} finally {
(globalThis as any).window = previousWindow;
vi.resetModules();
}
});
it("installWindowNext merges subsequent calls without clobbering existing fields", async () => {
const previousWindow = (globalThis as any).window;
const win: any = {};
(globalThis as any).window = win;
try {
vi.resetModules();
const { installWindowNext } = await import("../packages/vinext/src/client/window-next.js");
const pagesRouter = { kind: "pages" } as any;
const appRouter = { kind: "app" } as any;
installWindowNext({ version: "v1", router: pagesRouter });
installWindowNext({ appDir: true, router: appRouter });
// Whichever installer ran last (in real-world hybrid setups, App
// Router) wins for `router` — mirrors Next.js's load order where
// app-bootstrap.ts runs after next.ts.
expect(win.next.router).toBe(appRouter);
expect(win.next.appDir).toBe(true);
// Fields not overridden are preserved.
expect(win.next.version).toBe("v1");
} finally {
(globalThis as any).window = previousWindow;
vi.resetModules();
}
});
it("setWindowNextInternalSourcePage mirrors the App Router source-page field", async () => {
// Ported from Next.js: test/e2e/app-dir/app/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app/index.test.ts
const previousWindow = (globalThis as any).window;
const win: any = {};
(globalThis as any).window = win;
try {
vi.resetModules();
const { setWindowNextInternalSourcePage } =
await import("../packages/vinext/src/client/window-next.js");
setWindowNextInternalSourcePage("/dashboard/page");
expect(win.next.__internal_src_page).toBe("/dashboard/page");
setWindowNextInternalSourcePage("/dynamic/[category]/[id]/page");
expect(win.next.__internal_src_page).toBe("/dynamic/[category]/[id]/page");
setWindowNextInternalSourcePage(null);
expect(win.next.__internal_src_page).toBeUndefined();
} finally {
(globalThis as any).window = previousWindow;
vi.resetModules();
}
});
it("Pages Router shim installs window.next.router with the expected NextRouter surface", async () => {
// Build a minimal fake window so importing shims/router.ts (which
// touches window at module load to attach popstate) does not crash.
const previousWindow = (globalThis as any).window;
const win: any = {
location: { pathname: "/", search: "", hash: "", href: "http://localhost/" },
history: { state: null, pushState() {}, replaceState() {} },
addEventListener() {},
};
(globalThis as any).window = win;
try {
vi.resetModules();
// Side-effecting import: installs window.next.router at module load.
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
expect(win.next).toBeDefined();
expect(win.next.router).toBe(Router);
// Ported from Next.js: NextRouter type in
// packages/next/src/shared/lib/router/router.ts (line 372).
const router = win.next.router;
expect(typeof router.push).toBe("function");
expect(typeof router.replace).toBe("function");
expect(typeof router.back).toBe("function");
expect(typeof router.reload).toBe("function");
expect(typeof router.prefetch).toBe("function");
expect(typeof router.beforePopState).toBe("function");
expect(router.events).toBeDefined();
expect(typeof router.events.on).toBe("function");
expect(typeof router.events.off).toBe("function");
expect(typeof router.events.emit).toBe("function");
// State fields — Next.js's singletonRouter exposes pathname/route/query/
// asPath/basePath/locale*/isReady/isPreview/isFallback as live getters
// off `window.next.router` (urlPropertyFields in
// .nextjs-ref/packages/next/src/client/router.ts lines 32-47). The
// Next.js deploy suite drives navigations via
// `browser.eval('next.router.push(...)')` and then asserts on
// `browser.eval('next.router.pathname')`, so these must read like data
// properties, not raise.
expect(typeof router.pathname).toBe("string");
expect(typeof router.asPath).toBe("string");
expect(typeof router.query).toBe("object");
expect(typeof router.basePath).toBe("string");
expect(typeof router.isReady).toBe("boolean");
expect(typeof router.isPreview).toBe("boolean");
expect(typeof router.isFallback).toBe("boolean");
expect(typeof router.route).toBe("string");
} finally {
(globalThis as any).window = previousWindow;
vi.resetModules();
}
});
// Ported from Next.js: tests that rely on `window.next.router.events.on(...)`
// — e.g. test/development/pages-dir/client-navigation/index.test.ts:457
// (`window.next.router.events.on('routeChangeError', ...)`).
it("window.next.router.events forwards Pages Router events", async () => {
const previousWindow = (globalThis as any).window;
const win: any = {
location: { pathname: "/", search: "", hash: "", href: "http://localhost/" },
history: { state: null, pushState() {}, replaceState() {} },
addEventListener() {},
};
(globalThis as any).window = win;
try {
vi.resetModules();
await import("../packages/vinext/src/shims/router.js");
const fired: unknown[] = [];
win.next.router.events.on("routeChangeStart", (url: unknown) => {
fired.push(url);
});
win.next.router.events.emit("routeChangeStart", "/next-page");
expect(fired).toEqual(["/next-page"]);
} finally {
(globalThis as any).window = previousWindow;
vi.resetModules();
}
});
// Issue #1467 — `window.next.router.push(...)` must resolve to a boolean
// (true on success, false if blocked), matching Next.js's contract in
// `packages/next/src/shared/lib/router/router.ts:1025-1048` where push/replace
// delegate to `change()` which returns `Promise<boolean>`. The Next.js deploy
// test suite (prerender, use-router-with-rewrites, etc.) reads the resolved
// value via `browser.eval('await window.next.router.push("/foo")')` and
// asserts truthiness; resolving to `undefined` is observable as a regression
// even when the navigation otherwise succeeds.
it("window.next.router.push resolves to true on shallow success", async () => {
const previousWindow = (globalThis as any).window;
const win: any = {
location: {
pathname: "/",
search: "",
hash: "",
href: "http://localhost/",
origin: "http://localhost",
},
history: { state: null, pushState() {}, replaceState() {} },
addEventListener() {},
dispatchEvent() {},
scrollTo() {},
};
(globalThis as any).window = win;
try {
vi.resetModules();
await import("../packages/vinext/src/shims/router.js");
// Shallow push avoids the HTML fetch + render path so this unit test
// does not need a navigateClient stub. The boolean-return contract
// applies equally to shallow and non-shallow navigations.
const result = await win.next.router.push("/foo", undefined, { shallow: true });
expect(result).toBe(true);
} finally {
(globalThis as any).window = previousWindow;
vi.resetModules();
}
});
it("window.next.router.replace resolves to true on shallow success", async () => {
const previousWindow = (globalThis as any).window;
const win: any = {
location: {
pathname: "/",
search: "",
hash: "",
href: "http://localhost/",
origin: "http://localhost",
},
history: { state: null, pushState() {}, replaceState() {} },
addEventListener() {},
dispatchEvent() {},
scrollTo() {},
};
(globalThis as any).window = win;
try {
vi.resetModules();
await import("../packages/vinext/src/shims/router.js");
const result = await win.next.router.replace("/foo", undefined, { shallow: true });
expect(result).toBe(true);
} finally {
(globalThis as any).window = previousWindow;
vi.resetModules();
}
});
// Shallow navigations skip the render-commit path, so the scroll reset is
// applied synchronously in performNavigation — before routeChangeComplete.
// This matches the non-shallow path, where the x/y reset runs inside the
// render-commit callback (also ahead of routeChangeComplete). Pins the
// ordering so listeners observe the final scroll position.
it("shallow push applies the scroll reset before routeChangeComplete", async () => {
const previousWindow = (globalThis as any).window;
const order: string[] = [];
const win: any = {
location: {
pathname: "/",
search: "",
hash: "",
href: "http://localhost/",
origin: "http://localhost",
},
history: { state: null, pushState() {}, replaceState() {} },
addEventListener() {},
dispatchEvent() {},
scrollTo(x: number, y: number) {
order.push(`scrollTo:${x},${y}`);
},
};
(globalThis as any).window = win;
try {
vi.resetModules();
await import("../packages/vinext/src/shims/router.js");
win.next.router.events.on("routeChangeComplete", () => {
order.push("routeChangeComplete");
});
const result = await win.next.router.push("/foo", undefined, { shallow: true });
expect(result).toBe(true);
expect(order).toEqual(["scrollTo:0,0", "routeChangeComplete"]);
} finally {
(globalThis as any).window = previousWindow;
vi.resetModules();
}
});
it("query-only UrlObjects preserve the current visible pathname", async () => {
const previousWindow = (globalThis as any).window;
const pushState = vi.fn();
const win: any = {
location: {
pathname: "/rewrite-navigation/0",
search: "",
hash: "",
href: "http://localhost/rewrite-navigation/0",
origin: "http://localhost",
},
history: { state: null, pushState, replaceState() {} },
addEventListener() {},
dispatchEvent() {},
scrollTo() {},
};
(globalThis as any).window = win;
try {
vi.resetModules();
const routerModule = await import("../packages/vinext/src/shims/router.js");
const result = await routerModule.default.push({ query: { id: "1" } }, undefined, {
shallow: true,
});
expect(result).toBe(true);
expect(pushState).toHaveBeenCalledWith(
expect.objectContaining({
url: "/rewrite-navigation/0?id=1",
as: "/rewrite-navigation/0?id=1",
}),
"",
"/rewrite-navigation/0?id=1",
);
} finally {
(globalThis as any).window = previousWindow;
vi.resetModules();
}
});
it.each([
[{}, "/rewrite-navigation/[id]/destination", "/rewrite-navigation/[id]/destination"],
[{ query: {} }, "/rewrite-navigation/[id]/destination", "/rewrite-navigation/[id]/destination"],
[{ search: "flag" }, "/rewrite-navigation/0?flag", "/rewrite-navigation/0?flag"],
[{ search: "q=a%20b" }, "/rewrite-navigation/0?q=a%20b", "/rewrite-navigation/0?q=a%20b"],
[{ search: "q=a#b" }, "/rewrite-navigation/0?q=a%23b", "/rewrite-navigation/0?q=a%23b"],
[{ search: "q=a#b#c" }, "/rewrite-navigation/0?q=a%23b#c", "/rewrite-navigation/0?q=a%23b"],
[{ search: "?" }, "/rewrite-navigation/0?", "/rewrite-navigation/0?"],
[
{ query: { id: "ignored" }, search: "id=3" },
"/rewrite-navigation/0?id=3",
"/rewrite-navigation/0?id=3",
],
[
{ search: "q=a%20b", hash: "result" },
"/rewrite-navigation/0?q=a%20b#result",
"/rewrite-navigation/0?q=a%20b",
],
[
{ search: "q=a#b", hash: "result" },
"/rewrite-navigation/0?q=a%23b#result",
"/rewrite-navigation/0?q=a%23b",
],
[
{ query: { id: "ignored" }, search: "?id=3", hash: "#result" },
"/rewrite-navigation/0?id=3#result",
"/rewrite-navigation/0?id=3",
],
[
{ hash: "section" },
"/rewrite-navigation/0?existing=1#section",
"/rewrite-navigation/0?existing=1",
],
])(
"formats Pages Router UrlObjects with Next.js semantics: %j",
async (url, expected, stateAs) => {
const previousWindow = (globalThis as any).window;
const previousDocument = (globalThis as any).document;
const pushState = vi.fn();
const win: any = {
location: {
pathname: "/rewrite-navigation/0",
search: "?existing=1",
hash: "",
href: "http://localhost/rewrite-navigation/0?existing=1",
origin: "http://localhost",
},
history: { state: null, pushState, replaceState() {} },
addEventListener() {},
dispatchEvent() {},
scrollTo() {},
__NEXT_DATA__: {
page: "/rewrite-navigation/[id]/destination",
query: { id: "0" },
isFallback: false,
},
};
(globalThis as any).window = win;
(globalThis as any).document = {
getElementById: vi.fn(() => null),
getElementsByName: vi.fn(() => []),
};
try {
vi.resetModules();
const routerModule = await import("../packages/vinext/src/shims/router.js");
await routerModule.default.push(url, undefined, { shallow: true });
expect(pushState).toHaveBeenCalledWith(
expect.objectContaining({ as: stateAs }),
"",
expected,
);
} finally {
(globalThis as any).window = previousWindow;
if (previousDocument === undefined) delete (globalThis as any).document;
else (globalThis as any).document = previousDocument;
vi.resetModules();
}
},
);
it.each(["push", "replace"] as const)(
"preserves a bare trailing question mark in router.asPath after %s",
async (method) => {
const previousWindow = (globalThis as any).window;
const win: any = {
location: {
pathname: "/rewrite-navigation/0",
search: "",
hash: "",
href: "http://localhost/rewrite-navigation/0",
origin: "http://localhost",
},
history: {
state: null as unknown,
pushState(state: unknown, _title: string, url: string) {
this.state = state;
const nextUrl = new URL(url, win.location.href);
win.location.pathname = nextUrl.pathname;
win.location.search = nextUrl.search;
win.location.hash = nextUrl.hash;
win.location.href = nextUrl.href;
},
replaceState(state: unknown, _title: string, url?: string) {
this.state = state;
if (url === undefined) return;
const nextUrl = new URL(url, win.location.href);
win.location.pathname = nextUrl.pathname;
win.location.search = nextUrl.search;
win.location.hash = nextUrl.hash;
win.location.href = nextUrl.href;
},
},
addEventListener() {},
dispatchEvent() {},
scrollTo() {},
scrollX: 0,
scrollY: 0,
__NEXT_DATA__: {
page: "/rewrite-navigation/[id]/destination",
query: { id: "0" },
isFallback: false,
},
};
(globalThis as any).window = win;
try {
vi.resetModules();
const routerModule = await import("../packages/vinext/src/shims/router.js");
await routerModule.default[method]({ search: "?" }, undefined, { shallow: true });
expect(win.location.search).toBe("");
expect(win.history.state.as).toBe("/rewrite-navigation/0?");
expect(routerModule.default.asPath).toBe("/rewrite-navigation/0?");
} finally {
if (previousWindow === undefined) delete (globalThis as any).window;
else (globalThis as any).window = previousWindow;
vi.resetModules();
}
},
);
it.each([
["/", "", "/#section"],
["/posts", "?tab=1", "/posts?tab=1#section"],
])(
"preserves the visible path and search in router.asPath after hash-only navigation from %s%s",
async (pathname, search, expectedAsPath) => {
const previousWindow = (globalThis as any).window;
const win: any = {
location: {
pathname,
search,
hash: "",
href: `http://localhost${pathname}${search}`,
origin: "http://localhost",
},
history: {
state: null as unknown,
pushState(state: unknown, _title: string, url: string) {
this.state = state;
const nextUrl = new URL(url, win.location.href);
win.location.pathname = nextUrl.pathname;
win.location.search = nextUrl.search;
win.location.hash = nextUrl.hash;
win.location.href = nextUrl.href;
},
replaceState() {},
},
addEventListener() {},
dispatchEvent() {},
scrollTo() {},
scrollX: 0,
scrollY: 0,
__NEXT_DATA__: { page: pathname, query: {}, isFallback: false },
};
(globalThis as any).window = win;
(globalThis as any).document = {
getElementById: vi.fn(() => null),
getElementsByName: vi.fn(() => []),
};
try {
vi.resetModules();
const routerModule = await import("../packages/vinext/src/shims/router.js");
await routerModule.default.push("#section");
expect(win.history.state.as).toBe("");
expect(routerModule.default.asPath).toBe(expectedAsPath);
} finally {
if (previousWindow === undefined) delete (globalThis as any).window;
else (globalThis as any).window = previousWindow;
delete (globalThis as any).document;
vi.resetModules();
}
},
);
it("query-only push replaces the current locale under basePath", async () => {
const previousWindow = (globalThis as any).window;
const previousBasePath = process.env.__NEXT_ROUTER_BASEPATH;
const pushState = vi.fn();
process.env.__NEXT_ROUTER_BASEPATH = "/docs";
const win: any = {
location: {
pathname: "/docs/fr/rewrite-navigation/0",
search: "",
hash: "",
href: "http://localhost/docs/fr/rewrite-navigation/0",
origin: "http://localhost",
hostname: "localhost",
},
history: { state: null, pushState, replaceState() {} },
addEventListener() {},
dispatchEvent() {},
scrollTo() {},
__VINEXT_LOCALE__: "fr",
__VINEXT_LOCALES__: ["en", "fr", "nl"],
__VINEXT_DEFAULT_LOCALE__: "en",
};
(globalThis as any).window = win;
try {
vi.resetModules();
const routerModule = await import("../packages/vinext/src/shims/router.js");
await routerModule.default.push({ query: { id: "1" } }, undefined, {
locale: "nl",
shallow: true,
});
expect(pushState).toHaveBeenCalledWith(
expect.objectContaining({ as: "/nl/rewrite-navigation/0?id=1" }),
"",
"/docs/nl/rewrite-navigation/0?id=1",
);
} finally {
if (previousBasePath === undefined) delete process.env.__NEXT_ROUTER_BASEPATH;
else process.env.__NEXT_ROUTER_BASEPATH = previousBasePath;
(globalThis as any).window = previousWindow;
vi.resetModules();
}
});
it("query-only replace with locale false removes the current locale under basePath", async () => {
const previousWindow = (globalThis as any).window;
const previousBasePath = process.env.__NEXT_ROUTER_BASEPATH;
const replaceState = vi.fn();
process.env.__NEXT_ROUTER_BASEPATH = "/docs";
const win: any = {
location: {
pathname: "/docs/fr/rewrite-navigation/0",
search: "",
hash: "",
href: "http://localhost/docs/fr/rewrite-navigation/0",
origin: "http://localhost",
hostname: "localhost",
},
history: { state: null, pushState() {}, replaceState },
addEventListener() {},
dispatchEvent() {},
scrollTo() {},
__VINEXT_LOCALE__: "fr",
__VINEXT_LOCALES__: ["en", "fr"],
__VINEXT_DEFAULT_LOCALE__: "en",
};
(globalThis as any).window = win;
try {
vi.resetModules();
const routerModule = await import("../packages/vinext/src/shims/router.js");
await routerModule.default.replace({ search: "id=2" }, undefined, {
locale: false,
shallow: true,
});
expect(replaceState).toHaveBeenCalledWith(
expect.objectContaining({ as: "/rewrite-navigation/0?id=2" }),
"",
"/docs/rewrite-navigation/0?id=2",
);
} finally {
if (previousBasePath === undefined) delete process.env.__NEXT_ROUTER_BASEPATH;
else process.env.__NEXT_ROUTER_BASEPATH = previousBasePath;
(globalThis as any).window = previousWindow;
vi.resetModules();
}
});
// Issue #1522 — shallow `Router.push(url, as, { shallow: true })` must
// update history to `as` (the visible URL) without fetching the server,
// even when `as` matches a server-side redirect rule (e.g. `/redirect-1`
// configured in `next.config.js#redirects`). This mirrors the Next.js
// bloom-filter "client router filter" bypass for shallow updates.
//
// Ported from Next.js: test/e2e/app-dir/app/index.test.ts
// "should not apply client router filter on shallow"
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app/index.test.ts
it("Router.push with shallow=true updates URL to `as` and skips fetch for redirect-matching paths", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const pushState = vi.fn();
const fetchSpy = vi.fn(async () => {
throw new Error("shallow Router.push must not fetch the page HTML");
});
const win: any = {
location: {
pathname: "/",
search: "",
hash: "",
href: "http://localhost/",
origin: "http://localhost",
hostname: "localhost",
assign: vi.fn(),
replace: vi.fn(),
reload: vi.fn(),
},
history: { state: null, pushState, replaceState() {} },
addEventListener() {},
dispatchEvent() {},
scrollTo() {},
__NEXT_DATA__: { page: "/", query: {}, isFallback: false },
};
(globalThis as any).window = win;
globalThis.fetch = fetchSpy as any;
try {
vi.resetModules();
const routerModule = await import("../packages/vinext/src/shims/router.js");
// Mirror the failing Next.js call exactly:
// window.next.router.push('/', '/redirect-1', { shallow: true })
const result = await routerModule.default.push("/", "/redirect-1", { shallow: true });
expect(result).toBe(true);
expect(fetchSpy).not.toHaveBeenCalled();
// The visible URL is `as` (/redirect-1), even though the underlying
// route is `url` (/). The server's redirect rule for /redirect-1
// must not fire on a shallow update. History state now follows the
// Next.js shape (`{ url, as, options, __N, key }`) instead of `{}`.
expect(pushState).toHaveBeenCalledWith(
expect.objectContaining({ __N: true }),
"",
"/redirect-1",
);
} finally {
(globalThis as any).window = previousWindow;
globalThis.fetch = originalFetch;
vi.resetModules();
}
});
it("masked same-path pushes with a hash replace the current history entry", async () => {
const previousWindow = (globalThis as any).window;
const previousDocument = (globalThis as any).document;
const originalFetch = globalThis.fetch;
const pushState = vi.fn();
const replaceState = vi.fn();
const fetchSpy = vi.fn(async () => {
throw new Error("shallow Router.push must not fetch the page HTML");
});
const win: any = {
location: {
pathname: "/hello",
search: "",
hash: "",
href: "http://localhost/hello",
origin: "http://localhost",
hostname: "localhost",
assign: vi.fn(),
replace: vi.fn(),
reload: vi.fn(),
},
history: { state: null, pushState, replaceState },
addEventListener() {},
dispatchEvent() {},
scrollTo() {},
__NEXT_DATA__: { page: "/hello", query: {}, isFallback: false },
};
(globalThis as any).window = win;
(globalThis as any).document = {
getElementById: vi.fn(() => null),
getElementsByName: vi.fn(() => []),
};
globalThis.fetch = fetchSpy as any;
try {
vi.resetModules();
const routerModule = await import("../packages/vinext/src/shims/router.js");
const result = await routerModule.default.push("/something-else", "/hello#section", {
shallow: true,
});
expect(result).toBe(true);
expect(fetchSpy).not.toHaveBeenCalled();
expect(pushState).not.toHaveBeenCalled();
expect(replaceState).toHaveBeenCalledWith(
expect.objectContaining({
__N: true,
url: "/something-else",
as: "/hello",
}),
"",
"/hello#section",
);
} finally {
(globalThis as any).window = previousWindow;
if (previousDocument === undefined) delete (globalThis as any).document;
else (globalThis as any).document = previousDocument;
globalThis.fetch = originalFetch;
vi.resetModules();
}
});
it("appRouterInstance exported from the navigation shim has the public router surface", async () => {
vi.resetModules();
const { appRouterInstance } = await import("../packages/vinext/src/shims/navigation.js");
// Ported from Next.js: publicAppRouterInstance shape in
// packages/next/src/client/components/app-router-instance.ts (line 392).
expect(typeof appRouterInstance.push).toBe("function");
expect(typeof appRouterInstance.replace).toBe("function");
expect(typeof appRouterInstance.back).toBe("function");
expect(typeof appRouterInstance.forward).toBe("function");
expect(typeof appRouterInstance.refresh).toBe("function");
expect(typeof appRouterInstance.prefetch).toBe("function");
expect(appRouterInstance.bfcacheId).toBe("0");
});
it("installWindowNext is a no-op on the server (typeof window === 'undefined')", async () => {
const previousWindow = (globalThis as any).window;
delete (globalThis as any).window;
try {
vi.resetModules();
const { installWindowNext } = await import("../packages/vinext/src/client/window-next.js");
// Does not throw and does not attempt to create a global. We cannot
// observe a non-existent window, so the assertion is structural: the
// call returns without error and there is no global to inspect.
expect(() => installWindowNext({ version: "x", appDir: true })).not.toThrow();
} finally {
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
vi.resetModules();
}
});
});
// ---------------------------------------------------------------------------
// next/router withRouter HOC
//
// withRouter is a higher-order component that injects the Pages Router
// `router` instance as a prop into class components (which cannot call
// hooks). Real Next.js apps and the Next.js deploy test suite depend on
// this export — without it, bundlers fail with
// `[MISSING_EXPORT] "withRouter" is not exported by ".../shims/router.js"`.
//
// Ported from Next.js: packages/next/src/client/with-router.tsx
// https://github.com/vercel/next.js/blob/canary/packages/next/src/client/with-router.tsx
//
// Reference Next.js e2e fixture (class component using withRouter):
// .nextjs-ref/test/e2e/with-router/pages/a.js
// ---------------------------------------------------------------------------
describe("next/router withRouter HOC", () => {
let previousWindow: unknown;
function createTestRouter(overrides: Partial<NextRouter> = {}): NextRouter {
const router: NextRouter = {
pathname: "/provided",
route: "/provided",
query: {},
asPath: "/provided",
basePath: "",
isReady: true,
isPreview: false,
isFallback: false,
push: vi.fn(async () => true),
replace: vi.fn(async () => true),
back: vi.fn(),
reload: vi.fn(),
prefetch: vi.fn(async () => {}),
beforePopState: vi.fn(),
events: {
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
},
};
return { ...router, ...overrides };
}
beforeEach(() => {
previousWindow = (globalThis as any).window;
(globalThis as any).window = {
location: { pathname: "/", search: "", hash: "", href: "http://localhost/" },
history: { state: null, pushState() {}, replaceState() {} },
addEventListener() {},
};
vi.resetModules();
});
afterEach(() => {
(globalThis as any).window = previousWindow;
vi.resetModules();
});
it("exports withRouter as a named function", async () => {
const { withRouter } = await import("../packages/vinext/src/shims/router.js");
expect(typeof withRouter).toBe("function");
});
it("next/router useRouter reads the mounted RouterContext value", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const { useRouter } = await import("../packages/vinext/src/shims/router.js");
const { RouterContext } =
await import("../packages/vinext/src/shims/internal/router-context.js");
const providedRouter = createTestRouter({ pathname: "/from-context" });
let captured: NextRouter | null = null;
function Probe() {
captured = useRouter();
return React.createElement("span", null, "ok");
}
renderToStaticMarkup(
React.createElement(
RouterContext.Provider,
{ value: providedRouter },
React.createElement(Probe),
),
);
expect(captured).toBe(providedRouter);
});
it("next/router useRouter throws when the Pages Router context is not mounted", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const { useRouter } = await import("../packages/vinext/src/shims/router.js");
function Probe() {
useRouter();
return React.createElement("span", null, "ok");
}
expect(() => renderToStaticMarkup(React.createElement(Probe))).toThrow(
"NextRouter was not mounted",
);
});
it("next/router useRouter falls back to the singleton in a Pages Router browser document", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const { useRouter } = await import("../packages/vinext/src/shims/router.js");
const win = (globalThis as { window: Window }).window;
const previousPageLoaders = win.__VINEXT_PAGE_LOADERS__;
try {
win.__VINEXT_PAGE_LOADERS__ = {};
const captured: NextRouter[] = [];
function Probe() {
captured.push(useRouter());
return React.createElement("span", null, "ok");
}
renderToStaticMarkup(React.createElement(Probe));
const router = captured[0];
expect(router).toBeDefined();
expect(router?.events).toBeDefined();
expect(typeof router?.push).toBe("function");
} finally {
win.__VINEXT_PAGE_LOADERS__ = previousPageLoaders;
}
});
it("next/router useRouter does not subscribe once per hook call", async () => {
const previousWindowForMock = (globalThis as any).window;
const addEventListener = vi.fn();
const providedRouter = createTestRouter();
(globalThis as any).window = {
location: { pathname: "/", search: "", hash: "", href: "http://localhost/" },
history: { state: null, pushState() {}, replaceState() {} },
addEventListener,
removeEventListener: vi.fn(),
__NEXT_DATA__: { page: "/", query: {}, isFallback: false },
};
vi.resetModules();
vi.doMock("react", () => {
const react = {
Component: class {},
createContext(defaultValue: unknown) {
return { Provider: "Provider", Consumer: "Consumer", defaultValue };
},
createElement(type: unknown, props: unknown, ...children: unknown[]) {
return { type, props, children };
},
useContext() {
return providedRouter;
},
useState(initialValue: unknown) {
return [typeof initialValue === "function" ? initialValue() : initialValue, vi.fn()];
},
useEffect(effect: () => void | (() => void)) {
effect();
},
useLayoutEffect(effect: () => void | (() => void)) {
effect();
},
useMemo(factory: () => unknown) {
return factory();
},
};
return { ...react, default: react };
});
try {
const { useRouter } = await import("../packages/vinext/src/shims/router.js");
expect(useRouter()).toBe(providedRouter);
expect(useRouter()).toBe(providedRouter);
expect(useRouter()).toBe(providedRouter);
const navigateListenerCalls = addEventListener.mock.calls.filter(
(call) => call[0] === "vinext:navigate",
);
expect(navigateListenerCalls).toHaveLength(0);
} finally {
vi.doUnmock("react");
if (previousWindowForMock === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindowForMock;
}
vi.resetModules();
}
});
it("withRouter wraps a component and forwards static props", async () => {
const { withRouter } = await import("../packages/vinext/src/shims/router.js");
const React = await import("react");
const Inner: React.ComponentType<{ router: any; label: string }> = ({ label }) =>
React.createElement("span", null, label);
const Wrapped = withRouter(Inner);
expect(typeof Wrapped).toBe("function");
// displayName is set in dev to `withRouter(<Inner>)`.
if (process.env.NODE_ENV !== "production") {
expect(Wrapped.displayName).toContain("withRouter");
}
});
it("withRouter injects a router prop into the wrapped component", async () => {
const { withRouter, wrapWithRouterContext } =
await import("../packages/vinext/src/shims/router.js");
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
let receivedRouter: any = null;
let receivedLabel: string | undefined;
const Inner: React.ComponentType<{ router: any; label: string }> = (props) => {
receivedRouter = props.router;
receivedLabel = props.label;
return React.createElement("span", null, "ok");
};
const Wrapped = withRouter(Inner);
const html = renderToStaticMarkup(
wrapWithRouterContext(React.createElement(Wrapped, { label: "hi" })),
);
expect(html).toBe("<span>ok</span>");
expect(receivedLabel).toBe("hi");
// router must be the NextRouter shape (push/replace/back/...).
expect(receivedRouter).toBeTruthy();
expect(typeof receivedRouter.push).toBe("function");
expect(typeof receivedRouter.replace).toBe("function");
expect(typeof receivedRouter.back).toBe("function");
expect(typeof receivedRouter.reload).toBe("function");
expect(typeof receivedRouter.prefetch).toBe("function");
expect(receivedRouter.events).toBeDefined();
});
// Regression guard for Next.js spread order:
// packages/next/src/client/with-router.tsx renders
// `<ComposedComponent router={useRouter()} {...props} />`
// — the injected `router` is placed FIRST and `{...props}` spread after,
// so a user-passed `router` prop overrides the HOC-injected one. If the
// spread order is ever inverted in the shim, this test fails.
it("user-passed router prop overrides the HOC-injected router (Next.js spread order)", async () => {
const { withRouter, wrapWithRouterContext } =
await import("../packages/vinext/src/shims/router.js");
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
let receivedRouter: any = null;
const Inner: React.ComponentType<{ router: any }> = (props) => {
receivedRouter = props.router;
return React.createElement("span", null, "ok");
};
const Wrapped = withRouter(Inner);
const userRouter = { sentinel: "user-provided" };
const WrappedWithOverride = Wrapped as React.ComponentType<{ router: unknown }>;
renderToStaticMarkup(
wrapWithRouterContext(React.createElement(WrappedWithOverride, { router: userRouter })),
);
// Last spread wins: the user-passed router survives.
expect(receivedRouter).toBe(userRouter);
});
it("withRouter forwards getInitialProps from the composed component", async () => {
const { withRouter } = await import("../packages/vinext/src/shims/router.js");
type InnerType = ((props: { router: any }) => null) & {
getInitialProps?: () => unknown;
origGetInitialProps?: () => unknown;
};
const Inner: InnerType = (() => null) as InnerType;
const gip = () => ({ foo: "bar" });
Inner.getInitialProps = gip;
Inner.origGetInitialProps = gip;
const Wrapped = withRouter(Inner) as typeof Inner;
expect(Wrapped.getInitialProps).toBe(gip);
expect(Wrapped.origGetInitialProps).toBe(gip);
});
});
describe("next/headers shim", () => {
it("exports cookies, headers, draftMode", async () => {
const mod = await import("../packages/vinext/src/shims/headers.js");
expect(typeof mod.cookies).toBe("function");
expect(typeof mod.headers).toBe("function");
expect(typeof mod.draftMode).toBe("function");
});
it("headers() returns request headers from context", async () => {
const { setHeadersContext, headers } = await import("../packages/vinext/src/shims/headers.js");
const reqHeaders = new Headers({ "x-custom": "test-value" });
setHeadersContext({
headers: reqHeaders,
cookies: new Map(),
});
const h = await headers();
expect(h.get("x-custom")).toBe("test-value");
setHeadersContext(null);
});
it("headers() strips internal flight and dev request-id headers from userland reads", async () => {
// Ported from Next.js:
// packages/next/src/server/async-storage/request-store.ts
// https://github.com/vercel/next.js/blob/bba8fe18eb21d49dac68cdddef64e8906dc9f804/packages/next/src/server/async-storage/request-store.ts
const { setHeadersContext, headers } = await import("../packages/vinext/src/shims/headers.js");
const reqHeaders = new Headers({
rsc: "1",
"Next-Router-State-Tree": "tree",
"x-nextjs-request-id": "dev-request",
"x-nextjs-html-request-id": "dev-html-request",
"x-custom": "test-value",
});
setHeadersContext({
headers: reqHeaders,
cookies: new Map(),
});
const h = await headers();
expect(h.get("x-custom")).toBe("test-value");
expect(h.get("rsc")).toBeNull();
expect(h.get("Next-Router-State-Tree")).toBeNull();
expect(h.get("x-nextjs-request-id")).toBeNull();
expect(h.get("x-nextjs-html-request-id")).toBeNull();
expect(reqHeaders.get("x-nextjs-request-id")).toBe("dev-request");
expect(reqHeaders.get("x-nextjs-html-request-id")).toBe("dev-html-request");
setHeadersContext(null);
});
it("headers() supports the legacy sync access pattern", async () => {
// Next.js docs: headers() temporarily supports sync property access in v15.
const { setHeadersContext, headers } = await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers({ "x-sync-header": "sync-value" }),
cookies: new Map(),
});
const headerStore = headers();
expect(typeof headerStore.get).toBe("function");
expect(headerStore.get("x-sync-header")).toBe("sync-value");
const awaited = await headerStore;
expect(awaited.get("x-sync-header")).toBe("sync-value");
setHeadersContext(null);
});
it("headers() and cookies() reuse request API promise identity within a request context", async () => {
// Next.js caches request API promises by the underlying request object:
// packages/next/src/server/request/headers.ts
// packages/next/src/server/request/cookies.ts
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/request/headers.ts
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/request/cookies.ts
const { setHeadersAccessPhase, setHeadersContext, headers, cookies } =
await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers({ "x-custom": "test-value" }),
cookies: new Map([["session", "abc123"]]),
});
try {
const firstHeaders = headers();
const secondHeaders = headers();
expect(secondHeaders).toBe(firstHeaders);
expect(await secondHeaders).toBe(await firstHeaders);
const firstReadonlyCookies = cookies();
const secondReadonlyCookies = cookies();
expect(secondReadonlyCookies).toBe(firstReadonlyCookies);
expect(await secondReadonlyCookies).toBe(await firstReadonlyCookies);
setHeadersAccessPhase("route-handler");
const firstMutableCookies = cookies();
const secondMutableCookies = cookies();
expect(secondMutableCookies).toBe(firstMutableCookies);
expect(await secondMutableCookies).toBe(await firstMutableCookies);
expect(firstMutableCookies).not.toBe(firstReadonlyCookies);
} finally {
setHeadersContext(null);
}
});
it("headers() is read-only for both sync and awaited access", async () => {
// Ported from Next.js:
// packages/next/src/server/web/spec-extension/adapters/headers.test.ts
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/web/spec-extension/adapters/headers.test.ts
const { setHeadersContext, headers } = await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers({ foo: "original" }),
cookies: new Map(),
});
const syncHeaders = headers();
expect(() => Reflect.get(syncHeaders, "set")).toThrow(/Headers cannot be modified/);
expect(() => Reflect.get(syncHeaders, "append")).toThrow(/Headers cannot be modified/);
expect(() => Reflect.get(syncHeaders, "delete")).toThrow(/Headers cannot be modified/);
expect(() => syncHeaders.set("foo", "mutated")).toThrow(/Headers cannot be modified/);
expect(() => syncHeaders.append("foo", "mutated")).toThrow(/Headers cannot be modified/);
expect(() => syncHeaders.delete("foo")).toThrow(/Headers cannot be modified/);
expect(syncHeaders.get("foo")).toBe("original");
const awaitedHeaders = await headers();
expect(() => Reflect.get(awaitedHeaders, "set")).toThrow(/Headers cannot be modified/);
expect(() => awaitedHeaders.set("foo", "mutated")).toThrow(/Headers cannot be modified/);
expect(awaitedHeaders.get("foo")).toBe("original");
expect((await headers()).get("foo")).toBe("original");
setHeadersContext(null);
});
it("cookies() returns parsed cookies from context", async () => {
const { setHeadersContext, cookies } = await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers(),
cookies: new Map([
["session", "abc123"],
["theme", "dark"],
]),
});
const c = await cookies();
expect(c.get("session")).toEqual({ name: "session", value: "abc123" });
expect(c.get("theme")).toEqual({ name: "theme", value: "dark" });
expect(c.has("session")).toBe(true);
expect(c.has("missing")).toBe(false);
expect(c.size).toBe(2);
setHeadersContext(null);
});
it("cookies() supports the legacy sync access pattern", async () => {
// Next.js docs: cookies() temporarily supports sync property access in v15.
const { setHeadersContext, cookies } = await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers(),
cookies: new Map([["session", "sync-cookie"]]),
});
const cookieStore = cookies();
expect(typeof cookieStore.get).toBe("function");
expect(cookieStore.get("session")).toEqual({ name: "session", value: "sync-cookie" });
const awaited = await cookieStore;
expect(awaited.get("session")).toEqual({ name: "session", value: "sync-cookie" });
setHeadersContext(null);
});
it("cookies() is read-only during render for both sync and awaited access", async () => {
// Ported from Next.js:
// packages/next/src/server/web/spec-extension/adapters/request-cookies.test.ts
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/web/spec-extension/adapters/request-cookies.test.ts
const { setHeadersContext, cookies, getAndClearPendingCookies } =
await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers(),
cookies: new Map([["session", "abc123"]]),
});
const syncCookies = cookies();
expect(() => Reflect.get(syncCookies, "set")).toThrow(
/Cookies can only be modified in a Server Action or Route Handler/,
);
expect(() => Reflect.get(syncCookies, "delete")).toThrow(
/Cookies can only be modified in a Server Action or Route Handler/,
);
expect(() => syncCookies.set("session", "mutated")).toThrow(
/Cookies can only be modified in a Server Action or Route Handler/,
);
expect(() => syncCookies.delete("session")).toThrow(
/Cookies can only be modified in a Server Action or Route Handler/,
);
expect(syncCookies.get("session")).toEqual({ name: "session", value: "abc123" });
const awaitedCookies = await cookies();
expect(() => Reflect.get(awaitedCookies, "set")).toThrow(
/Cookies can only be modified in a Server Action or Route Handler/,
);
expect(() => awaitedCookies.set("session", "mutated")).toThrow(
/Cookies can only be modified in a Server Action or Route Handler/,
);
expect(() => awaitedCookies.delete("session")).toThrow(
/Cookies can only be modified in a Server Action or Route Handler/,
);
expect(getAndClearPendingCookies()).toEqual([]);
expect((await cookies()).get("session")).toEqual({ name: "session", value: "abc123" });
setHeadersContext(null);
});
it("headersContextFromRequest parses cookies from Request", async () => {
const { headersContextFromRequest } = await import("../packages/vinext/src/shims/headers.js");
const req = new Request("https://example.com", {
headers: { cookie: "a=1; b=2" },
});
const ctx = headersContextFromRequest(req);
expect(ctx.cookies.get("a")).toBe("1");
expect(ctx.cookies.get("b")).toBe("2");
expect(ctx.headers.get("cookie")).toBe("a=1; b=2");
});
it("cookies().getAll(name) filters by name and matches upstream duplicate semantics", async () => {
const { headersContextFromRequest, runWithHeadersContext, cookies } =
await import("../packages/vinext/src/shims/headers.js");
// Ported from the current @edge-runtime/cookies RequestCookies behavior:
// duplicate names are collapsed to the last value, and getAll(name) filters.
const ctx = headersContextFromRequest(
new Request("https://example.com", {
headers: { cookie: "a=1; a=2; b=3" },
}),
);
await runWithHeadersContext(ctx, async () => {
const jar = await cookies();
expect(jar.get("a")).toEqual({ name: "a", value: "2" });
expect(jar.getAll("a")).toEqual([{ name: "a", value: "2" }]);
expect(jar.getAll()).toEqual([
{ name: "a", value: "2" },
{ name: "b", value: "3" },
]);
});
});
it("cookies().getAll({ name }) supports the RequestCookie overload and missing names", async () => {
const { headersContextFromRequest, runWithHeadersContext, cookies } =
await import("../packages/vinext/src/shims/headers.js");
const ctx = headersContextFromRequest(
new Request("https://example.com", {
headers: { cookie: "a=1; a=2; token=abc%3D123" },
}),
);
await runWithHeadersContext(ctx, async () => {
const jar = await cookies();
expect(jar.getAll({ name: "a" })).toEqual([{ name: "a", value: "2" }]);
expect(jar.getAll("missing")).toEqual([]);
expect(jar.getAll({ name: "missing" })).toEqual([]);
expect(jar.get("token")).toEqual({ name: "token", value: "abc=123" });
});
});
it("cookies() ignores malformed cookie values and treats bare tokens as true", async () => {
const { headersContextFromRequest, runWithHeadersContext, cookies } =
await import("../packages/vinext/src/shims/headers.js");
const ctx = headersContextFromRequest(
new Request("https://example.com", {
headers: { cookie: "bad=%E0%A4%A; good=ok; flag" },
}),
);
await runWithHeadersContext(ctx, async () => {
const jar = await cookies();
expect(jar.get("bad")).toBeUndefined();
expect(jar.get("good")).toEqual({ name: "good", value: "ok" });
expect(jar.get("flag")).toEqual({ name: "flag", value: "true" });
expect(jar.getAll()).toEqual([
{ name: "good", value: "ok" },
{ name: "flag", value: "true" },
]);
});
});
it("cookies() preserves explicit empty values", async () => {
const { headersContextFromRequest, runWithHeadersContext, cookies } =
await import("../packages/vinext/src/shims/headers.js");
const ctx = headersContextFromRequest(
new Request("https://example.com", {
headers: { cookie: "empty=; flag" },
}),
);
await runWithHeadersContext(ctx, async () => {
const jar = await cookies();
expect(jar.get("empty")).toEqual({ name: "empty", value: "" });
expect(jar.get("flag")).toEqual({ name: "flag", value: "true" });
expect(jar.getAll()).toEqual([
{ name: "empty", value: "" },
{ name: "flag", value: "true" },
]);
});
});
it("cookies() preserves whitespace exactly like the Next.js parser", async () => {
const { headersContextFromRequest, runWithHeadersContext, cookies } =
await import("../packages/vinext/src/shims/headers.js");
const ctx = headersContextFromRequest(
new Request("https://example.com", {
headers: { cookie: "a= 1 ; a =2" },
}),
);
await runWithHeadersContext(ctx, async () => {
const jar = await cookies();
expect(jar.get("a")).toEqual({ name: "a", value: " 1 " });
expect(jar.get("a ")).toEqual({ name: "a ", value: "2" });
expect(jar.getAll()).toEqual([
{ name: "a", value: " 1 " },
{ name: "a ", value: "2" },
]);
});
});
it("headersContextFromRequest returns mutable headers (not the immutable Request.headers)", async () => {
// In Cloudflare Workers, Request.headers is immutable. applyMiddlewareRequestHeaders
// needs ctx.headers.set() after middleware runs, so the context must hold a mutable
// copy, not the original Headers reference.
const { headersContextFromRequest } = await import("../packages/vinext/src/shims/headers.js");
const req = new Request("https://example.com", {
headers: { "x-custom": "original" },
});
const ctx = headersContextFromRequest(req);
// Must be a separate, mutable copy — not the same reference
expect(ctx.headers).not.toBe(req.headers);
// Must be writable without throwing
expect(() => ctx.headers.set("x-custom", "modified")).not.toThrow();
expect(ctx.headers.get("x-custom")).toBe("modified");
// Original request headers must be unaffected
expect(req.headers.get("x-custom")).toBe("original");
});
it("headersContextFromRequest defers new Headers() copy until first write", async () => {
// Performance regression guard: the expensive cross-boundary copy in Workerd
// (new Headers(request.headers)) must NOT happen on reads — only on the
// first mutating call (.set/.delete/.append).
const { headersContextFromRequest } = await import("../packages/vinext/src/shims/headers.js");
const req = new Request("https://example.com", {
headers: { "x-foo": "bar", cookie: "a=1" },
});
const ctx = headersContextFromRequest(req);
// Reads must work before any write (no copy yet)
expect(ctx.headers.get("x-foo")).toBe("bar");
expect(ctx.headers.has("x-foo")).toBe(true);
// After a write, the copy is materialised and the new value is visible
ctx.headers.set("x-foo", "baz");
expect(ctx.headers.get("x-foo")).toBe("baz");
// Original request is untouched
expect(req.headers.get("x-foo")).toBe("bar");
});
it("headersContextFromRequest preserves iterator-based reads before copy-on-write", async () => {
// Ported from Next.js:
// packages/next/src/server/web/spec-extension/adapters/headers.test.ts
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/web/spec-extension/adapters/headers.test.ts
const { headersContextFromRequest } = await import("../packages/vinext/src/shims/headers.js");
const ctx = headersContextFromRequest(
new Request("https://example.com", {
headers: {
"x-iter-a": "alpha",
"x-iter-b": "beta",
},
}),
);
expect(Array.from(ctx.headers)).toEqual([
["x-iter-a", "alpha"],
["x-iter-b", "beta"],
]);
expect(Array.from(ctx.headers.entries())).toEqual([
["x-iter-a", "alpha"],
["x-iter-b", "beta"],
]);
expect(Array.from(ctx.headers.keys())).toEqual(["x-iter-a", "x-iter-b"]);
expect(Array.from(ctx.headers.values())).toEqual(["alpha", "beta"]);
expect(Object.fromEntries(ctx.headers)).toEqual({
"x-iter-a": "alpha",
"x-iter-b": "beta",
});
});
it("headers() preserves iterator-based reads for sync and awaited access", async () => {
const { headersContextFromRequest, runWithHeadersContext, headers } =
await import("../packages/vinext/src/shims/headers.js");
const ctx = headersContextFromRequest(
new Request("https://example.com", {
headers: {
"x-iter-a": "alpha",
"x-iter-b": "beta",
},
}),
);
await runWithHeadersContext(ctx, async () => {
const syncHeaders = headers();
expect(Array.from(syncHeaders)).toEqual([
["x-iter-a", "alpha"],
["x-iter-b", "beta"],
]);
expect(Array.from(syncHeaders.keys())).toEqual(["x-iter-a", "x-iter-b"]);
const awaitedHeaders = await headers();
expect(Array.from(awaitedHeaders.entries())).toEqual([
["x-iter-a", "alpha"],
["x-iter-b", "beta"],
]);
expect(Array.from(awaitedHeaders.values())).toEqual(["alpha", "beta"]);
expect(Object.fromEntries(awaitedHeaders)).toEqual({
"x-iter-a": "alpha",
"x-iter-b": "beta",
});
});
});
it("headersContextFromRequest defers cookie parsing until first access", async () => {
// Cookie parsing should be deferred: accessing ctx.cookies triggers parsing,
// but merely calling headersContextFromRequest must not.
const { headersContextFromRequest } = await import("../packages/vinext/src/shims/headers.js");
const req = new Request("https://example.com", {
headers: { cookie: "session=xyz; theme=dark" },
});
const ctx = headersContextFromRequest(req);
// First access parses cookies
expect(ctx.cookies.get("session")).toBe("xyz");
expect(ctx.cookies.get("theme")).toBe("dark");
// Subsequent access returns the same map (no re-parse)
const map1 = ctx.cookies;
const map2 = ctx.cookies;
expect(map1).toBe(map2);
});
it("headersContextFromRequest cookie getter reflects middleware-modified cookie header", async () => {
// When middleware calls ctx.headers.set("cookie", ...) the lazy cookie
// map must reflect the new value on next access.
const { headersContextFromRequest, applyMiddlewareRequestHeaders, runWithHeadersContext } =
await import("../packages/vinext/src/shims/headers.js");
const req = new Request("https://example.com", {
headers: { cookie: "a=1" },
});
const ctx = headersContextFromRequest(req);
// Simulate middleware updating the cookie header
const middlewareResponseHeaders = new Headers({
"x-middleware-request-cookie": "a=2; b=3",
});
await runWithHeadersContext(ctx, async () => {
applyMiddlewareRequestHeaders(middlewareResponseHeaders);
// Cookies map should be rebuilt with the new values
expect(ctx.cookies.get("a")).toBe("2");
expect(ctx.cookies.get("b")).toBe("3");
});
});
it("cookies().getAll(name) reflects middleware cookie rewrites with duplicate names", async () => {
const {
headersContextFromRequest,
applyMiddlewareRequestHeaders,
runWithHeadersContext,
cookies,
} = await import("../packages/vinext/src/shims/headers.js");
const req = new Request("https://example.com", {
headers: { cookie: "a=1; b=2" },
});
const ctx = headersContextFromRequest(req);
await runWithHeadersContext(ctx, async () => {
applyMiddlewareRequestHeaders(
new Headers({
"x-middleware-request-cookie": "a=1; a=2; b=4",
}),
);
const jar = await cookies();
expect(jar.get("a")).toEqual({ name: "a", value: "2" });
expect(jar.getAll("a")).toEqual([{ name: "a", value: "2" }]);
expect(jar.getAll({ name: "a" })).toEqual([{ name: "a", value: "2" }]);
expect(jar.getAll()).toEqual([
{ name: "a", value: "2" },
{ name: "b", value: "4" },
]);
});
});
it("cookies() preserves explicit empty values after middleware cookie rewrites", async () => {
const {
headersContextFromRequest,
applyMiddlewareRequestHeaders,
runWithHeadersContext,
cookies,
} = await import("../packages/vinext/src/shims/headers.js");
const ctx = headersContextFromRequest(
new Request("https://example.com", {
headers: { cookie: "start=1" },
}),
);
await runWithHeadersContext(ctx, async () => {
applyMiddlewareRequestHeaders(
new Headers({
"x-middleware-request-cookie": "empty=; flag",
}),
);
const jar = await cookies();
expect(jar.get("empty")).toEqual({ name: "empty", value: "" });
expect(jar.get("flag")).toEqual({ name: "flag", value: "true" });
expect(jar.getAll()).toEqual([
{ name: "empty", value: "" },
{ name: "flag", value: "true" },
]);
});
});
// Ported from Next.js:
// - packages/next/src/server/async-storage/request-store.ts
// - test/e2e/app-dir/app-middleware/app-middleware.test.ts
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/async-storage/request-store.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-middleware/app-middleware.test.ts
it("middleware-set cookies are visible to cookies() in the same render", async () => {
const { NextResponse } = await import("../packages/vinext/src/shims/server.js");
const {
headersContextFromRequest,
applyMiddlewareRequestHeaders,
runWithHeadersContext,
cookies,
} = await import("../packages/vinext/src/shims/headers.js");
const middlewareResponse = NextResponse.next();
middlewareResponse.cookies.set("rsc-cookie-value-1", "123", { path: "/" });
middlewareResponse.cookies.set("rsc-cookie-value-2", "456", { path: "/", secure: true });
const ctx = headersContextFromRequest(
new Request("https://example.com/rsc-cookies", {
headers: { cookie: "existing=kept" },
}),
);
await runWithHeadersContext(ctx, async () => {
applyMiddlewareRequestHeaders(middlewareResponse.headers);
const jar = await cookies();
expect(jar.get("existing")).toEqual({ name: "existing", value: "kept" });
expect(jar.get("rsc-cookie-value-1")).toEqual({
name: "rsc-cookie-value-1",
value: "123",
});
expect(jar.get("rsc-cookie-value-2")).toEqual({
name: "rsc-cookie-value-2",
value: "456",
});
expect(jar.getAll()).toEqual([
{ name: "existing", value: "kept" },
{ name: "rsc-cookie-value-1", value: "123" },
{ name: "rsc-cookie-value-2", value: "456" },
]);
});
});
it("throws when called outside request context", async () => {
const { headers, cookies, draftMode } = await import("../packages/vinext/src/shims/headers.js");
// Ensure context is cleared
const { setHeadersContext } = await import("../packages/vinext/src/shims/headers.js");
setHeadersContext(null);
await expect(headers()).rejects.toThrow("Server Component");
await expect(cookies()).rejects.toThrow("Server Component");
await expect(draftMode()).rejects.toThrow("draftMode() can only be called");
});
it("legacy sync access still throws the request-context error outside request context", async () => {
const { headers, cookies, setHeadersContext } =
await import("../packages/vinext/src/shims/headers.js");
setHeadersContext(null);
expect(() => headers().get("x-test")).toThrow("Server Component");
expect(() => cookies().get("session")).toThrow("Server Component");
});
it("draftMode() returns isEnabled=false when no bypass cookie", async () => {
const { setHeadersContext, draftMode } =
await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers(),
cookies: new Map(),
});
const dm = await draftMode();
expect(dm.isEnabled).toBe(false);
setHeadersContext(null);
});
it("draftMode() returns isEnabled=false for arbitrary cookie values (not signed)", async () => {
const { setHeadersContext, draftMode } =
await import("../packages/vinext/src/shims/headers.js");
// An arbitrary cookie value should NOT enable draft mode — only the
// server-generated secret is valid.
setHeadersContext({
headers: new Headers(),
cookies: new Map([["__prerender_bypass", "1"]]),
});
const dm = await draftMode();
expect(dm.isEnabled).toBe(false);
setHeadersContext(null);
});
it("draft mode validation is scoped to the request context secret", async () => {
const { draftMode, headersContextFromRequest, isDraftModeRequest, runWithHeadersContext } =
await import("../packages/vinext/src/shims/headers.js");
const firstContext = headersContextFromRequest(new Request("https://example.test/one"), {
draftModeSecret: "first-secret",
});
const secondContext = headersContextFromRequest(new Request("https://example.test/two"), {
draftModeSecret: "second-secret",
});
await runWithHeadersContext(firstContext, async () => {
const dm = await draftMode();
dm.enable();
expect(dm.isEnabled).toBe(true);
});
await runWithHeadersContext(secondContext, async () => {
const dm = await draftMode();
dm.enable();
expect(dm.isEnabled).toBe(true);
});
const firstCookieRequest = new Request("https://example.test/one", {
headers: { Cookie: "__prerender_bypass=first-secret" },
});
expect(isDraftModeRequest(firstCookieRequest, "first-secret")).toBe(true);
expect(isDraftModeRequest(firstCookieRequest, "second-secret")).toBe(false);
});
it("draftMode().enable() sets the bypass cookie in context", async () => {
const { setHeadersContext, draftMode, getDraftModeCookieHeader } =
await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers(),
cookies: new Map(),
});
const dm = await draftMode();
expect(dm.isEnabled).toBe(false);
dm.enable();
// isEnabled should reflect the change on the same object (live getter)
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/request/draft-mode.ts
expect(dm.isEnabled).toBe(true);
// A fresh draftMode() call should also see the change
const dm2 = await draftMode();
expect(dm2.isEnabled).toBe(true);
// The Set-Cookie header should be generated with a non-predictable secret (UUID)
const cookieHeader = getDraftModeCookieHeader();
expect(cookieHeader).toContain("__prerender_bypass=");
const bypassMatch = cookieHeader!.match(/__prerender_bypass=([^;]+)/);
expect(bypassMatch).not.toBeNull();
expect(bypassMatch![1]).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
expect(cookieHeader).toContain("HttpOnly");
setHeadersContext(null);
});
it("draftMode().disable() clears the bypass cookie", async () => {
const { setHeadersContext, draftMode, getDraftModeCookieHeader } =
await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers(),
cookies: new Map(),
});
// Enable first so the cookie is set to the server secret
const dm = await draftMode();
dm.enable();
// Consume the enable Set-Cookie header
getDraftModeCookieHeader();
const dm1 = await draftMode();
expect(dm1.isEnabled).toBe(true);
dm1.disable();
// isEnabled should reflect the change on the same object (live getter)
expect(dm1.isEnabled).toBe(false);
// A fresh draftMode() call should also see the change
const dm2 = await draftMode();
expect(dm2.isEnabled).toBe(false);
const cookieHeader = getDraftModeCookieHeader();
expect(cookieHeader).toContain("Expires=Thu, 01 Jan 1970 00:00:00 GMT");
expect(cookieHeader).not.toContain("Max-Age=0");
setHeadersContext(null);
});
it("draftMode().disable() throws after its request context has been cleared", async () => {
const { setHeadersContext, draftMode, getDraftModeCookieHeader } =
await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers(),
cookies: new Map([["__prerender_bypass", "draft-secret"]]),
draftModeSecret: "draft-secret",
});
const dm = await draftMode();
expect(dm.isEnabled).toBe(true);
setHeadersContext(null);
expect(() => dm.disable()).toThrow("draftMode().disable() can only be called");
expect(getDraftModeCookieHeader()).toBeNull();
});
it('draftMode() stays readable with dynamic = "error" but mutations throw', async () => {
const { setHeadersContext, draftMode, getDraftModeCookieHeader, consumeDynamicUsage } =
await import("../packages/vinext/src/shims/headers.js");
const accessError = new Error(
'Page with `dynamic = "error"` used a dynamic API. This page was expected to be fully static.',
);
setHeadersContext({
headers: new Headers(),
cookies: new Map(),
accessError,
});
const dm = await draftMode();
expect(dm.isEnabled).toBe(false);
expect(() => dm.enable()).toThrow(accessError);
expect(() => dm.disable()).toThrow(accessError);
expect(consumeDynamicUsage()).toBe(false);
expect(getDraftModeCookieHeader()).toBeNull();
setHeadersContext(null);
});
});
describe("next/headers phase-aware cookies", () => {
it("cookies().set() works in the route-handler phase and accumulates Set-Cookie headers", async () => {
const { setHeadersContext, setHeadersAccessPhase, cookies, getAndClearPendingCookies } =
await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers(),
cookies: new Map(),
});
const previousPhase = setHeadersAccessPhase("route-handler");
try {
const c = await cookies();
c.set("token", "xyz", { path: "/", httpOnly: true, secure: true });
expect(c.get("token")).toEqual({ name: "token", value: "xyz" });
expect(c.has("token")).toBe(true);
const pending = getAndClearPendingCookies();
expect(pending.length).toBe(1);
expect(pending[0]).toContain("token=xyz");
expect(pending[0]).toContain("Path=/");
expect(pending[0]).toContain("HttpOnly");
expect(pending[0]).toContain("Secure");
expect(getAndClearPendingCookies().length).toBe(0);
} finally {
setHeadersAccessPhase(previousPhase);
setHeadersContext(null);
}
});
it("cookies().set() supports legacy sync access in the route-handler phase", async () => {
const { setHeadersContext, setHeadersAccessPhase, cookies, getAndClearPendingCookies } =
await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers(),
cookies: new Map(),
});
const previousPhase = setHeadersAccessPhase("route-handler");
try {
const cookieStore = cookies();
void cookieStore.set("token", "sync-token", { httpOnly: true });
expect(cookieStore.get("token")).toEqual({ name: "token", value: "sync-token" });
expect(getAndClearPendingCookies()).toEqual([expect.stringContaining("token=sync-token")]);
} finally {
setHeadersAccessPhase(previousPhase);
setHeadersContext(null);
}
});
it("cookies().set() emits Path=/ when no path option is provided", async () => {
const { setHeadersContext, setHeadersAccessPhase, cookies, getAndClearPendingCookies } =
await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers(),
cookies: new Map(),
});
const previousPhase = setHeadersAccessPhase("route-handler");
try {
const c = await cookies();
c.set("token", "implicit-path");
const pending = getAndClearPendingCookies();
expect(pending).toHaveLength(1);
expect(pending[0]).toContain("token=implicit-path");
expect(pending[0]).toContain("Path=/");
expect(getAndClearPendingCookies()).toHaveLength(0);
} finally {
setHeadersAccessPhase(previousPhase);
setHeadersContext(null);
}
});
it("cookies().set() preserves an explicit path option", async () => {
const { setHeadersContext, setHeadersAccessPhase, cookies, getAndClearPendingCookies } =
await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers(),
cookies: new Map(),
});
const previousPhase = setHeadersAccessPhase("route-handler");
try {
const c = await cookies();
c.set("token", "scoped-path", { path: "/api" });
const pending = getAndClearPendingCookies();
expect(pending).toHaveLength(1);
expect(pending[0]).toContain("token=scoped-path");
expect(pending[0]).toContain("Path=/api");
expect(pending[0]).not.toContain("Path=/;");
expect(getAndClearPendingCookies()).toHaveLength(0);
} finally {
setHeadersAccessPhase(previousPhase);
setHeadersContext(null);
}
});
it("cookies().delete() works in the route-handler phase", async () => {
const { setHeadersContext, setHeadersAccessPhase, cookies, getAndClearPendingCookies } =
await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers(),
cookies: new Map([["session", "abc"]]),
});
const previousPhase = setHeadersAccessPhase("route-handler");
try {
const c = await cookies();
expect(c.has("session")).toBe(true);
c.delete("session");
expect(c.has("session")).toBe(false);
const pending = getAndClearPendingCookies();
expect(pending.length).toBe(1);
expect(pending[0]).toContain("session=");
expect(pending[0]).toContain("Expires=");
} finally {
setHeadersAccessPhase(previousPhase);
setHeadersContext(null);
}
});
it("cookies().delete() accepts options with path and domain", async () => {
const { setHeadersContext, setHeadersAccessPhase, cookies, getAndClearPendingCookies } =
await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers(),
cookies: new Map([["session", "abc"]]),
});
const previousPhase = setHeadersAccessPhase("route-handler");
try {
const c = await cookies();
c.delete({ name: "session", path: "/account", domain: ".example.com" });
const pending = getAndClearPendingCookies();
expect(pending).toHaveLength(1);
expect(pending[0]).toContain("session=");
expect(pending[0]).toContain("Path=/account");
expect(pending[0]).toContain("Domain=.example.com");
expect(pending[0]).toContain("Expires=");
} finally {
setHeadersAccessPhase(previousPhase);
setHeadersContext(null);
}
});
it("cookies().delete() defaults Path=/ for object syntax without path", async () => {
const { setHeadersContext, setHeadersAccessPhase, cookies, getAndClearPendingCookies } =
await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers(),
cookies: new Map([["session", "abc"]]),
});
const previousPhase = setHeadersAccessPhase("route-handler");
try {
const c = await cookies();
c.delete({ name: "session" });
const pending = getAndClearPendingCookies();
expect(pending).toHaveLength(1);
expect(pending[0]).toContain("session=");
expect(pending[0]).toContain("Path=/");
expect(pending[0]).toContain("Expires=");
} finally {
setHeadersAccessPhase(previousPhase);
setHeadersContext(null);
}
});
it("cookies().set() with object syntax works in the action phase", async () => {
const { setHeadersContext, setHeadersAccessPhase, cookies, getAndClearPendingCookies } =
await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers(),
cookies: new Map(),
});
const previousPhase = setHeadersAccessPhase("action");
try {
const c = await cookies();
c.set({ name: "pref", value: "dark", sameSite: "Lax" });
expect(c.get("pref")?.value).toBe("dark");
const pending = getAndClearPendingCookies();
expect(pending[0]).toContain("pref=dark");
expect(pending[0]).toContain("SameSite=Lax");
} finally {
setHeadersAccessPhase(previousPhase);
setHeadersContext(null);
}
});
it("mutable cookie references stop accepting writes after the phase returns to render", async () => {
// Ported from Next.js:
// packages/next/src/server/web/spec-extension/adapters/request-cookies.test.ts
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/web/spec-extension/adapters/request-cookies.test.ts
const { setHeadersContext, setHeadersAccessPhase, cookies, getAndClearPendingCookies } =
await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers(),
cookies: new Map(),
});
const previousPhase = setHeadersAccessPhase("action");
try {
const c = await cookies();
c.set("session", "abc123");
expect(c.get("session")?.value).toBe("abc123");
setHeadersAccessPhase("render");
expect(() => c.set("session", "mutated")).toThrow(
/Cookies can only be modified in a Server Action or Route Handler/,
);
expect(() => c.delete("session")).toThrow(
/Cookies can only be modified in a Server Action or Route Handler/,
);
expect(c.get("session")?.value).toBe("abc123");
expect(getAndClearPendingCookies()).toEqual([expect.stringContaining("session=abc123")]);
} finally {
setHeadersAccessPhase(previousPhase);
setHeadersContext(null);
}
});
});
describe("next/server shim", () => {
it("NextRequest wraps a standard Request with nextUrl and cookies", async () => {
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
const req = new NextRequest("https://example.com/blog?page=2", {
headers: { cookie: "session=abc123; theme=dark" },
});
expect(req.nextUrl.pathname).toBe("/blog");
expect(req.nextUrl.searchParams.get("page")).toBe("2");
expect(req.cookies.get("session")).toEqual({ name: "session", value: "abc123" });
expect(req.cookies.get("theme")).toEqual({ name: "theme", value: "dark" });
expect(req.cookies.has("session")).toBe(true);
expect(req.cookies.has("missing")).toBe(false);
});
it("NextRequest copies a Request body without disturbing the source request", async () => {
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
const source = new Request("https://example.com/api/echo", {
body: JSON.stringify({ ok: true }),
headers: { "content-type": "application/json" },
method: "POST",
});
const req = new NextRequest(source);
await expect(req.json()).resolves.toEqual({ ok: true });
expect(source.bodyUsed).toBe(false);
await expect(source.json()).resolves.toEqual({ ok: true });
});
it("NextResponse.json() creates a JSON response", async () => {
const { NextResponse } = await import("../packages/vinext/src/shims/server.js");
const res = NextResponse.json({ message: "hello" }, { status: 201 });
expect(res.status).toBe(201);
expect(res.headers.get("content-type")).toBe("application/json");
const body = await res.json();
expect(body).toEqual({ message: "hello" });
});
it("NextResponse.redirect() creates a redirect response", async () => {
const { NextResponse } = await import("../packages/vinext/src/shims/server.js");
const res = NextResponse.redirect("https://example.com/new", 308);
expect(res.status).toBe(308);
expect(res.headers.get("Location")).toBe("https://example.com/new");
});
// Ported from Next.js: test/e2e/middleware-general/test/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-general/test/index.test.ts
it("NextResponse.redirect() throws when using a relative URL", async () => {
const { NextResponse } = await import("../packages/vinext/src/shims/server.js");
expect(() => NextResponse.redirect("/urls-b")).toThrow("URL is malformed");
});
it("NextResponse.rewrite() sets x-middleware-rewrite header", async () => {
const { NextResponse } = await import("../packages/vinext/src/shims/server.js");
const res = NextResponse.rewrite("https://example.com/internal");
expect(res.headers.get("x-middleware-rewrite")).toBe("https://example.com/internal");
});
// Ported from Next.js: test/e2e/middleware-general/test/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-general/test/index.test.ts
it("NextResponse.rewrite() throws when using a relative URL", async () => {
const { NextResponse } = await import("../packages/vinext/src/shims/server.js");
expect(() => NextResponse.rewrite("/urls-b")).toThrow("URL is malformed");
});
it("NextResponse.rewrite() forwards request header overrides", async () => {
const { NextResponse } = await import("../packages/vinext/src/shims/server.js");
const forwardedHeaders = new Headers({
cookie: "a=1",
"x-added": "1",
});
const res = NextResponse.rewrite("https://example.com/internal", {
request: {
headers: forwardedHeaders,
},
});
expect(res.headers.get("x-middleware-rewrite")).toBe("https://example.com/internal");
expect(res.headers.get("x-middleware-override-headers")).toBe("cookie,x-added");
expect(res.headers.get("x-middleware-request-cookie")).toBe("a=1");
expect(res.headers.get("x-middleware-request-x-added")).toBe("1");
});
// Ported from Next.js:
// - packages/next/src/server/web/spec-extension/response.ts
// - test/e2e/app-dir/app-middleware/app-middleware.test.ts
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/web/spec-extension/response.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-middleware/app-middleware.test.ts
it("NextResponse.cookies.set() emits x-middleware-set-cookie for same-render reads", async () => {
const { NextResponse } = await import("../packages/vinext/src/shims/server.js");
const res = NextResponse.next();
res.cookies.set("rsc-cookie-value-1", "123", { path: "/" });
res.cookies.set("rsc-cookie-value-2", "456", { path: "/", httpOnly: true });
expect(res.headers.getSetCookie()).toEqual([
"rsc-cookie-value-1=123; Path=/",
"rsc-cookie-value-2=456; Path=/; HttpOnly",
]);
const internalCookieHeader = res.headers.get("x-middleware-set-cookie");
expect(internalCookieHeader).not.toBeNull();
expect(internalCookieHeader).toContain("rsc-cookie-value-1=123; Path=/");
expect(internalCookieHeader).toContain("rsc-cookie-value-2=456; Path=/; HttpOnly");
});
it("NextResponse.next() sets x-middleware-next header", async () => {
const { NextResponse } = await import("../packages/vinext/src/shims/server.js");
const res = NextResponse.next();
expect(res.headers.get("x-middleware-next")).toBe("1");
});
it("ResponseCookies set/get/delete work", async () => {
const { NextResponse } = await import("../packages/vinext/src/shims/server.js");
const res = new NextResponse();
res.cookies.set("token", "xyz", { path: "/", httpOnly: true });
const cookie = res.cookies.get("token");
expect(cookie).toBeTruthy();
expect(cookie!.value).toBe("xyz");
// Verify the Set-Cookie header was set
const setCookie = res.headers.getSetCookie();
expect(setCookie.length).toBeGreaterThan(0);
expect(setCookie[0]).toContain("token=xyz");
expect(setCookie[0]).toContain("HttpOnly");
});
it("userAgentFromString detects bots", async () => {
const { userAgentFromString } = await import("../packages/vinext/src/shims/server.js");
const bot = userAgentFromString("Googlebot/2.1");
expect(bot.isBot).toBe(true);
const human = userAgentFromString("Mozilla/5.0");
expect(human.isBot).toBe(false);
});
it("after() runs a callback asynchronously without throwing", async () => {
const { after } = await import("../packages/vinext/src/shims/server.js");
let called = false;
after(() => {
called = true;
});
// after() schedules as a microtask, so await a tick
await new Promise((r) => setTimeout(r, 10));
expect(called).toBe(true);
});
it("after() handles a promise argument", async () => {
const { after } = await import("../packages/vinext/src/shims/server.js");
let resolved = false;
const p = new Promise<void>((resolve) => {
setTimeout(() => {
resolved = true;
resolve();
}, 5);
});
after(p);
await new Promise((r) => setTimeout(r, 20));
expect(resolved).toBe(true);
});
it("after() swallows errors from failing tasks", async () => {
const { after } = await import("../packages/vinext/src/shims/server.js");
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
after(() => {
throw new Error("task failed");
});
// after() wraps function tasks in Promise.resolve().then(task) — two microtask
// ticks are sufficient and more deterministic than a setTimeout.
await Promise.resolve();
await Promise.resolve();
expect(consoleError).toHaveBeenCalledWith("[vinext] after() task failed:", expect.any(Error));
consoleError.mockRestore();
});
it("after() calls waitUntil on the execution context when one exists", async () => {
const { after } = await import("../packages/vinext/src/shims/server.js");
const { runWithExecutionContext } =
await import("../packages/vinext/src/shims/request-context.js");
const waitUntilCalls: Promise<unknown>[] = [];
const mockCtx = {
waitUntil: (p: Promise<unknown>) => {
waitUntilCalls.push(p);
},
};
let called = false;
await runWithExecutionContext(mockCtx, () => {
after(() => {
called = true;
});
});
// waitUntil is called synchronously — no microtask delay needed
expect(waitUntilCalls).toHaveLength(1);
// Await the guarded promise to verify the callback ran
await waitUntilCalls[0];
expect(called).toBe(true);
});
it("after() falls back to fire-and-forget when no execution context exists", async () => {
const { after } = await import("../packages/vinext/src/shims/server.js");
// Outside any execution context scope — should still run the task
let called = false;
after(() => {
called = true;
});
// after() wraps function tasks in Promise.resolve().then(task) — two microtask
// ticks are sufficient and more deterministic than a setTimeout.
await Promise.resolve();
await Promise.resolve();
expect(called).toBe(true);
});
it('after() throws inside "use cache" scope', async () => {
const { after } = await import("../packages/vinext/src/shims/server.js");
const { cacheContextStorage } = await import("../packages/vinext/src/shims/cache-runtime.js");
cacheContextStorage.run(
{
tags: [],
lifeConfigs: [],
variant: "default",
hasExplicitRevalidate: false,
hasExplicitExpire: false,
dynamicNestedCacheError: undefined,
},
() => {
expect(() => after(() => {})).toThrow(/cannot be called inside "use cache"/);
},
);
});
it("after() throws inside unstable_cache() scope", async () => {
const { after } = await import("../packages/vinext/src/shims/server.js");
const { AsyncLocalStorage } = await import("node:async_hooks");
const key = Symbol.for("vinext.unstableCache.als");
const g = globalThis as unknown as Record<symbol, unknown>;
// Lazily register an ALS on globalThis if cache.ts hasn't been imported yet.
// This test is intentionally isolated from the real cache.ts registration path —
// it only validates that server.ts reads from the same Symbol key to detect the
// unstable_cache scope. If cache.ts was already imported, the existing instance is
// reused; if not, this standalone ALS is sufficient for the guard to work.
if (!g[key]) g[key] = new AsyncLocalStorage();
(g[key] as any).run(true, () => {
expect(() => after(() => {})).toThrow(/unstable_cache/);
});
});
it("connection() returns a resolved promise", async () => {
const { connection } = await import("../packages/vinext/src/shims/server.js");
const result = connection();
expect(result).toBeInstanceOf(Promise);
await expect(result).resolves.toBeUndefined();
});
it("connection() interrupts speculative probes before post-connection user code runs", async () => {
const { runWithConnectionProbe } = await import("../packages/vinext/src/shims/headers.js");
const { connection } = await import("../packages/vinext/src/shims/server.js");
let ranAfterConnection = false;
const outcome = await runWithConnectionProbe(async () => {
await connection();
ranAfterConnection = true;
return "completed";
});
expect(outcome).toEqual({ completed: false });
expect(ranAfterConnection).toBe(false);
});
it("URLPattern is exported and available in Node 20+", async () => {
const { URLPattern } = await import("../packages/vinext/src/shims/server.js");
// Node 22+ has URLPattern globally; if available, test it works
if (globalThis.URLPattern) {
expect(URLPattern).toBe(globalThis.URLPattern);
const pattern = new URLPattern({ pathname: "/blog/:slug" });
const match = pattern.exec({ pathname: "/blog/hello-world" });
expect(match).toBeTruthy();
expect(match!.pathname.groups.slug).toBe("hello-world");
} else {
// URLPattern not available — our export should be a fallback that throws
expect(typeof URLPattern).toBe("function");
}
});
});
describe("next/config shim", () => {
it("getConfig returns default empty config", async () => {
const { default: getConfig } = await import("../packages/vinext/src/shims/config.js");
const config = getConfig();
expect(config).toEqual({
serverRuntimeConfig: {},
publicRuntimeConfig: {},
});
});
it("setConfig updates the runtime config", async () => {
const { default: getConfig, setConfig } =
await import("../packages/vinext/src/shims/config.js");
setConfig({
serverRuntimeConfig: { secret: "s3cr3t" },
publicRuntimeConfig: { appName: "test-app" },
});
const config = getConfig();
expect(config.serverRuntimeConfig.secret).toBe("s3cr3t");
expect(config.publicRuntimeConfig.appName).toBe("test-app");
// Reset for other tests
setConfig({ serverRuntimeConfig: {}, publicRuntimeConfig: {} });
});
});
describe("next/cache shim", () => {
it("exports revalidateTag, revalidatePath, unstable_cache", async () => {
const mod = await import("../packages/vinext/src/shims/cache.js");
expect(typeof mod.revalidateTag).toBe("function");
expect(typeof mod.revalidatePath).toBe("function");
expect(typeof mod.unstable_cache).toBe("function");
});
it("exports setCacheHandler and getCacheHandler", async () => {
const mod = await import("../packages/vinext/src/shims/cache.js");
expect(typeof mod.setCacheHandler).toBe("function");
expect(typeof mod.getCacheHandler).toBe("function");
});
it("default handler is MemoryCacheHandler", async () => {
const { getCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const handler = getCacheHandler();
expect(handler).toBeInstanceOf(MemoryCacheHandler);
});
it("unstable_cache caches function results", async () => {
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
// Fresh handler for isolation
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const expensive = async (x: number) => {
callCount++;
return x * 2;
};
const cached = unstable_cache(expensive, ["test-fn"], {
tags: ["test-tag"],
});
const r1 = await cached(5);
expect(r1).toBe(10);
expect(callCount).toBe(1);
const r2 = await cached(5);
expect(r2).toBe(10);
expect(callCount).toBe(1); // Should NOT call the function again
const r3 = await cached(10);
expect(r3).toBe(20);
expect(callCount).toBe(2); // Different args = different cache key
// Reset
setCacheHandler(new MemoryCacheHandler());
});
it("unstable_cache caches undefined results", async () => {
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const cached = unstable_cache(async () => {
callCount++;
return undefined;
}, ["undefined-result-test"]);
await expect(cached()).resolves.toBeUndefined();
expect(callCount).toBe(1);
await expect(cached()).resolves.toBeUndefined();
expect(callCount).toBe(1);
setCacheHandler(new MemoryCacheHandler());
});
it("revalidateTag invalidates cached entries", async () => {
const { unstable_cache, revalidateTag, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const fn = async () => {
callCount++;
return "result-" + callCount;
};
const cached = unstable_cache(fn, ["revalidate-test"], {
tags: ["my-tag"],
});
const r1 = await cached();
expect(r1).toBe("result-1");
expect(callCount).toBe(1);
// Revalidate the tag
await revalidateTag("my-tag");
// Next call should re-execute the function
const r2 = await cached();
expect(r2).toBe("result-2");
expect(callCount).toBe(2);
// Reset
setCacheHandler(new MemoryCacheHandler());
});
it("revalidateTag accepts optional cacheLife profile (Next.js 16)", async () => {
const { revalidateTag, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
// Should not throw with profile argument
await revalidateTag("my-tag", "max");
await revalidateTag("my-tag", "hours");
await revalidateTag("my-tag", { expire: 3600 });
// Should still work without profile (deprecated single-arg form)
await revalidateTag("my-tag");
setCacheHandler(new MemoryCacheHandler());
});
it("exports updateTag function (Next.js 16)", async () => {
const mod = await import("../packages/vinext/src/shims/cache.js");
expect(typeof mod.updateTag).toBe("function");
});
it("updateTag invalidates cached entries when called inside a Server Action", async () => {
const { unstable_cache, updateTag, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { setHeadersAccessPhase } = await import("../packages/vinext/src/shims/headers.js");
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const fn = async () => {
callCount++;
return "result-" + callCount;
};
const cached = unstable_cache(fn, ["update-tag-test"], {
tags: ["user-1"],
});
const r1 = await cached();
expect(r1).toBe("result-1");
// updateTag expires the cache (must be called from inside a Server Action)
const previousPhase = setHeadersAccessPhase("action");
try {
await updateTag("user-1");
} finally {
setHeadersAccessPhase(previousPhase);
}
const r2 = await cached();
expect(r2).toBe("result-2");
expect(callCount).toBe(2);
setCacheHandler(new MemoryCacheHandler());
});
it("updateTag throws when called outside a Server Action (e.g. route handler)", async () => {
const { updateTag } = await import("../packages/vinext/src/shims/cache.js");
const { setHeadersAccessPhase } = await import("../packages/vinext/src/shims/headers.js");
// Simulate a route handler phase
const previousPhase = setHeadersAccessPhase("route-handler");
try {
await expect(updateTag("some-tag")).rejects.toThrow(
/updateTag can only be called from within a Server Action/,
);
} finally {
setHeadersAccessPhase(previousPhase);
}
});
it("updateTag throws when called during render", async () => {
const { updateTag } = await import("../packages/vinext/src/shims/cache.js");
const { setHeadersAccessPhase } = await import("../packages/vinext/src/shims/headers.js");
const previousPhase = setHeadersAccessPhase("render");
try {
await expect(updateTag("some-tag")).rejects.toThrow(
/updateTag can only be called from within a Server Action/,
);
} finally {
setHeadersAccessPhase(previousPhase);
}
});
it("exports refresh function (Next.js 16)", async () => {
const mod = await import("../packages/vinext/src/shims/cache.js");
expect(typeof mod.refresh).toBe("function");
// refresh() is a no-op on the server but should not throw
mod.refresh();
});
// Ported from Next.js: packages/next/src/client/request/io.browser.ts
// https://github.com/vercel/next.js/blob/canary/packages/next/src/client/request/io.browser.ts
it("exports io function", async () => {
const mod = await import("../packages/vinext/src/shims/cache.js");
expect(typeof mod.io).toBe("function");
});
it("io returns a resolved promise", async () => {
const { io } = await import("../packages/vinext/src/shims/cache.js");
const result = io();
expect(result).toBeInstanceOf(Promise);
expect((result as any).status).toBe("fulfilled");
expect((result as any).value).toBeUndefined();
await expect(result).resolves.toBeUndefined();
});
it("io returns same instance (singleton)", async () => {
const { io } = await import("../packages/vinext/src/shims/cache.js");
const r1 = io();
const r2 = io();
expect(r1).toBe(r2);
});
// Ported from Next.js: packages/next/src/server/request/io.ts
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/request/io.ts
it("io returns a hanging promise during prerender", async () => {
const { io } = await import("../packages/vinext/src/shims/cache.js");
const { workUnitAsyncStorage } =
await import("../packages/vinext/src/shims/internal/work-unit-async-storage.js");
const controller = new AbortController();
// run() returns whatever the callback returns — a hanging promise.
const hanging = workUnitAsyncStorage.run(
{ type: "prerender", renderSignal: controller.signal },
io,
);
// The promise should not be resolved or rejected (it's "hanging")
expect(hanging).toBeInstanceOf(Promise);
// Verify it's still hanging using Promise.race (more portable than V8 internals)
const result = await Promise.race([
hanging.then(() => "resolved"),
new Promise((r) => setTimeout(() => r("still-hanging"), 50)),
]);
expect(result).toBe("still-hanging");
// Clean up by aborting the signal
controller.abort();
});
it("io resolves immediately with request store", async () => {
const { io } = await import("../packages/vinext/src/shims/cache.js");
const { workUnitAsyncStorage } =
await import("../packages/vinext/src/shims/internal/work-unit-async-storage.js");
const promise = workUnitAsyncStorage.run({ type: "request" }, io);
expect(promise).toBeInstanceOf(Promise);
await expect(promise).resolves.toBeUndefined();
});
it("io resolves immediately with cache store", async () => {
const { io } = await import("../packages/vinext/src/shims/cache.js");
const { workUnitAsyncStorage } =
await import("../packages/vinext/src/shims/internal/work-unit-async-storage.js");
const promise = workUnitAsyncStorage.run({ type: "cache" }, io);
expect(promise).toBeInstanceOf(Promise);
await expect(promise).resolves.toBeUndefined();
});
it("io rejects hanging promise on abort when prerendering", async () => {
const { io } = await import("../packages/vinext/src/shims/cache.js");
const { workUnitAsyncStorage } =
await import("../packages/vinext/src/shims/internal/work-unit-async-storage.js");
const controller = new AbortController();
const hanging = workUnitAsyncStorage.run(
{ type: "prerender", renderSignal: controller.signal },
io,
);
expect(hanging).toBeInstanceOf(Promise);
// Abort the signal — the hanging promise should reject
controller.abort();
await expect(hanging).rejects.toThrow(/`io\(\)`/);
});
it("io returns rejected promise when signal already aborted", async () => {
const { io } = await import("../packages/vinext/src/shims/cache.js");
const { workUnitAsyncStorage } =
await import("../packages/vinext/src/shims/internal/work-unit-async-storage.js");
const controller = new AbortController();
controller.abort(); // already aborted
const promise = workUnitAsyncStorage.run(
{ type: "prerender", renderSignal: controller.signal },
io,
);
await expect(promise).rejects.toThrow(/prerendering/i);
});
it("io does not emit unhandled rejection when signal already aborted", async () => {
const { io } = await import("../packages/vinext/src/shims/cache.js");
const { workUnitAsyncStorage } =
await import("../packages/vinext/src/shims/internal/work-unit-async-storage.js");
const unhandledRejections: unknown[] = [];
const onUnhandledRejection = (reason: unknown) => {
unhandledRejections.push(reason);
};
process.on("unhandledRejection", onUnhandledRejection);
try {
const controller = new AbortController();
controller.abort(); // already aborted
const promise = workUnitAsyncStorage.run(
{ type: "prerender", renderSignal: controller.signal, route: "/test" },
io,
);
// Wait a tick for potential unhandled rejection to be detected
await new Promise((resolve) => setTimeout(resolve, 10));
// Await the promise to handle the rejection
try {
await promise;
} catch {
// expected
}
expect(unhandledRejections.length).toBe(0);
} finally {
process.off("unhandledRejection", onUnhandledRejection);
}
});
it("unstable_io is exported as a deprecation alias for io", async () => {
const mod = await import("../packages/vinext/src/shims/cache.js");
expect(typeof mod.unstable_io).toBe("function");
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const result = mod.unstable_io();
expect(result).toBeInstanceOf(Promise);
await expect(result).resolves.toBeUndefined();
// Warning fires at most once per process; at least one call must mention deprecation.
const warned = warn.mock.calls.some((args) => String(args[0] ?? "").includes("unstable_io"));
expect(warned).toBe(true);
} finally {
warn.mockRestore();
}
});
it("setCacheHandler swaps the active handler", async () => {
const { setCacheHandler, getCacheHandler, unstable_cache } =
await import("../packages/vinext/src/shims/cache.js");
// Create a custom handler that tracks calls
const calls: string[] = [];
const customHandler = {
async get(key: string) {
calls.push(`get:${key}`);
return null;
},
async set(key: string) {
calls.push(`set:${key}`);
},
async revalidateTag(tags: string | string[]) {
const tagList = Array.isArray(tags) ? tags : [tags];
calls.push(`revalidateTag:${tagList.join(",")}`);
},
resetRequestCache() {
calls.push("reset");
},
};
const originalHandler = getCacheHandler();
setCacheHandler(customHandler);
const cached = unstable_cache(async () => 42, ["custom-test"]);
await cached();
expect(calls.some((c) => c.startsWith("get:"))).toBe(true);
expect(calls.some((c) => c.startsWith("set:"))).toBe(true);
// Restore
setCacheHandler(originalHandler);
});
it("MemoryCacheHandler.get/set round-trips values", async () => {
const { MemoryCacheHandler } = await import("../packages/vinext/src/shims/cache.js");
const handler = new MemoryCacheHandler();
await handler.set("test-key", {
kind: "FETCH",
data: { headers: {}, body: '{"x":1}', url: "test" },
tags: ["t1"],
revalidate: 3600,
});
const result = await handler.get("test-key");
expect(result).not.toBeNull();
expect(result!.value).not.toBeNull();
expect(result!.value!.kind).toBe("FETCH");
if (result!.value!.kind === "FETCH") {
expect(result!.value!.data.body).toBe('{"x":1}');
}
});
it("MemoryCacheHandler evicts least-recently-used entries when max size is exceeded", async () => {
const { MemoryCacheHandler } = await import("../packages/vinext/src/shims/cache.js");
const handler = new MemoryCacheHandler({ cacheMaxMemorySize: 250 });
const makeEntry = (body: string) => ({
kind: "PAGES" as const,
html: body,
pageData: {},
headers: undefined,
status: 200,
});
await handler.set("a", makeEntry("a".repeat(80)));
await handler.set("b", makeEntry("b".repeat(80)));
expect(await handler.get("a")).toBeNull();
expect(await handler.get("b")).not.toBeNull();
});
it("MemoryCacheHandler refreshes LRU order on get", async () => {
const { MemoryCacheHandler } = await import("../packages/vinext/src/shims/cache.js");
const handler = new MemoryCacheHandler({ cacheMaxMemorySize: 360 });
const makeEntry = (body: string) => ({
kind: "PAGES" as const,
html: body,
pageData: {},
headers: undefined,
status: 200,
});
await handler.set("a", makeEntry("a".repeat(80)));
await handler.set("b", makeEntry("b".repeat(80)));
expect(await handler.get("a")).not.toBeNull();
await handler.set("c", makeEntry("c".repeat(80)));
expect(await handler.get("a")).not.toBeNull();
expect(await handler.get("b")).toBeNull();
expect(await handler.get("c")).not.toBeNull();
});
it("MemoryCacheHandler with max size 0 disables storage", async () => {
const { MemoryCacheHandler } = await import("../packages/vinext/src/shims/cache.js");
const handler = new MemoryCacheHandler({ cacheMaxMemorySize: 0 });
await handler.set("disabled", {
kind: "FETCH",
data: { headers: {}, body: '"cached"', url: "test" },
tags: [],
revalidate: 3600,
});
expect(await handler.get("disabled")).toBeNull();
});
it("MemoryCacheHandler respects tag invalidation", async () => {
const { MemoryCacheHandler } = await import("../packages/vinext/src/shims/cache.js");
const handler = new MemoryCacheHandler();
await handler.set(
"tagged-entry",
{
kind: "FETCH",
data: { headers: {}, body: '"cached"', url: "test" },
tags: ["fresh-tag"],
revalidate: 3600,
},
{ tags: ["fresh-tag"] },
);
// Should return the entry
let result = await handler.get("tagged-entry");
expect(result).not.toBeNull();
// Invalidate the tag
await handler.revalidateTag("fresh-tag");
// Should now return null (invalidated)
result = await handler.get("tagged-entry");
expect(result).toBeNull();
});
it("exports unstable_noStore and noStore as no-ops", async () => {
const { unstable_noStore, noStore } = await import("../packages/vinext/src/shims/cache.js");
expect(typeof unstable_noStore).toBe("function");
expect(typeof noStore).toBe("function");
// Both should run without throwing
expect(() => unstable_noStore()).not.toThrow();
expect(() => noStore()).not.toThrow();
});
it("exports cacheLife with built-in profiles", async () => {
const { cacheLife, cacheLifeProfiles } = await import("../packages/vinext/src/shims/cache.js");
expect(typeof cacheLife).toBe("function");
expect(typeof cacheLifeProfiles).toBe("object");
// Built-in profiles should exist
expect(cacheLifeProfiles).toHaveProperty("default");
expect(cacheLifeProfiles).toHaveProperty("seconds");
expect(cacheLifeProfiles).toHaveProperty("minutes");
expect(cacheLifeProfiles).toHaveProperty("hours");
expect(cacheLifeProfiles).toHaveProperty("days");
expect(cacheLifeProfiles).toHaveProperty("weeks");
expect(cacheLifeProfiles).toHaveProperty("max");
// Profile shapes
expect(cacheLifeProfiles.seconds).toEqual({ stale: 30, revalidate: 1, expire: 60 });
expect(cacheLifeProfiles.max).toEqual({ stale: 300, revalidate: 2592000, expire: 31536000 });
// Should run without throwing with valid inputs
expect(() => cacheLife("default")).not.toThrow();
expect(() => cacheLife("hours")).not.toThrow();
expect(() => cacheLife({ stale: 60, revalidate: 300, expire: 3600 })).not.toThrow();
});
it("cacheLife warns on unknown profile", async () => {
const { cacheLife } = await import("../packages/vinext/src/shims/cache.js");
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
cacheLife("nonexistent-profile");
expect(consoleWarn).toHaveBeenCalledWith(expect.stringContaining("unknown profile"));
consoleWarn.mockRestore();
});
it("cacheLife warns when expire < revalidate", async () => {
const { cacheLife } = await import("../packages/vinext/src/shims/cache.js");
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
cacheLife({ revalidate: 3600, expire: 60 });
expect(consoleWarn).toHaveBeenCalledWith(
expect.stringContaining("expire must be >= revalidate"),
);
consoleWarn.mockRestore();
});
it("cacheLife inline configs inherit default profile values for omitted fields", async () => {
const {
cacheLife,
_consumeRequestScopedCacheLife,
_peekRequestScopedCacheLife,
_runWithCacheState,
} = await import("../packages/vinext/src/shims/cache.js");
await _runWithCacheState(async () => {
cacheLife({ expire: 60 });
expect(_peekRequestScopedCacheLife()).toEqual({
revalidate: 900,
expire: 60,
});
expect(_consumeRequestScopedCacheLife()).toEqual({
revalidate: 900,
expire: 60,
});
expect(_peekRequestScopedCacheLife()).toBeNull();
});
});
it("exports cacheTag as a no-op function", async () => {
const { cacheTag } = await import("../packages/vinext/src/shims/cache.js");
expect(typeof cacheTag).toBe("function");
// Should accept multiple tags without throwing
expect(() => cacheTag("tag1", "tag2", "tag3")).not.toThrow();
});
// Ported from Next.js: test/e2e/app-dir/cache-components-errors/cache-components-unstable-deprecations.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/cache-components-errors/cache-components-unstable-deprecations.test.ts
it("unstable_cacheLife is exported as a deprecation alias for cacheLife", async () => {
const mod = await import("../packages/vinext/src/shims/cache.js");
expect(typeof mod.unstable_cacheLife).toBe("function");
// Matches the cacheLife signature: accepts a profile name or an inline config.
const error = vi.spyOn(console, "error").mockImplementation(() => {});
try {
expect(() => mod.unstable_cacheLife("hours")).not.toThrow();
expect(() =>
mod.unstable_cacheLife({ stale: 60, revalidate: 300, expire: 3600 }),
).not.toThrow();
// Next.js asserts CLI output contains "Error: `unstable_cacheLife` was recently stabilized".
// That string comes from console.error(new Error(...)) — match the same surface here.
const warned = error.mock.calls.some((args) => {
const msg = args[0];
const text = msg instanceof Error ? msg.message : String(msg ?? "");
return text.includes("`unstable_cacheLife` was recently stabilized");
});
expect(warned).toBe(true);
} finally {
error.mockRestore();
}
});
it("unstable_cacheTag is exported as a deprecation alias for cacheTag", async () => {
const mod = await import("../packages/vinext/src/shims/cache.js");
expect(typeof mod.unstable_cacheTag).toBe("function");
const error = vi.spyOn(console, "error").mockImplementation(() => {});
try {
// Matches the cacheTag signature: variadic string tags. Outside a "use cache"
// scope this is a no-op (same as cacheTag).
expect(() => mod.unstable_cacheTag("tag1", "tag2", "tag3")).not.toThrow();
const warned = error.mock.calls.some((args) => {
const msg = args[0];
const text = msg instanceof Error ? msg.message : String(msg ?? "");
return text.includes("`unstable_cacheTag` was recently stabilized");
});
expect(warned).toBe(true);
} finally {
error.mockRestore();
}
});
it("unstable_cache re-fetches when entry is stale (time-expired)", async () => {
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const fn = async () => {
callCount++;
return "value-" + callCount;
};
// Use a very short revalidate (1 second)
const cached = unstable_cache(fn, ["stale-test"], {
revalidate: 1,
});
const r1 = await cached();
expect(r1).toBe("value-1");
expect(callCount).toBe(1);
// Cached call should still return the same value
const r2 = await cached();
expect(r2).toBe("value-1");
expect(callCount).toBe(1);
// Manually expire the entry by advancing time past revalidate window.
// We do this by patching the entry's revalidateAt in the handler.
const handler = (await import("../packages/vinext/src/shims/cache.js")).getCacheHandler();
const store = (handler as any).store as Map<string, any>;
for (const [, entry] of store) {
if (entry.revalidateAt) {
entry.revalidateAt = Date.now() - 1000; // expired 1 second ago
}
}
// Next call should re-fetch because entry is now stale
const r3 = await cached();
expect(r3).toBe("value-2");
expect(callCount).toBe(2);
setCacheHandler(new MemoryCacheHandler());
});
it("unstable_cache serves stale entries and refreshes them in the background during App Router requests", async () => {
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { createRequestContext, runWithRequestContext } =
await import("../packages/vinext/src/shims/unified-request-context.js");
const waitUntilPromises: Promise<unknown>[] = [];
const setBodies: string[] = [];
let callCount = 0;
const handler: CacheHandler = {
async get(): Promise<CacheHandlerValue> {
return {
lastModified: Date.now() - 2_000,
cacheState: "stale",
value: {
kind: "FETCH",
data: {
headers: {},
body: JSON.stringify({ v: "stale-value" }),
url: "unstable_cache:stale-swr-test:[]",
},
tags: ["stale-swr"],
revalidate: 1,
},
};
},
async set(_key: string, data: IncrementalCacheValue | null) {
if (data?.kind === "FETCH") {
setBodies.push(data.data.body);
}
},
async revalidateTag(_tags: string | string[]) {},
};
setCacheHandler(handler);
const cached = unstable_cache(
async () => {
callCount++;
await new Promise((resolve) => setTimeout(resolve, 50));
return "fresh-value";
},
["stale-swr-test"],
{ tags: ["stale-swr"], revalidate: 1 },
);
// Matches Next.js App Router semantics: stale entries schedule a
// pending revalidate and return the stale response immediately.
// Source: https://github.com/vercel/next.js/blob/canary/packages/next/src/server/web/spec-extension/unstable-cache.ts
const requestContext = createRequestContext({
unstableCacheRevalidation: "background",
executionContext: {
waitUntil(promise) {
waitUntilPromises.push(promise);
},
},
});
try {
const result = await Promise.race([
runWithRequestContext(requestContext, () => cached()),
new Promise((resolve) => setTimeout(() => resolve("blocked"), 10)),
]);
expect(result).toBe("stale-value");
expect(callCount).toBe(1);
expect(waitUntilPromises).toHaveLength(1);
expect(setBodies).toEqual([]);
await Promise.all(waitUntilPromises);
expect(setBodies).toEqual([JSON.stringify({ v: "fresh-value" })]);
} finally {
setCacheHandler(new MemoryCacheHandler());
}
});
it("unstable_cache blocks on stale entries inside revalidation scopes", async () => {
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { createRequestContext, runWithRequestContext } =
await import("../packages/vinext/src/shims/unified-request-context.js");
let callCount = 0;
const handler: CacheHandler = {
async get(): Promise<CacheHandlerValue> {
return {
lastModified: Date.now() - 2_000,
cacheState: "stale",
value: {
kind: "FETCH",
data: {
headers: {},
body: JSON.stringify({ v: "stale-value" }),
url: "unstable_cache:foreground-test:[]",
},
tags: ["foreground"],
revalidate: 1,
},
};
},
async set() {},
async revalidateTag(_tags: string | string[]) {},
};
setCacheHandler(handler);
const cached = unstable_cache(
async () => {
callCount++;
return "fresh-value";
},
["foreground-test"],
{ tags: ["foreground"], revalidate: 1 },
);
// Next.js foreground-revalidates stale unstable_cache entries while
// regenerating a static/ISR page so the regenerated page stores fresh data.
// Source test: https://github.com/vercel/next.js/blob/canary/test/production/app-dir/unstable-cache-foreground-revalidate/unstable-cache-foreground-revalidate.test.ts
const requestContext = createRequestContext({
unstableCacheRevalidation: "foreground",
});
try {
await expect(runWithRequestContext(requestContext, () => cached())).resolves.toBe(
"fresh-value",
);
expect(callCount).toBe(1);
} finally {
setCacheHandler(new MemoryCacheHandler());
}
});
it("unstable_cache with no revalidate option caches indefinitely", async () => {
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const fn = async () => {
callCount++;
return "result-" + callCount;
};
// No revalidate option (should cache indefinitely, not expire at t=0)
const cached = unstable_cache(fn, ["no-revalidate-test"]);
const r1 = await cached();
expect(r1).toBe("result-1");
expect(callCount).toBe(1);
// Should return cached value (not re-fetch)
const r2 = await cached();
expect(r2).toBe("result-1");
expect(callCount).toBe(1);
// Even after "time passes", should still be cached (not stale)
const handler = (await import("../packages/vinext/src/shims/cache.js")).getCacheHandler();
const store = (handler as any).store as Map<string, any>;
for (const [, entry] of store) {
// revalidateAt should be null (indefinite) not 0 or past timestamp
expect(entry.revalidateAt).toBeNull();
}
setCacheHandler(new MemoryCacheHandler());
});
});
// ---------------------------------------------------------------------------
// "use cache" runtime tests
// ---------------------------------------------------------------------------
describe('"use cache" runtime', () => {
it("registerCachedFunction caches return values", async () => {
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
// Reset state
const { setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const fn = async (x: number) => {
callCount++;
return { result: x * 2 };
};
const cached = registerCachedFunction(fn, "test:double");
const r1 = await cached(5);
expect(r1).toEqual({ result: 10 });
expect(callCount).toBe(1);
// Second call with same args — should be cached
const r2 = await cached(5);
expect(r2).toEqual({ result: 10 });
expect(callCount).toBe(1); // Not called again
// Different args — cache miss
const r3 = await cached(7);
expect(r3).toEqual({ result: 14 });
expect(callCount).toBe(2);
});
it("scopes shared cache entries by build ID", async () => {
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
const previousBuildId = process.env.__VINEXT_BUILD_ID;
try {
let callCount = 0;
process.env.__VINEXT_BUILD_ID = "build-one";
const firstBuild = registerCachedFunction(async () => {
callCount++;
return { version: "old" };
}, "test:same-id");
expect(await firstBuild()).toEqual({ version: "old" });
expect(callCount).toBe(1);
process.env.__VINEXT_BUILD_ID = "build-two";
const secondBuild = registerCachedFunction(async () => {
callCount++;
return { version: "new" };
}, "test:same-id");
expect(await secondBuild()).toEqual({ version: "new" });
expect(callCount).toBe(2);
} finally {
if (previousBuildId === undefined) {
delete process.env.__VINEXT_BUILD_ID;
} else {
process.env.__VINEXT_BUILD_ID = previousBuildId;
}
}
});
it("scopes shared cache entries by deployment ID when available", async () => {
// Ported from Next.js: test/production/app-dir/use-cache-cross-deployment/use-cache-cross-deployment.test.ts
// https://github.com/vercel/next.js/blob/07f76411b07de9417d4a6b816f3137cafe1045fc/test/production/app-dir/use-cache-cross-deployment/use-cache-cross-deployment.test.ts
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
const previousBuildId = process.env.__VINEXT_BUILD_ID;
const previousDeploymentId = process.env.__VINEXT_DEPLOYMENT_ID;
const previousNextDeploymentId = process.env.NEXT_DEPLOYMENT_ID;
try {
process.env.__VINEXT_BUILD_ID = "stable-build";
delete process.env.NEXT_DEPLOYMENT_ID;
let callCount = 0;
const cached = registerCachedFunction(async () => {
callCount++;
return { count: callCount };
}, "test:deployment-id");
process.env.__VINEXT_DEPLOYMENT_ID = "deployment-one";
expect(await cached()).toEqual({ count: 1 });
expect(await cached()).toEqual({ count: 1 });
process.env.__VINEXT_DEPLOYMENT_ID = "deployment-two";
expect(await cached()).toEqual({ count: 2 });
expect(await cached()).toEqual({ count: 2 });
delete process.env.__VINEXT_DEPLOYMENT_ID;
expect(await cached()).toEqual({ count: 3 });
expect(await cached()).toEqual({ count: 3 });
} finally {
if (previousBuildId === undefined) {
delete process.env.__VINEXT_BUILD_ID;
} else {
process.env.__VINEXT_BUILD_ID = previousBuildId;
}
if (previousDeploymentId === undefined) {
delete process.env.__VINEXT_DEPLOYMENT_ID;
} else {
process.env.__VINEXT_DEPLOYMENT_ID = previousDeploymentId;
}
if (previousNextDeploymentId === undefined) {
delete process.env.NEXT_DEPLOYMENT_ID;
} else {
process.env.NEXT_DEPLOYMENT_ID = previousNextDeploymentId;
}
}
});
it("uses NEXT_DEPLOYMENT_ID when the internal define is empty", async () => {
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
const previousBuildId = process.env.__VINEXT_BUILD_ID;
const previousDeploymentId = process.env.__VINEXT_DEPLOYMENT_ID;
const previousNextDeploymentId = process.env.NEXT_DEPLOYMENT_ID;
try {
process.env.__VINEXT_BUILD_ID = "stable-build";
process.env.__VINEXT_DEPLOYMENT_ID = "";
let callCount = 0;
const cached = registerCachedFunction(async () => {
callCount++;
return { count: callCount };
}, "test:next-deployment-id");
process.env.NEXT_DEPLOYMENT_ID = "env-deployment-one";
expect(await cached()).toEqual({ count: 1 });
expect(await cached()).toEqual({ count: 1 });
process.env.NEXT_DEPLOYMENT_ID = "env-deployment-two";
expect(await cached()).toEqual({ count: 2 });
expect(await cached()).toEqual({ count: 2 });
expect(callCount).toBe(2);
} finally {
if (previousBuildId === undefined) {
delete process.env.__VINEXT_BUILD_ID;
} else {
process.env.__VINEXT_BUILD_ID = previousBuildId;
}
if (previousDeploymentId === undefined) {
delete process.env.__VINEXT_DEPLOYMENT_ID;
} else {
process.env.__VINEXT_DEPLOYMENT_ID = previousDeploymentId;
}
if (previousNextDeploymentId === undefined) {
delete process.env.NEXT_DEPLOYMENT_ID;
} else {
process.env.NEXT_DEPLOYMENT_ID = previousNextDeploymentId;
}
}
});
it("registerCachedFunction respects cacheLife inside cached function", async () => {
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { setCacheHandler, MemoryCacheHandler, cacheLife } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const fn = async () => {
cacheLife("seconds"); // revalidate: 1s
callCount++;
return { ts: Date.now() };
};
const cached = registerCachedFunction(fn, "test:cachelife");
await cached();
expect(callCount).toBe(1);
// Immediate second call — cached
await cached();
expect(callCount).toBe(1);
});
it("registerCachedFunction collects cacheTag", async () => {
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { setCacheHandler, MemoryCacheHandler, cacheTag } =
await import("../packages/vinext/src/shims/cache.js");
const handler = new MemoryCacheHandler();
setCacheHandler(handler);
const fn = async () => {
cacheTag("my-tag", "another-tag");
return { data: "tagged" };
};
const cached = registerCachedFunction(fn, "test:tags");
await cached();
// The cache entry should have tags
const entry = await handler.get("use-cache:test:tags");
expect(entry).not.toBeNull();
expect(entry?.value).toHaveProperty("kind", "FETCH");
if (entry?.value && entry.value.kind === "FETCH") {
expect(entry.value.tags).toContain("my-tag");
expect(entry.value.tags).toContain("another-tag");
}
});
it("revalidateTag invalidates cached entries", async () => {
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { setCacheHandler, MemoryCacheHandler, cacheTag, revalidateTag } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const fn = async () => {
cacheTag("invalidate-me");
callCount++;
return { count: callCount };
};
const cached = registerCachedFunction(fn, "test:invalidate");
const r1 = await cached();
expect(r1).toEqual({ count: 1 });
expect(callCount).toBe(1);
// Cached
const r2 = await cached();
expect(r2).toEqual({ count: 1 });
expect(callCount).toBe(1);
// Invalidate the tag
await revalidateTag("invalidate-me");
// Should re-execute
const r3 = await cached();
expect(r3).toEqual({ count: 2 });
expect(callCount).toBe(2);
});
it("nested cacheTag bubbles to the outer cache entry even when the inner HITs", async () => {
// Regression for issue #1453 (nested-HIT path): when an inner "use cache"
// function HITs the data cache while nested inside an outer cache that is
// being (re)generated, the inner entry's stored tags must bubble into the
// outer entry's tags. Otherwise revalidateTag("inner-tag") evicts the inner
// entry but leaves the outer page/route-handler ISR entry stale.
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { setCacheHandler, MemoryCacheHandler, cacheTag } =
await import("../packages/vinext/src/shims/cache.js");
const handler = new MemoryCacheHandler();
setCacheHandler(handler);
const inner = registerCachedFunction(async () => {
cacheTag("inner-tag");
return { inner: true };
}, "test:nested-inner");
const outer = registerCachedFunction(async () => {
cacheTag("outer-tag");
await inner();
return { outer: true };
}, "test:nested-outer");
// Warm the inner entry on its own so the next outer call HITs it.
await inner();
// Now build the outer entry; its body re-runs `inner()`, which HITs.
await outer();
const outerEntry = await handler.get("use-cache:test:nested-outer");
expect(outerEntry?.value).toHaveProperty("kind", "FETCH");
if (outerEntry?.value && outerEntry.value.kind === "FETCH") {
// The outer entry must carry both its own tag and the nested inner tag.
expect(outerEntry.value.tags).toContain("outer-tag");
expect(outerEntry.value.tags).toContain("inner-tag");
}
});
it("private variant uses per-request cache", async () => {
const { registerCachedFunction, clearPrivateCache } =
await import("../packages/vinext/src/shims/cache-runtime.js");
let callCount = 0;
const fn = async () => {
callCount++;
return { count: callCount };
};
const cached = registerCachedFunction(fn, "test:private", "private");
const r1 = await cached();
expect(r1).toEqual({ count: 1 });
// Same request — cached
const r2 = await cached();
expect(r2).toEqual({ count: 1 });
// Clear private cache (simulates new request)
clearPrivateCache();
// Should re-execute
const r3 = await cached();
expect(r3).toEqual({ count: 2 });
});
it("private variant marks prerender output dynamic", async () => {
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { consumeDynamicUsage } = await import("../packages/vinext/src/shims/headers.js");
const { createRequestContext, runWithRequestContext } =
await import("../packages/vinext/src/shims/unified-request-context.js");
// Ported from Next.js: "use cache: private" is dynamic in prerendering contexts.
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/use-cache/use-cache-wrapper.ts
const previousPrerender = process.env.VINEXT_PRERENDER;
process.env.VINEXT_PRERENDER = "1";
try {
await runWithRequestContext(createRequestContext(), async () => {
const cached = registerCachedFunction(
async () => "private",
"test:private-prerender",
"private",
);
await cached();
expect(consumeDynamicUsage()).toBe(true);
});
} finally {
if (previousPrerender === undefined) {
delete process.env.VINEXT_PRERENDER;
} else {
process.env.VINEXT_PRERENDER = previousPrerender;
}
}
});
it('rejects "use cache: private" nested inside public "use cache"', async () => {
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { cookies, setHeadersContext } = await import("../packages/vinext/src/shims/headers.js");
const { createRequestContext, runWithRequestContext } =
await import("../packages/vinext/src/shims/unified-request-context.js");
// Ported from Next.js: "use cache: private" must not run inside public "use cache".
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/use-cache/use-cache-wrapper.ts
await runWithRequestContext(createRequestContext(), async () => {
try {
setHeadersContext({
headers: new Headers({ cookie: "test-cookie=leaked" }),
cookies: new Map([["test-cookie", "leaked"]]),
});
const inner = registerCachedFunction(
async () => (await cookies()).get("test-cookie")?.value,
"test:private-nested-inner",
"private",
);
const outer = registerCachedFunction(async () => inner(), "test:private-nested-outer", "");
await expect(outer()).rejects.toThrow(
/"use cache: private" must not be used within "use cache"/,
);
} finally {
setHeadersContext(null);
}
});
});
it("cacheLife minimum-wins rule applies", async () => {
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { setCacheHandler, MemoryCacheHandler, cacheLife } =
await import("../packages/vinext/src/shims/cache.js");
const handler = new MemoryCacheHandler();
setCacheHandler(handler);
const fn = async () => {
cacheLife("hours"); // revalidate: 3600
cacheLife("seconds"); // revalidate: 1 — this should win
return { data: "min-wins" };
};
const cached = registerCachedFunction(fn, "test:min-wins");
await cached();
// The entry should have the minimum revalidate (1 second from "seconds" profile)
const entry = await handler.get("use-cache:test:min-wins");
expect(entry).not.toBeNull();
if (entry?.value && entry.value.kind === "FETCH") {
expect(entry.value.revalidate).toBe(1);
}
});
it("getCacheContext returns null outside cache function", async () => {
const { getCacheContext } = await import("../packages/vinext/src/shims/cache-runtime.js");
expect(getCacheContext()).toBeNull();
});
it("consistent cache keys for same objects regardless of key order", async () => {
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const fn = async (_opts: Record<string, unknown>) => {
callCount++;
return { result: "ok" };
};
const cached = registerCachedFunction(fn, "test:stable-key");
// Different key order, same content — should be same cache key
await cached({ b: 2, a: 1 });
expect(callCount).toBe(1);
await cached({ a: 1, b: 2 });
expect(callCount).toBe(1); // Same cache key, still cached
});
it("cached function with no args works correctly", async () => {
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const fn = async () => {
callCount++;
return { hello: "world" };
};
const cached = registerCachedFunction(fn, "test:no-args");
const r1 = await cached();
const r2 = await cached();
expect(r1).toEqual({ hello: "world" });
expect(r2).toEqual({ hello: "world" });
expect(callCount).toBe(1);
});
it("preserves the original function arity on the cached wrapper", async () => {
// The wrapper is declared as `(...args)` (arity 0). Callers like
// `resolveModuleMetadata` rely on `fn.length` to decide whether to pass
// optional arguments (e.g. the metadata `parent`), so the wrapper must
// expose the original arity rather than 0.
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
const zero = registerCachedFunction(async () => ({}), "test:arity-0");
const one = registerCachedFunction(async (_a: unknown) => ({}), "test:arity-1");
const two = registerCachedFunction(async (_a: unknown, _b: unknown) => ({}), "test:arity-2");
expect(zero.length).toBe(0);
expect(one.length).toBe(1);
expect(two.length).toBe(2);
});
it("falls back to JSON when RSC module is unavailable (test environment)", async () => {
// In vitest, @vitejs/plugin-rsc/react/rsc is not available (no Vite RSC
// environment). The runtime should gracefully fall back to JSON.stringify
// for cache values and stableStringify for cache keys.
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const handler = new MemoryCacheHandler();
setCacheHandler(handler);
const fn = async (x: number) => ({ doubled: x * 2 });
const cached = registerCachedFunction(fn, "test:json-fallback");
const r1 = await cached(3);
expect(r1).toEqual({ doubled: 6 });
// Verify the stored value is JSON (no x-vinext-rsc header)
// stableStringify wraps args as an array: [3]
const entry = await handler.get("use-cache:test:json-fallback:[3]");
expect(entry).not.toBeNull();
if (entry?.value && entry.value.kind === "FETCH") {
expect(entry.value.data.headers["x-vinext-rsc"]).toBeUndefined();
expect(JSON.parse(entry.value.data.body)).toEqual({ doubled: 6 });
}
});
it("skips caching for non-serializable args (functions)", async () => {
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const fn = async (_cb: () => void) => {
callCount++;
return { called: true };
};
const cached = registerCachedFunction(fn, "test:fn-arg");
// Functions can't be serialized — should execute every time (no caching)
await cached(() => {});
await cached(() => {});
expect(callCount).toBe(2);
});
it("produces different cache entries for Promise-augmented params with different values", async () => {
// Regression test: Next.js 16 params are created via
// Object.assign(Promise.resolve(params), params) — a "thenable object".
// encodeReply with temporaryReferences treats Promises as temp refs,
// which excluded the actual param values from the cache key.
// This caused all dynamic route pages with "use cache" to share one
// cache entry (e.g., /layouts/sports showed /layouts/electronics data).
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
// Simulates a page component: async function Page({ params }) { ... }
const fn = async (props: { params: any }) => {
callCount++;
const p = typeof props.params.then === "function" ? await props.params : props.params;
return { section: p.section, data: `data-for-${p.section}` };
};
const cached = registerCachedFunction(fn, "test:thenable-params");
// Create Promise-augmented params (same pattern as entries/app-rsc-entry.ts)
const electronicsParams = { section: "electronics" };
const asyncElectronics = Object.assign(Promise.resolve(electronicsParams), electronicsParams);
const sportsParams = { section: "sports" };
const asyncSports = Object.assign(Promise.resolve(sportsParams), sportsParams);
// First call — electronics
const r1 = await cached({ params: asyncElectronics });
expect(r1).toEqual({ section: "electronics", data: "data-for-electronics" });
expect(callCount).toBe(1);
// Second call with SAME params — should be cached
const asyncElectronics2 = Object.assign(Promise.resolve({ section: "electronics" }), {
section: "electronics",
});
const r2 = await cached({ params: asyncElectronics2 });
expect(r2).toEqual({ section: "electronics", data: "data-for-electronics" });
expect(callCount).toBe(1); // Cache hit
// Third call with DIFFERENT params — must be a cache MISS
const r3 = await cached({ params: asyncSports });
expect(r3).toEqual({ section: "sports", data: "data-for-sports" });
expect(callCount).toBe(2); // Must have called the function again!
});
it("does not read page searchParams while deriving a use cache key", async () => {
const { registerCachedFunction, markAppPagePropsForUseCache } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { makeThenableParams } = await import("../packages/vinext/src/shims/thenable-params.js");
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const observeSearchParams = vi.fn();
const cached = registerCachedFunction(
async (props: {
params: Promise<{ slug: string }>;
searchParams: Promise<Record<string, unknown>>;
}) => {
callCount++;
const params = await props.params;
return { slug: params.slug };
},
"test:page-props-searchparams",
);
const first = await cached(
markAppPagePropsForUseCache({
params: makeThenableParams({ slug: "same" }),
searchParams: makeThenableParams(
{ q: "first" },
{ observeParamAccess: observeSearchParams },
),
}),
);
expect(first).toEqual({ slug: "same" });
expect(callCount).toBe(1);
expect(observeSearchParams).not.toHaveBeenCalled();
const second = await cached(
markAppPagePropsForUseCache({
params: makeThenableParams({ slug: "same" }),
searchParams: makeThenableParams(
{ q: "second" },
{ observeParamAccess: observeSearchParams },
),
}),
);
expect(second).toEqual({ slug: "same" });
expect(callCount).toBe(1);
expect(observeSearchParams).not.toHaveBeenCalled();
});
it("omits page default export searchParams from use cache keys via transform metadata", async () => {
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { makeThenableParams } = await import("../packages/vinext/src/shims/thenable-params.js");
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const observeSearchParams = vi.fn();
const cached = registerCachedFunction(
async (props: {
params: Promise<{ slug: string }>;
searchParams: Promise<Record<string, unknown>>;
}) => {
callCount++;
const params = await props.params;
return { slug: params.slug };
},
"/fixture/app/cached/page.tsx:default",
"",
{ appPageDefaultExport: true },
);
const first = await cached({
params: makeThenableParams({ slug: "same" }),
searchParams: makeThenableParams({ q: "first" }, { observeParamAccess: observeSearchParams }),
});
expect(first).toEqual({ slug: "same" });
expect(callCount).toBe(1);
expect(observeSearchParams).not.toHaveBeenCalled();
const second = await cached({
params: makeThenableParams({ slug: "same" }),
searchParams: makeThenableParams(
{ q: "second" },
{ observeParamAccess: observeSearchParams },
),
});
expect(second).toEqual({ slug: "same" });
expect(callCount).toBe(1);
expect(observeSearchParams).not.toHaveBeenCalled();
});
it('rejects app page searchParams access inside page default "use cache"', async () => {
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { makeThenableParams } = await import("../packages/vinext/src/shims/thenable-params.js");
const { makeObservedAppPageSearchParamsThenable } =
await import("../packages/vinext/src/server/app-page-search-params-observation.js");
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const cached = registerCachedFunction(
async (props: {
params: Promise<{ slug: string }>;
searchParams: Promise<Record<string, string | string[]>>;
}) => {
callCount++;
const searchParams = await props.searchParams;
return { q: searchParams.q };
},
"/fixture/app/cached/page.tsx:default",
"",
{ appPageDefaultExport: true },
);
await expect(
cached({
params: makeThenableParams({ slug: "same" }),
searchParams: makeObservedAppPageSearchParamsThenable({ q: "first" }),
}),
).rejects.toThrow(/cannot be called inside "use cache"/);
expect(callCount).toBe(1);
await expect(
cached({
params: makeThenableParams({ slug: "same" }),
searchParams: makeObservedAppPageSearchParamsThenable({ q: "second" }),
}),
).rejects.toThrow(/cannot be called inside "use cache"/);
expect(callCount).toBe(2);
});
it('does not store a page default "use cache" result after caught searchParams access', async () => {
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { makeThenableParams } = await import("../packages/vinext/src/shims/thenable-params.js");
const { makeObservedAppPageSearchParamsThenable } =
await import("../packages/vinext/src/server/app-page-search-params-observation.js");
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const cached = registerCachedFunction(
async (props: {
params: Promise<{ slug: string }>;
searchParams: Promise<Record<string, string | string[]>>;
}) => {
callCount++;
try {
const searchParams = await props.searchParams;
return { q: searchParams.q };
} catch {
return { q: `caught-${callCount}` };
}
},
"/fixture/app/cached/caught/page.tsx:default",
"",
{ appPageDefaultExport: true },
);
await expect(
cached({
params: makeThenableParams({ slug: "same" }),
searchParams: makeObservedAppPageSearchParamsThenable({ q: "first" }),
}),
).rejects.toThrow(/cannot be called inside "use cache"/);
expect(callCount).toBe(1);
await expect(
cached({
params: makeThenableParams({ slug: "same" }),
searchParams: makeObservedAppPageSearchParamsThenable({ q: "second" }),
}),
).rejects.toThrow(/cannot be called inside "use cache"/);
expect(callCount).toBe(2);
});
it("does not infer page props from the cache id alone", async () => {
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { makeThenableParams } = await import("../packages/vinext/src/shims/thenable-params.js");
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const cached = registerCachedFunction(
async (props: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ q: string }>;
}) => {
callCount++;
const searchParams = await props.searchParams;
return { q: searchParams.q };
},
"/fixture/app/cached/page.tsx:default",
);
await expect(
cached({
params: makeThenableParams({ slug: "same" }),
searchParams: makeThenableParams({ q: "first" }),
}),
).resolves.toEqual({ q: "first" });
expect(callCount).toBe(1);
await expect(
cached({
params: makeThenableParams({ slug: "same" }),
searchParams: makeThenableParams({ q: "second" }),
}),
).resolves.toEqual({ q: "second" });
expect(callCount).toBe(2);
});
it("keeps arbitrary params/searchParams objects distinct in use cache keys", async () => {
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { makeThenableParams } = await import("../packages/vinext/src/shims/thenable-params.js");
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const cached = registerCachedFunction(
async (props: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ q: string }>;
}) => {
callCount++;
const searchParams = await props.searchParams;
return { q: searchParams.q };
},
"test:userland-params-searchparams",
);
await expect(
cached({
params: makeThenableParams({ slug: "same" }),
searchParams: makeThenableParams({ q: "first" }),
}),
).resolves.toEqual({ q: "first" });
expect(callCount).toBe(1);
await expect(
cached({
params: makeThenableParams({ slug: "same" }),
searchParams: makeThenableParams({ q: "second" }),
}),
).resolves.toEqual({ q: "second" });
expect(callCount).toBe(2);
});
// -----------------------------------------------------------------------
// Nested-dynamic cache life error tests — ported from Next.js PR #93707
// https://github.com/vercel/next.js/pull/93707
// -----------------------------------------------------------------------
// These tests exercise the prerender-only throw path. The runtime check
// is gated on `process.env.VINEXT_PRERENDER === "1"` (or NODE_ENV ===
// "development") to match Next.js, which only throws when the work unit
// type is `prerender` or `request` in dev. We set/restore the flag around
// each test rather than relying on test ordering.
function withPrerenderFlag<T>(fn: () => Promise<T>): Promise<T> {
const previous = process.env.VINEXT_PRERENDER;
process.env.VINEXT_PRERENDER = "1";
return fn().finally(() => {
if (previous === undefined) delete process.env.VINEXT_PRERENDER;
else process.env.VINEXT_PRERENDER = previous;
});
}
it("throws nested-dynamic error when inner cache has revalidate:0 and outer has no explicit cacheLife (prerender)", async () => {
await withPrerenderFlag(async () => {
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { setCacheHandler, MemoryCacheHandler, cacheLife } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
const innerFn = async () => {
cacheLife({ revalidate: 0 });
return "inner";
};
const innerCached = registerCachedFunction(innerFn, "test:nested-rev0-inner");
const outerFn = async () => {
const result = await innerCached();
return `outer-${result}`;
};
const outerCached = registerCachedFunction(outerFn, "test:nested-rev0-outer");
// Top-level call — outer has no explicit cacheLife, inner has revalidate:0
// This should throw a nested-dynamic error
let thrown: Error | undefined;
try {
await outerCached();
} catch (e) {
thrown = e as Error;
}
expect(thrown).toBeDefined();
expect(thrown!.message).toContain("revalidate");
expect(thrown!.message).toContain('"use cache"');
expect(thrown!.message).toContain("not allowed");
expect(thrown!.cause).toBeDefined();
expect((thrown!.cause as Error).message).toContain("dynamic cache life");
expect((thrown!.cause as Error).name).toContain("Nested dynamic");
});
});
it("throws nested-dynamic error when inner cache has short expire and outer has no explicit cacheLife (prerender)", async () => {
await withPrerenderFlag(async () => {
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { setCacheHandler, MemoryCacheHandler, cacheLife } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
const innerFn = async () => {
cacheLife({ expire: 60 }); // 1 minute, under 5 minute threshold
return "inner";
};
const innerCached = registerCachedFunction(innerFn, "test:nested-short-inner");
const outerFn = async () => {
const result = await innerCached();
return `outer-${result}`;
};
const outerCached = registerCachedFunction(outerFn, "test:nested-short-outer");
let thrown: Error | undefined;
try {
await outerCached();
} catch (e) {
thrown = e as Error;
}
expect(thrown).toBeDefined();
expect(thrown!.message).toContain("expire");
expect(thrown!.message).toContain('"use cache"');
expect(thrown!.message).toContain("not allowed");
expect(thrown!.cause).toBeDefined();
expect((thrown!.cause as Error).message).toContain("dynamic cache life");
});
});
it("does not throw outside prerender/dev even with a nested dynamic inner cache", async () => {
// Verifies the prerender gate: in production runtime SSR (no
// VINEXT_PRERENDER, NODE_ENV !== "development"), nesting a dynamic
// inner inside a non-cacheLife() outer must NOT throw. This matches
// Next.js, which only surfaces the error during prerender or dev
// requests (see use-cache-wrapper.ts).
// `NODE_ENV` is typed read-only on `process.env`; cast to the indexable
// signature to allow temporary mutation in this test.
const env = process.env as Record<string, string | undefined>;
const previousPrerender = env.VINEXT_PRERENDER;
const previousNodeEnv = env.NODE_ENV;
delete env.VINEXT_PRERENDER;
env.NODE_ENV = "production";
try {
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { setCacheHandler, MemoryCacheHandler, cacheLife } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
const innerCached = registerCachedFunction(async () => {
cacheLife({ revalidate: 0 });
return "inner";
}, "test:nested-prod-inner");
// No explicit cacheLife on the outer — in prerender this would throw,
// but in production runtime SSR it must just run and not be cached.
const outerCached = registerCachedFunction(
async () => `outer-${await innerCached()}`,
"test:nested-prod-outer",
);
const result = await outerCached();
expect(result).toBe("outer-inner");
} finally {
if (previousPrerender === undefined) delete env.VINEXT_PRERENDER;
else env.VINEXT_PRERENDER = previousPrerender;
if (previousNodeEnv === undefined) delete env.NODE_ENV;
else env.NODE_ENV = previousNodeEnv;
}
});
it("does throw in development mode even without VINEXT_PRERENDER set", async () => {
// Companion to the production test above: verifies the *other side* of
// the gate. With `NODE_ENV === "development"` (and no VINEXT_PRERENDER),
// the throw must still fire. This catches a hypothetical regression
// where the gate is broken to e.g. only check VINEXT_PRERENDER, or to
// check `!== "production"` (which would falsely fire in Vitest's
// default `NODE_ENV=test`). Together with the production test, this
// pins down both branches of the gate.
const env = process.env as Record<string, string | undefined>;
const previousPrerender = env.VINEXT_PRERENDER;
const previousNodeEnv = env.NODE_ENV;
delete env.VINEXT_PRERENDER;
env.NODE_ENV = "development";
try {
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { setCacheHandler, MemoryCacheHandler, cacheLife } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
const innerCached = registerCachedFunction(async () => {
cacheLife({ revalidate: 0 });
return "inner";
}, "test:nested-dev-inner");
const outerCached = registerCachedFunction(
async () => `outer-${await innerCached()}`,
"test:nested-dev-outer",
);
let thrown: Error | undefined;
try {
await outerCached();
} catch (e) {
thrown = e as Error;
}
expect(thrown).toBeDefined();
expect(thrown!.message).toContain("revalidate");
// In dev, the message should say "in development", not
// "during prerendering", since no prerendering is happening.
expect(thrown!.message).toContain("in development");
expect(thrown!.message).not.toContain("during prerendering");
} finally {
if (previousPrerender === undefined) delete env.VINEXT_PRERENDER;
else env.VINEXT_PRERENDER = previousPrerender;
if (previousNodeEnv === undefined) delete env.NODE_ENV;
else env.NODE_ENV = previousNodeEnv;
}
});
it("does not throw when outer cache has explicit cacheLife", async () => {
await withPrerenderFlag(async () => {
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { setCacheHandler, MemoryCacheHandler, cacheLife } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
const innerFn = async () => {
cacheLife({ revalidate: 0 });
return "inner";
};
const innerCached = registerCachedFunction(innerFn, "test:nested-ok-inner");
const outerFn = async () => {
cacheLife({ revalidate: 60 }); // explicit on outer
const result = await innerCached();
return `outer-${result}`;
};
const outerCached = registerCachedFunction(outerFn, "test:nested-ok-outer");
// Should NOT throw — outer made an explicit choice
const result = await outerCached();
expect(result).toBe("outer-inner");
});
});
it("explicit cacheLife on outer suppresses throw but minimum-wins still applies to effective cache life", async () => {
// Documents a potentially surprising behavior: when the outer has an
// explicit `cacheLife({ revalidate: 60 })` and the inner is dynamic
// (`revalidate: 0`), the throw is suppressed (outer made an explicit
// choice), but the outer's effective `revalidate` resolves to 0 via
// minimum-wins (because the inner's resolved life is pushed into the
// outer's `lifeConfigs`). MemoryCacheHandler treats `revalidate: 0` as
// not cached, so the outer function executes on every call. This matches
// Next.js's `propagateCacheLifeAndTagsToRevalidateStore` semantics —
// the outer's explicit cacheLife() opts it out of the throw, but does
// not override the inner's minimum-wins contribution.
await withPrerenderFlag(async () => {
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { setCacheHandler, MemoryCacheHandler, cacheLife } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
let innerCalls = 0;
let outerCalls = 0;
const innerCached = registerCachedFunction(async () => {
innerCalls++;
cacheLife({ revalidate: 0 });
return "inner";
}, "test:min-wins-inner");
const outerCached = registerCachedFunction(async () => {
outerCalls++;
cacheLife({ revalidate: 60 });
return `outer-${await innerCached()}`;
}, "test:min-wins-outer");
const first = await outerCached();
const second = await outerCached();
expect(first).toBe("outer-inner");
expect(second).toBe("outer-inner");
// The outer function must execute twice — minimum-wins resolved its
// effective revalidate to 0, so the result was not cached despite the
// explicit `cacheLife({ revalidate: 60 })`.
expect(outerCalls).toBe(2);
// Inner is also dynamic (revalidate: 0) so it re-executes too.
expect(innerCalls).toBe(2);
});
});
it("keeps first dynamic child as cause when multiple nested caches are dynamic", async () => {
await withPrerenderFlag(async () => {
const { registerCachedFunction } =
await import("../packages/vinext/src/shims/cache-runtime.js");
const { setCacheHandler, MemoryCacheHandler, cacheLife } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
const innerA = registerCachedFunction(async () => {
cacheLife({ revalidate: 0 });
return "a";
}, "test:first-child-a");
const innerB = registerCachedFunction(async () => {
cacheLife({ expire: 120 }); // also dynamic
return "b";
}, "test:first-child-b");
const outerFn = async () => {
await innerA();
await innerB();
return "outer";
};
const outerCached = registerCachedFunction(outerFn, "test:first-child-outer");
let thrown: Error | undefined;
try {
await outerCached();
} catch (e) {
thrown = e as Error;
}
expect(thrown).toBeDefined();
expect(thrown!.cause).toBeDefined();
// The cause should be from the FIRST dynamic child (innerA), which set
// `revalidate: 0`. The outer error message reflects the first-firing
// dynamic field — if innerA's revalidate propagated first, the outer
// throws the revalidate (not expire) error, even though innerB's
// `expire: 120` is also below DYNAMIC_EXPIRE. This rigorously verifies
// first-child-wins semantics: dynamicNestedCacheError uses `??=` so the
// first dynamic child's eager error is preserved as the cause, and the
// revalidate check fires before the expire check in the outer.
expect(thrown!.message).toContain("revalidate");
expect(thrown!.message).not.toContain("expire");
});
});
});
describe("replyToCacheKey deterministic hashing", () => {
it("returns string replies as-is", async () => {
const { replyToCacheKey } = await import("../packages/vinext/src/shims/cache-runtime.js");
expect(await replyToCacheKey("hello")).toBe("hello");
expect(await replyToCacheKey("")).toBe("");
});
it("produces stable hash for FormData with string entries", async () => {
const { replyToCacheKey } = await import("../packages/vinext/src/shims/cache-runtime.js");
const fd1 = new FormData();
fd1.append("a", "1");
fd1.append("b", "2");
const fd2 = new FormData();
fd2.append("a", "1");
fd2.append("b", "2");
const key1 = await replyToCacheKey(fd1);
const key2 = await replyToCacheKey(fd2);
expect(key1).toBe(key2);
});
it("produces stable hash regardless of entry insertion order", async () => {
const { replyToCacheKey } = await import("../packages/vinext/src/shims/cache-runtime.js");
const fd1 = new FormData();
fd1.append("b", "2");
fd1.append("a", "1");
const fd2 = new FormData();
fd2.append("a", "1");
fd2.append("b", "2");
const key1 = await replyToCacheKey(fd1);
const key2 = await replyToCacheKey(fd2);
expect(key1).toBe(key2);
});
it("produces stable hash for FormData with Blob entries", async () => {
const { replyToCacheKey } = await import("../packages/vinext/src/shims/cache-runtime.js");
const blob = new Blob([new Uint8Array([1, 2, 3])], { type: "application/octet-stream" });
const fd1 = new FormData();
fd1.append("data", blob);
const fd2 = new FormData();
fd2.append("data", blob);
const key1 = await replyToCacheKey(fd1);
const key2 = await replyToCacheKey(fd2);
expect(key1).toBe(key2);
});
it("produces different hashes for different FormData content", async () => {
const { replyToCacheKey } = await import("../packages/vinext/src/shims/cache-runtime.js");
const fd1 = new FormData();
fd1.append("a", "1");
const fd2 = new FormData();
fd2.append("a", "2");
const key1 = await replyToCacheKey(fd1);
const key2 = await replyToCacheKey(fd2);
expect(key1).not.toBe(key2);
});
});
describe("middleware runner", () => {
it("findMiddlewareFile finds middleware.ts at project root", async () => {
const { findMiddlewareFile } = await import("../packages/vinext/src/server/middleware.js");
const { createValidFileMatcher } =
await import("../packages/vinext/src/routing/file-matcher.js");
// pages-basic fixture has middleware.ts
const result = findMiddlewareFile(FIXTURE_DIR, createValidFileMatcher());
expect(result).not.toBeNull();
expect(result).toContain("middleware.ts");
});
it("findMiddlewareFile returns null when no middleware exists", async () => {
const { findMiddlewareFile } = await import("../packages/vinext/src/server/middleware.js");
const { createValidFileMatcher } =
await import("../packages/vinext/src/routing/file-matcher.js");
const result = findMiddlewareFile(
"/tmp/nonexistent-dir-" + Date.now(),
createValidFileMatcher(),
);
expect(result).toBeNull();
});
it("findMiddlewareFile does not find middleware.ts when ts is not a configured pageExtension", async () => {
const { findMiddlewareFile } = await import("../packages/vinext/src/server/middleware.js");
const { createValidFileMatcher } =
await import("../packages/vinext/src/routing/file-matcher.js");
// FIXTURE_DIR has middleware.ts — restricting to mdx only means it should not match
const result = findMiddlewareFile(FIXTURE_DIR, createValidFileMatcher(["mdx"]));
expect(result).toBeNull();
});
it("findMiddlewareFile emits a deprecation warning when middleware.ts is found", async () => {
const { findMiddlewareFile } = await import("../packages/vinext/src/server/middleware.js");
const { createValidFileMatcher } =
await import("../packages/vinext/src/routing/file-matcher.js");
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
findMiddlewareFile(FIXTURE_DIR, createValidFileMatcher());
expect(warnSpy).toHaveBeenCalledOnce();
// Match Next.js canonical wording: deprecation-warnings and
// app-middleware e2e suites assert on this exact phrase via toContain.
// See .nextjs-ref/packages/next/src/build/index.ts (the Log.warnOnce call
// adjacent to MIDDLEWARE_FILENAME / PROXY_FILENAME).
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
'The "middleware" file convention is deprecated. Please use "proxy" instead.',
),
);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("https://nextjs.org/docs/messages/middleware-to-proxy"),
);
} finally {
warnSpy.mockRestore();
}
});
it("runs middleware when its matcher is rejected as unsafe", async () => {
const { runMiddleware } = await import("../packages/vinext/src/server/middleware.js");
let middlewareCalled = false;
const mockRunner = {
import: async () => ({
default: () => {
middlewareCalled = true;
return new Response("Forbidden", { status: 403 });
},
// lgtm[js/redos] — deliberate pathological regex to exercise fail-closed behavior
config: { matcher: "/(?:[a-z]+/)*admin" },
}),
};
const result = await runMiddleware(
mockRunner as any,
"/fake/middleware.ts",
new Request("http://localhost/admin"),
);
expect(middlewareCalled).toBe(true);
expect(result.continue).toBe(false);
expect(result.response?.status).toBe(403);
expect(await result.response?.text()).toBe("Forbidden");
});
it("findMiddlewareFile rejects projects with both proxy.ts and middleware.ts", async () => {
const fs = await import("node:fs");
const path = await import("node:path");
const os = await import("node:os");
const { findMiddlewareFile } = await import("../packages/vinext/src/server/middleware.js");
const { createValidFileMatcher } =
await import("../packages/vinext/src/routing/file-matcher.js");
// Create a temp directory with both proxy.ts and middleware.ts
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-proxy-test-"));
try {
fs.writeFileSync(path.join(tmpDir, "proxy.ts"), "export default function proxy() {}");
fs.writeFileSync(path.join(tmpDir, "middleware.ts"), "export function middleware() {}");
expect(() => findMiddlewareFile(tmpDir, createValidFileMatcher())).toThrow(
'Both middleware file "./middleware.ts" and proxy file "./proxy.ts" are detected. Please use "./proxy.ts" only.',
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("findMiddlewareFile finds proxy.js", async () => {
const fs = await import("node:fs");
const path = await import("node:path");
const os = await import("node:os");
const { findMiddlewareFile } = await import("../packages/vinext/src/server/middleware.js");
const { createValidFileMatcher } =
await import("../packages/vinext/src/routing/file-matcher.js");
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-proxy-test-"));
try {
fs.writeFileSync(path.join(tmpDir, "proxy.js"), "module.exports = function proxy() {}");
const result = findMiddlewareFile(tmpDir, createValidFileMatcher());
expect(result).not.toBeNull();
expect(result).toContain("proxy.js");
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});
// ---------------------------------------------------------------------------
// Dev-mode runMiddleware header preservation tests
// Tests that the middleware.ts runner (used by the dev server) preserves
// x-middleware-request-* headers so the caller can unpack them into actual
// request headers. This is the dev/prod parity fix — the production inline
// codegen (pages-server-entry.ts) already preserved them correctly.
describe("runMiddleware preserves x-middleware-request-* headers (dev mode)", () => {
it("keeps x-middleware-request-* headers on NextResponse.next()", async () => {
const { runMiddleware } = await import("../packages/vinext/src/server/middleware.js");
const { NextResponse } = await import("../packages/vinext/src/shims/server.js");
// Mock runner that loads a fake middleware module
const mockRunner = {
import: async () => ({
default: () =>
NextResponse.next({
request: {
headers: new Headers({ "x-custom-injected": "from-middleware" }),
},
}),
config: { matcher: "/" },
}),
};
const request = new Request("http://localhost/");
const result = await runMiddleware(mockRunner as any, "/fake/middleware.ts", request);
expect(result.continue).toBe(true);
expect(result.responseHeaders).toBeDefined();
// x-middleware-request-* must survive so the dev server can unpack them
expect(result.responseHeaders!.get("x-middleware-request-x-custom-injected")).toBe(
"from-middleware",
);
// Other x-middleware-* internal headers must be stripped
expect(result.responseHeaders!.has("x-middleware-next")).toBe(false);
});
it("preserves status from NextResponse.next()", async () => {
const { runMiddleware } = await import("../packages/vinext/src/server/middleware.js");
const { NextResponse } = await import("../packages/vinext/src/shims/server.js");
const mockRunner = {
import: async () => ({
default: () => NextResponse.next({ status: 404 }),
config: { matcher: "/" },
}),
};
const request = new Request("http://localhost/");
const result = await runMiddleware(mockRunner as any, "/fake/middleware.ts", request);
expect(result.continue).toBe(true);
expect(result.status).toBe(404);
});
it("keeps x-middleware-request-* headers on rewrite", async () => {
const { runMiddleware } = await import("../packages/vinext/src/server/middleware.js");
const { NextResponse } = await import("../packages/vinext/src/shims/server.js");
const mockRunner = {
import: async () => ({
default: () => {
const res = NextResponse.rewrite(new URL("/rewritten", "http://localhost"));
// Simulate middleware also setting request headers on a rewrite
res.headers.set("x-middleware-request-x-auth", "bearer-token");
return res;
},
config: { matcher: "/" },
}),
};
const request = new Request("http://localhost/");
const result = await runMiddleware(mockRunner as any, "/fake/middleware.ts", request);
expect(result.continue).toBe(true);
expect(result.rewriteUrl).toBe("/rewritten");
expect(result.responseHeaders).toBeDefined();
expect(result.responseHeaders!.get("x-middleware-request-x-auth")).toBe("bearer-token");
// x-middleware-rewrite must be stripped
expect(result.responseHeaders!.has("x-middleware-rewrite")).toBe(false);
});
// 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
it("preserves the full external URL for middleware rewrites", async () => {
const { runMiddleware } = await import("../packages/vinext/src/server/middleware.js");
const { NextResponse } = await import("../packages/vinext/src/shims/server.js");
const mockRunner = {
import: async () => ({
default: () => NextResponse.rewrite("https://api.example.com/echo?from=middleware"),
config: { matcher: "/:path*" },
}),
};
const request = new Request("http://localhost/original?keep=1");
const result = await runMiddleware(mockRunner as any, "/fake/middleware.ts", request);
expect(result.continue).toBe(true);
expect(result.rewriteUrl).toBe("https://api.example.com/echo?from=middleware");
});
it("strips x-middleware-set-cookie from custom middleware responses", async () => {
const { runMiddleware } = await import("../packages/vinext/src/server/middleware.js");
const { NextResponse } = await import("../packages/vinext/src/shims/server.js");
const mockRunner = {
import: async () => ({
default: () => {
const res = new NextResponse("blocked", { status: 403 });
res.cookies.set("blocked", "1", { path: "/" });
return res;
},
config: { matcher: "/:path*" },
}),
};
const request = new Request("http://localhost/blocked");
const result = await runMiddleware(mockRunner as any, "/fake/middleware.ts", request);
expect(result.continue).toBe(false);
expect(result.response).toBeDefined();
expect(result.response!.headers.get("x-middleware-set-cookie")).toBeNull();
expect(result.response!.headers.get("set-cookie")).toContain("blocked=1");
});
});
// ---------------------------------------------------------------------------
// Middleware/proxy export validation tests
// Ported from Next.js: test/e2e/app-dir/proxy-missing-export/proxy-missing-export.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/proxy-missing-export/proxy-missing-export.test.ts
describe("middleware/proxy export validation", () => {
it("isProxyFile returns true for proxy files", async () => {
const { isProxyFile } = await import("../packages/vinext/src/server/middleware.js");
expect(isProxyFile("/app/proxy.ts")).toBe(true);
expect(isProxyFile("/app/proxy.js")).toBe(true);
expect(isProxyFile("/app/proxy.mjs")).toBe(true);
expect(isProxyFile("/app/proxy.platform.tsx")).toBe(true);
expect(isProxyFile("/app/src/proxy.ts")).toBe(true);
});
it("isProxyFile returns false for middleware files", async () => {
const { isProxyFile } = await import("../packages/vinext/src/server/middleware.js");
expect(isProxyFile("/app/middleware.ts")).toBe(false);
expect(isProxyFile("/app/middleware.js")).toBe(false);
expect(isProxyFile("/app/middleware.mjs")).toBe(false);
expect(isProxyFile("/app/src/middleware.ts")).toBe(false);
});
it("resolveMiddlewareHandler: proxy.ts with named proxy export", async () => {
const { resolveMiddlewareHandler } =
await import("../packages/vinext/src/server/middleware.js");
const fn = () => {};
const handler = resolveMiddlewareHandler({ proxy: fn }, "/app/proxy.ts");
expect(handler).toBe(fn);
});
it("resolveMiddlewareHandler: proxy.ts with default export", async () => {
const { resolveMiddlewareHandler } =
await import("../packages/vinext/src/server/middleware.js");
const fn = () => {};
const handler = resolveMiddlewareHandler({ default: fn }, "/app/proxy.ts");
expect(handler).toBe(fn);
});
it("resolveMiddlewareHandler: proxy.ts prefers named proxy over default", async () => {
const { resolveMiddlewareHandler } =
await import("../packages/vinext/src/server/middleware.js");
const proxyFn = () => {};
const defaultFn = () => {};
const handler = resolveMiddlewareHandler(
{ proxy: proxyFn, default: defaultFn },
"/app/proxy.ts",
);
expect(handler).toBe(proxyFn);
});
it("resolveMiddlewareHandler: proxy.ts with default arrow function export", async () => {
const { resolveMiddlewareHandler } =
await import("../packages/vinext/src/server/middleware.js");
const fn = () => {};
const handler = resolveMiddlewareHandler({ default: fn }, "/app/proxy.ts");
expect(handler).toBe(fn);
});
it("resolveMiddlewareHandler: proxy.ts throws when only 'middleware' is exported (wrong name)", async () => {
const { resolveMiddlewareHandler } =
await import("../packages/vinext/src/server/middleware.js");
expect(() => resolveMiddlewareHandler({ middleware: () => {} }, "/app/proxy.ts")).toThrow(
'The file "./proxy.ts" must export a function, either as a default export or as a named "proxy" export.',
);
});
it("resolveMiddlewareHandler: proxy.ts throws when export is aliased to wrong name", async () => {
const { resolveMiddlewareHandler } =
await import("../packages/vinext/src/server/middleware.js");
expect(() => resolveMiddlewareHandler({ handler: () => {} }, "/app/proxy.ts")).toThrow(
'The file "./proxy.ts" must export a function, either as a default export or as a named "proxy" export.',
);
});
it("resolveMiddlewareHandler: proxy.ts throws when no exports", async () => {
const { resolveMiddlewareHandler } =
await import("../packages/vinext/src/server/middleware.js");
expect(() => resolveMiddlewareHandler({}, "/app/proxy.ts")).toThrow(
'The file "./proxy.ts" must export a function, either as a default export or as a named "proxy" export.',
);
});
it("resolveMiddlewareHandler: proxy.ts throws when export is not a function", async () => {
const { resolveMiddlewareHandler } =
await import("../packages/vinext/src/server/middleware.js");
expect(() => resolveMiddlewareHandler({ proxy: "not a function" }, "/app/proxy.ts")).toThrow(
'The file "./proxy.ts" must export a function, either as a default export or as a named "proxy" export.',
);
});
it("resolveMiddlewareHandler: proxy.ts throws when default export is not a function", async () => {
const { resolveMiddlewareHandler } =
await import("../packages/vinext/src/server/middleware.js");
expect(() => resolveMiddlewareHandler({ default: {} }, "/app/proxy.ts")).toThrow(
'The file "./proxy.ts" must export a function, either as a default export or as a named "proxy" export.',
);
});
it("resolveMiddlewareHandler: middleware.ts with named middleware export", async () => {
const { resolveMiddlewareHandler } =
await import("../packages/vinext/src/server/middleware.js");
const fn = () => {};
const handler = resolveMiddlewareHandler({ middleware: fn }, "/app/middleware.ts");
expect(handler).toBe(fn);
});
it("resolveMiddlewareHandler: middleware.ts with default export", async () => {
const { resolveMiddlewareHandler } =
await import("../packages/vinext/src/server/middleware.js");
const fn = () => {};
const handler = resolveMiddlewareHandler({ default: fn }, "/app/middleware.ts");
expect(handler).toBe(fn);
});
it("resolveMiddlewareHandler: middleware.ts throws when only 'proxy' is exported (wrong name)", async () => {
const { resolveMiddlewareHandler } =
await import("../packages/vinext/src/server/middleware.js");
expect(() => resolveMiddlewareHandler({ proxy: () => {} }, "/app/middleware.ts")).toThrow(
'The file "./middleware.ts" must export a function, either as a default export or as a named "middleware" export.',
);
});
it("resolveMiddlewareHandler: middleware.ts throws when no exports", async () => {
const { resolveMiddlewareHandler } =
await import("../packages/vinext/src/server/middleware.js");
expect(() => resolveMiddlewareHandler({}, "/app/middleware.ts")).toThrow(
'The file "./middleware.ts" must export a function, either as a default export or as a named "middleware" export.',
);
});
});
// ---------------------------------------------------------------------------
// matchPattern / matchesMiddleware unit tests
describe("middleware matcher patterns", () => {
it("matchPattern: exact path match", async () => {
const { matchPattern } = await import("../packages/vinext/src/server/middleware.js");
expect(matchPattern("/about", "/about")).toBe(true);
expect(matchPattern("/about", "/other")).toBe(false);
expect(matchPattern("/", "/")).toBe(true);
});
it("matchPattern: named parameter (:param)", async () => {
const { matchPattern } = await import("../packages/vinext/src/server/middleware.js");
expect(matchPattern("/user/123", "/user/:id")).toBe(true);
expect(matchPattern("/user/abc", "/user/:id")).toBe(true);
expect(matchPattern("/user/", "/user/:id")).toBe(false);
expect(matchPattern("/user/123/posts", "/user/:id")).toBe(false);
});
// Ported from Next.js: middleware matchers support :param(constraint) syntax
// https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
it("matchPattern: :param(constraint) applies inline regex constraint", async () => {
const { matchPattern } = await import("../packages/vinext/src/server/middleware.js");
// :id(\d+) should match only digits
expect(matchPattern("/blog/123", "/blog/:id(\\d+)")).toBe(true);
expect(matchPattern("/blog/0", "/blog/:id(\\d+)")).toBe(true);
expect(matchPattern("/blog/abc", "/blog/:id(\\d+)")).toBe(false);
expect(matchPattern("/blog/12x", "/blog/:id(\\d+)")).toBe(false);
// Locale-style alternation constraint: :locale(en|es|fr)
expect(matchPattern("/en/about", "/:locale(en|es|fr)/about")).toBe(true);
expect(matchPattern("/es/about", "/:locale(en|es|fr)/about")).toBe(true);
expect(matchPattern("/de/about", "/:locale(en|es|fr)/about")).toBe(false);
// Optional locale with ? after constraint
expect(matchPattern("/about", "/:locale(en|es|fr)?/about")).toBe(true);
expect(matchPattern("/en/about", "/:locale(en|es|fr)?/about")).toBe(true);
expect(matchPattern("/de/about", "/:locale(en|es|fr)?/about")).toBe(false);
});
it("matchPattern: wildcard (:path*) matches zero or more segments", async () => {
const { matchPattern } = await import("../packages/vinext/src/server/middleware.js");
expect(matchPattern("/dashboard", "/dashboard/:path*")).toBe(true);
expect(matchPattern("/dashboard/settings", "/dashboard/:path*")).toBe(true);
expect(matchPattern("/dashboard/settings/profile", "/dashboard/:path*")).toBe(true);
expect(matchPattern("/other", "/dashboard/:path*")).toBe(false);
});
it("matchPattern: :param*(constraint) and :param+(constraint)", async () => {
const { matchPattern } = await import("../packages/vinext/src/server/middleware.js");
// /:path*(api|static) — optional segment constrained to api or static
expect(matchPattern("/cdn", "/cdn/:path*(api|static)")).toBe(true);
expect(matchPattern("/cdn/api", "/cdn/:path*(api|static)")).toBe(true);
expect(matchPattern("/cdn/static", "/cdn/:path*(api|static)")).toBe(true);
expect(matchPattern("/cdn/other", "/cdn/:path*(api|static)")).toBe(false);
// /:path+(api|static) — required segment constrained
expect(matchPattern("/cdn", "/cdn/:path+(api|static)")).toBe(false);
expect(matchPattern("/cdn/api", "/cdn/:path+(api|static)")).toBe(true);
expect(matchPattern("/cdn/static", "/cdn/:path+(api|static)")).toBe(true);
expect(matchPattern("/cdn/other", "/cdn/:path+(api|static)")).toBe(false);
});
it("matchPattern: one-or-more (:path+) requires at least one segment", async () => {
const { matchPattern } = await import("../packages/vinext/src/server/middleware.js");
expect(matchPattern("/api/users", "/api/:path+")).toBe(true);
expect(matchPattern("/api/users/123", "/api/:path+")).toBe(true);
expect(matchPattern("/api", "/api/:path+")).toBe(false);
// /api/ has no actual segment after the slash
expect(matchPattern("/api/", "/api/:path+")).toBe(false);
});
it("matchPattern: regex patterns with groups", async () => {
const { matchPattern } = await import("../packages/vinext/src/server/middleware.js");
// Common Next.js matcher: /((?!api|_next|favicon\.ico).*)
expect(matchPattern("/about", "/((?!api|_next|favicon\\.ico).*)")).toBe(true);
expect(matchPattern("/dashboard/settings", "/((?!api|_next|favicon\\.ico).*)")).toBe(true);
expect(matchPattern("/api/hello", "/((?!api|_next|favicon\\.ico).*)")).toBe(false);
expect(matchPattern("/_next/static/chunk.js", "/((?!api|_next|favicon\\.ico).*)")).toBe(false);
});
it("fails closed when a middleware matcher is rejected as unsafe", async () => {
const { matchPattern, matchesMiddleware } =
await import("../packages/vinext/src/server/middleware.js");
// Next.js rejects invalid matcher sources during config analysis rather than
// silently treating them as non-matches. Vinext additionally rejects unsafe
// regexes at runtime, where failing closed avoids bypassing request guards.
// https://github.com/vercel/next.js/blob/canary/packages/next/src/build/segment-config/middleware/middleware-config.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/invalid-middleware-matchers/invalid-middleware-matchers.test.ts
// lgtm[js/redos] — deliberate pathological regex to exercise the safety fallback
const unsafePattern = "/(?:[A-Z]+/)*private";
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
expect(matchPattern("/private", unsafePattern)).toBe(true);
expect(matchesMiddleware("/private", unsafePattern)).toBe(true);
expect(matchesMiddleware("/public", [unsafePattern])).toBe(true);
expect(matchesMiddleware("/public", [{ source: unsafePattern }])).toBe(true);
// Repeated calls use the cached rejection, remain fail-closed, and do not
// repeatedly warn for the same matcher.
expect(matchPattern("/public", unsafePattern)).toBe(true);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("Middleware will run for all paths"),
);
} finally {
warnSpy.mockRestore();
}
});
it("matchPattern: dots are escaped in paths", async () => {
const { matchPattern } = await import("../packages/vinext/src/server/middleware.js");
expect(matchPattern("/files/data.json", "/files/data.json")).toBe(true);
expect(matchPattern("/files/dataXjson", "/files/data.json")).toBe(false);
});
it("matchesMiddleware: no matcher — matches all paths (Next.js default)", async () => {
const { matchesMiddleware } = await import("../packages/vinext/src/server/middleware.js");
// Next.js default: middleware runs on ALL paths when no matcher is configured.
// Users opt out of specific paths by configuring a matcher pattern.
expect(matchesMiddleware("/", undefined)).toBe(true);
expect(matchesMiddleware("/about", undefined)).toBe(true);
expect(matchesMiddleware("/dashboard/settings", undefined)).toBe(true);
expect(matchesMiddleware("/_next/static/chunk.js", undefined)).toBe(true);
expect(matchesMiddleware("/api/hello", undefined)).toBe(true);
expect(matchesMiddleware("/favicon.ico", undefined)).toBe(true);
expect(matchesMiddleware("/image.png", undefined)).toBe(true);
});
it("matchesMiddleware: single string matcher", async () => {
const { matchesMiddleware } = await import("../packages/vinext/src/server/middleware.js");
expect(matchesMiddleware("/about", "/about")).toBe(true);
expect(matchesMiddleware("/other", "/about")).toBe(false);
// Ported from Next.js: test/e2e/middleware-custom-matchers-i18n/test/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-custom-matchers-i18n/test/index.test.ts
expect(
matchesMiddleware("/about", "/about", undefined, {
locales: ["en", "fr"],
defaultLocale: "en",
}),
).toBe(true);
expect(
matchesMiddleware("/fr/about", "/about", undefined, {
locales: ["en", "fr"],
defaultLocale: "en",
}),
).toBe(true);
expect(
matchesMiddleware("/", "/", undefined, {
locales: ["en", "fr"],
defaultLocale: "en",
}),
).toBe(true);
});
it("matchesMiddleware: locale-prefixed negative-lookahead matchers keep internal paths excluded", async () => {
const { matchesMiddleware } = await import("../packages/vinext/src/server/middleware.js");
const matcher = "/((?!api|_next|favicon\\.ico).*)";
const i18nConfig = {
locales: ["en", "fr"],
defaultLocale: "en",
};
expect(matchesMiddleware("/fr/about", matcher, undefined, i18nConfig)).toBe(true);
expect(matchesMiddleware("/fr/api/hello", matcher, undefined, i18nConfig)).toBe(false);
expect(matchesMiddleware("/fr/_next/static/chunk.js", matcher, undefined, i18nConfig)).toBe(
false,
);
expect(matchesMiddleware("/fr/favicon.ico", matcher, undefined, i18nConfig)).toBe(false);
});
it("matchesMiddleware: array of string matchers", async () => {
const { matchesMiddleware } = await import("../packages/vinext/src/server/middleware.js");
const matcher = ["/about", "/dashboard/:path*"];
expect(matchesMiddleware("/about", matcher)).toBe(true);
expect(matchesMiddleware("/dashboard", matcher)).toBe(true);
expect(matchesMiddleware("/dashboard/settings", matcher)).toBe(true);
expect(matchesMiddleware("/other", matcher)).toBe(false);
});
it("matchesMiddleware: trailing slashes in matcher sources are optional", async () => {
const { matchPattern, matchesMiddleware } =
await import("../packages/vinext/src/server/middleware.js");
// Next.js compiles middleware sources with an optional terminal delimiter.
// https://github.com/vercel/next.js/blob/canary/packages/next/src/build/analysis/get-page-static-info.ts
expect(matchPattern("/api/admin/", "/api/admin/")).toBe(true);
expect(matchPattern("/api/admin/", "/api/admin\\/")).toBe(true);
expect(matchPattern("/api/admin", "/api/admin\\/")).toBe(false);
expect(matchPattern("/blog/post/", "/(.*)/")).toBe(true);
expect(matchPattern("/blog/post", "/(.*)/")).toBe(false);
expect(matchPattern("/api/123/", "/api/:id")).toBe(true);
expect(matchPattern("/api/123/", "/api/:id/")).toBe(true);
expect(matchPattern("/api/123", "/api/:id/")).toBe(false);
expect(matchPattern("/foo/", "/:path(.*\\/)")).toBe(true);
expect(matchPattern("/foo", "/:path(.*\\/)")).toBe(false);
expect(matchesMiddleware("/api/admin", "/api/admin/")).toBe(true);
expect(matchesMiddleware("/api/admin", ["/api/admin/"])).toBe(true);
expect(matchesMiddleware("/api/admin", [{ source: "/api/admin/" }])).toBe(true);
expect(
matchesMiddleware("/fr/api/admin/", "/api/admin\\/", undefined, {
locales: ["en", "fr"],
defaultLocale: "en",
}),
).toBe(true);
expect(matchesMiddleware("/", "/")).toBe(true);
});
it("matchesMiddleware: array of object matchers with source", async () => {
const { matchesMiddleware } = await import("../packages/vinext/src/server/middleware.js");
const matcher = [{ source: "/about" }, { source: "/dashboard/:path*" }];
expect(matchesMiddleware("/about", matcher)).toBe(true);
expect(matchesMiddleware("/dashboard/settings", matcher)).toBe(true);
expect(matchesMiddleware("/other", matcher)).toBe(false);
});
it("matchesMiddleware: object matchers respect has and missing conditions", async () => {
const { matchesMiddleware } = await import("../packages/vinext/src/server/middleware.js");
const matcher: any = [
{
source: "/dashboard",
has: [{ type: "header", key: "x-user-tier", value: "pro" }],
missing: [{ type: "cookie", key: "blocked" }],
},
];
expect(matchesMiddleware("/dashboard", matcher)).toBe(false);
expect(
matchesMiddleware(
"/dashboard",
matcher,
new Request("https://example.com/dashboard", {
headers: { "x-user-tier": "pro" },
}),
),
).toBe(true);
expect(
matchesMiddleware(
"/dashboard",
matcher,
new Request("https://example.com/dashboard", {
headers: { "x-user-tier": "free", cookie: "blocked=1" },
}),
),
).toBe(false);
});
it("matchesMiddleware: rejects a single object matcher config", async () => {
const { matchesMiddleware } = await import("../packages/vinext/src/server/middleware.js");
const matcher: any = {
source: "/dashboard",
has: [{ type: "header", key: "x-user-tier", value: "pro" }],
};
expect(matchesMiddleware("/dashboard", matcher)).toBe(false);
expect(
matchesMiddleware(
"/dashboard",
matcher,
new Request("https://example.com/dashboard", {
headers: { "x-user-tier": "pro" },
}),
),
).toBe(false);
});
it("matchesMiddleware: rejects object matchers with unsupported fields", async () => {
const { matchesMiddleware } = await import("../packages/vinext/src/server/middleware.js");
const matcher: any = [{ source: "/does-not-match", regexp: "^/dashboard(?:/.*)?$" }];
expect(matchesMiddleware("/dashboard/settings", matcher)).toBe(false);
expect(matchesMiddleware("/about", matcher)).toBe(false);
});
it("matchesMiddleware: matches default-locale and locale-prefixed paths unless locale is false", async () => {
const { matchesMiddleware } = await import("../packages/vinext/src/server/middleware.js");
const i18nConfig = {
locales: ["en", "fr"],
defaultLocale: "en",
};
// Ported from Next.js: test/e2e/middleware-custom-matchers-i18n/test/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-custom-matchers-i18n/test/index.test.ts
expect(matchesMiddleware("/dashboard", [{ source: "/dashboard" }], undefined, i18nConfig)).toBe(
true,
);
expect(
matchesMiddleware("/fr/dashboard", [{ source: "/dashboard" }], undefined, i18nConfig),
).toBe(true);
expect(
matchesMiddleware(
"/fr/dashboard",
[{ source: "/dashboard", locale: false }],
undefined,
i18nConfig,
),
).toBe(false);
});
it("matchesMiddleware: mixed array of strings and objects", async () => {
const { matchesMiddleware } = await import("../packages/vinext/src/server/middleware.js");
const matcher = ["/about", { source: "/api/:path+" }] as any;
expect(matchesMiddleware("/about", matcher)).toBe(true);
expect(matchesMiddleware("/api/users", matcher)).toBe(true);
expect(matchesMiddleware("/api", matcher)).toBe(false);
expect(matchesMiddleware("/other", matcher)).toBe(false);
});
it("matchPattern: fails closed for pathological ReDoS patterns", async () => {
const { matchPattern } = await import("../packages/vinext/src/server/middleware.js");
// Pathological pattern: (a+)+ causes catastrophic backtracking
// matchPattern must avoid evaluating it and run middleware instead of
// silently bypassing a potentially security-sensitive request guard.
// lgtm[js/redos] — deliberate pathological regex to test safeRegExp guard
expect(matchPattern("/aaaaaaaaaaaaaaaaaaaac", "(a+)+b")).toBe(true);
});
it("matchPattern: does not run globally for malformed regex syntax", async () => {
const { matchPattern } = await import("../packages/vinext/src/server/middleware.js");
expect(matchPattern("/public", "/admin(")).toBe(false);
expect(matchPattern("/admin(", "/admin(")).toBe(true);
});
});
// ---------------------------------------------------------------------------
// normalizePath unit tests
describe("normalizePath", () => {
it("returns root unchanged", async () => {
const { normalizePath } = await import("../packages/vinext/src/server/normalize-path.js");
expect(normalizePath("/")).toBe("/");
});
it("returns already-canonical paths unchanged", async () => {
const { normalizePath } = await import("../packages/vinext/src/server/normalize-path.js");
expect(normalizePath("/foo/bar")).toBe("/foo/bar");
expect(normalizePath("/about")).toBe("/about");
expect(normalizePath("/api/users/123")).toBe("/api/users/123");
});
it("collapses double slashes", async () => {
const { normalizePath } = await import("../packages/vinext/src/server/normalize-path.js");
expect(normalizePath("//foo")).toBe("/foo");
expect(normalizePath("/foo//bar")).toBe("/foo/bar");
expect(normalizePath("/dashboard//settings")).toBe("/dashboard/settings");
expect(normalizePath("///")).toBe("/");
expect(normalizePath("/foo///bar///baz")).toBe("/foo/bar/baz");
});
it("resolves single-dot segments", async () => {
const { normalizePath } = await import("../packages/vinext/src/server/normalize-path.js");
expect(normalizePath("/foo/./bar")).toBe("/foo/bar");
expect(normalizePath("/./foo")).toBe("/foo");
expect(normalizePath("/foo/.")).toBe("/foo");
});
it("resolves double-dot segments", async () => {
const { normalizePath } = await import("../packages/vinext/src/server/normalize-path.js");
expect(normalizePath("/foo/../bar")).toBe("/bar");
expect(normalizePath("/foo/bar/../baz")).toBe("/foo/baz");
expect(normalizePath("/foo/..")).toBe("/");
});
it("clamps traversal above root", async () => {
const { normalizePath } = await import("../packages/vinext/src/server/normalize-path.js");
expect(normalizePath("/../../../etc/passwd")).toBe("/etc/passwd");
expect(normalizePath("/..")).toBe("/");
});
it("ensures leading slash", async () => {
const { normalizePath } = await import("../packages/vinext/src/server/normalize-path.js");
expect(normalizePath("foo/bar")).toBe("/foo/bar");
expect(normalizePath("")).toBe("/");
});
it("preserves trailing slash on fast path", async () => {
const { normalizePath } = await import("../packages/vinext/src/server/normalize-path.js");
// Fast path: already canonical with trailing slash
expect(normalizePath("/foo/bar/")).toBe("/foo/bar/");
});
it("handles complex combined cases", async () => {
const { normalizePath } = await import("../packages/vinext/src/server/normalize-path.js");
expect(normalizePath("/foo/./bar/../baz")).toBe("/foo/baz");
expect(normalizePath("//foo/./bar//baz/../qux")).toBe("/foo/bar/qux");
});
});
// ── escapePathDelimiters / decodePathParams ────────────────────────────────
// Ported from Next.js: packages/next/src/shared/lib/router/utils/escape-path-delimiters.ts
// and packages/next/src/server/lib/router-utils/decode-path-params.ts
describe("escapePathDelimiters", () => {
let escapePathDelimiters: (segment: string, escapeEncoded?: boolean) => string;
beforeEach(async () => {
const mod = await import("../packages/vinext/src/server/normalize-path.js");
escapePathDelimiters = mod.escapePathDelimiters;
});
it("re-encodes forward slash", () => {
expect(escapePathDelimiters("admin/panel")).toBe("admin%2Fpanel");
});
it("re-encodes hash and question mark", () => {
expect(escapePathDelimiters("foo#bar")).toBe("foo%23bar");
expect(escapePathDelimiters("foo?bar")).toBe("foo%3Fbar");
});
it("re-encodes already-encoded delimiters when escapeEncoded is true", () => {
expect(escapePathDelimiters("admin%2Fpanel", true)).toBe("admin%252Fpanel");
expect(escapePathDelimiters("foo%23bar", true)).toBe("foo%2523bar");
expect(escapePathDelimiters("foo%3Fbar", true)).toBe("foo%253Fbar");
expect(escapePathDelimiters("foo%5Cbar", true)).toBe("foo%255Cbar");
});
it("leaves non-delimiter characters unchanged", () => {
expect(escapePathDelimiters("café")).toBe("café");
expect(escapePathDelimiters("hello world")).toBe("hello world");
});
});
describe("decodePathParams", () => {
let decodePathParams: (pathname: string) => string;
beforeEach(async () => {
const mod = await import("../packages/vinext/src/server/normalize-path.js");
decodePathParams = mod.decodePathParams;
});
it("decodes non-ASCII characters within segments", () => {
expect(decodePathParams("/caf%C3%A9")).toBe("/café");
expect(decodePathParams("/%E6%97%A5%E6%9C%AC%E8%AA%9E")).toBe("/日本語");
});
it("preserves encoded slashes (%2F) - does not change path structure", () => {
expect(decodePathParams("/admin%2Fpanel")).toBe("/admin%2Fpanel");
});
it("preserves encoded hash (%23) and question mark (%3F)", () => {
expect(decodePathParams("/foo%23bar")).toBe("/foo%23bar");
expect(decodePathParams("/foo%3Fbar")).toBe("/foo%3Fbar");
});
it("decodes encoded backslash (%5C) since it is not a path delimiter", () => {
// Backslash is not a URL path delimiter (browsers normalize \ to / before
// sending). The escapePathDelimiters function only re-encodes /, #, and ?.
// %5C is decoded to \ and left as-is, matching Next.js behavior.
expect(decodePathParams("/foo%5Cbar")).toBe("/foo\\bar");
});
it("decodes mixed paths correctly", () => {
// Non-ASCII decoded, structural delimiters preserved
expect(decodePathParams("/caf%C3%A9/admin%2Fpanel")).toBe("/café/admin%2Fpanel");
});
it("handles already-decoded paths", () => {
expect(decodePathParams("/about")).toBe("/about");
expect(decodePathParams("/foo/bar/baz")).toBe("/foo/bar/baz");
});
it("handles malformed percent-encoding gracefully", () => {
// Should not throw, just return the segment as-is
expect(decodePathParams("/%E0%A4%A")).toBe("/%E0%A4%A");
});
it("handles root path", () => {
expect(decodePathParams("/")).toBe("/");
});
});
// ---------------------------------------------------------------------------
// Integration: verify decodeURIComponent + normalizePath applied before matching
describe("middleware bypass prevention", () => {
it("percent-encoded path is decoded before matching", async () => {
const { matchPattern, matchesMiddleware } =
await import("../packages/vinext/src/server/middleware.js");
const { normalizePath } = await import("../packages/vinext/src/server/normalize-path.js");
// /%61dmin decodes to /admin
const encoded = "/%61dmin";
const decoded = normalizePath(decodeURIComponent(encoded));
expect(decoded).toBe("/admin");
expect(matchPattern(decoded, "/admin")).toBe(true);
expect(matchesMiddleware(decoded, "/admin")).toBe(true);
});
it("double-slash path is collapsed before matching", async () => {
const { matchPattern, matchesMiddleware } =
await import("../packages/vinext/src/server/middleware.js");
const { normalizePath } = await import("../packages/vinext/src/server/normalize-path.js");
// /dashboard//settings collapses to /dashboard/settings
const doubleSlash = "/dashboard//settings";
const normalized = normalizePath(doubleSlash);
expect(normalized).toBe("/dashboard/settings");
expect(matchPattern(normalized, "/dashboard/:path*")).toBe(true);
expect(matchesMiddleware(normalized, "/dashboard/:path*")).toBe(true);
});
it("default matcher (no config) matches all paths including /api", async () => {
const { matchesMiddleware } = await import("../packages/vinext/src/server/middleware.js");
// When no matcher is configured, middleware must run on ALL paths
expect(matchesMiddleware("/api/hello", undefined)).toBe(true);
expect(matchesMiddleware("/_next/data/build-id/page.json", undefined)).toBe(true);
expect(matchesMiddleware("/favicon.ico", undefined)).toBe(true);
});
it("regex patterns are not corrupted by dot-escaping", async () => {
const { matchPattern } = await import("../packages/vinext/src/server/middleware.js");
// The common Next.js regex pattern must work correctly:
// /((?!api|_next|favicon\.ico).*) should match /about but NOT /api/hello
const pattern = "/((?!api|_next|favicon\\.ico).*)";
expect(matchPattern("/about", pattern)).toBe(true);
expect(matchPattern("/dashboard/settings", pattern)).toBe(true);
expect(matchPattern("/api/hello", pattern)).toBe(false);
expect(matchPattern("/_next/static/chunk.js", pattern)).toBe(false);
expect(matchPattern("/favicon.ico", pattern)).toBe(false);
});
// ── Config matcher percent-encoding handling ──
it("config redirect matcher works with decoded percent-encoded paths", async () => {
const { matchRedirect } = await import("../packages/vinext/src/config/config-matchers.js");
const { normalizePath } = await import("../packages/vinext/src/server/normalize-path.js");
const redirects = [
{ source: "/admin", destination: "/login", permanent: true },
{ source: "/old-blog/:slug", destination: "/blog/:slug", permanent: false },
];
const reqCtx = {
headers: new Headers(),
cookies: {},
query: new URLSearchParams(),
host: "localhost",
};
// Decoded path should match
const decoded = normalizePath(decodeURIComponent("/%61dmin"));
expect(decoded).toBe("/admin");
const result = matchRedirect(decoded, redirects, reqCtx);
expect(result).toBeTruthy();
expect(result!.destination).toBe("/login");
// Mixed encoding in parameterized route
const slugDecoded = normalizePath(decodeURIComponent("/%6Fld-blog/my-p%6Fst"));
expect(slugDecoded).toBe("/old-blog/my-post");
const slugResult = matchRedirect(slugDecoded, redirects, reqCtx);
expect(slugResult).toBeTruthy();
expect(slugResult!.destination).toBe("/blog/my-post");
// Raw encoded path must NOT match (matchers expect decoded paths)
const rawResult = matchRedirect("/%61dmin", redirects, reqCtx);
expect(rawResult).toBeNull();
});
it("preserveRedirectDestinationQuery merges the original request query onto the destination (#1529)", async () => {
const { preserveRedirectDestinationQuery } =
await import("../packages/vinext/src/config/config-matchers.js");
// Carries the RSC cache-busting `_rsc` param onto a query-less destination.
expect(preserveRedirectDestinationQuery("/dest", "?_rsc=abc123")).toBe("/dest?_rsc=abc123");
// Merges with a destination that already has its own query; destination
// params win on key conflicts (mirrors Next.js + proxyExternalRequest).
expect(preserveRedirectDestinationQuery("/dest?from=cfg", "?from=req&_rsc=abc")).toBe(
"/dest?from=cfg&_rsc=abc",
);
// No request query → destination untouched.
expect(preserveRedirectDestinationQuery("/dest?a=1", "")).toBe("/dest?a=1");
expect(preserveRedirectDestinationQuery("/dest", "?")).toBe("/dest");
// External destinations are returned untouched (no query leak across origins).
expect(preserveRedirectDestinationQuery("https://example.com/x", "?_rsc=abc")).toBe(
"https://example.com/x",
);
expect(preserveRedirectDestinationQuery("//evil.com", "?_rsc=abc")).toBe("//evil.com");
// Preserves a hash fragment on the destination.
expect(preserveRedirectDestinationQuery("/dest#frag", "?_rsc=abc")).toBe("/dest?_rsc=abc#frag");
});
it("config header matcher works with decoded percent-encoded paths", async () => {
const { matchHeaders } = await import("../packages/vinext/src/config/config-matchers.js");
const { normalizePath } = await import("../packages/vinext/src/server/normalize-path.js");
const headers = [{ source: "/api/(.*)", headers: [{ key: "X-Custom", value: "true" }] }];
const reqCtx = {
headers: new Headers(),
cookies: {},
query: new URLSearchParams(),
host: "localhost",
};
// Decoded path should match
const decoded = normalizePath(decodeURIComponent("/%61pi/hello"));
expect(decoded).toBe("/api/hello");
const result = matchHeaders(decoded, headers, reqCtx);
expect(result).toHaveLength(1);
expect(result[0].key).toBe("X-Custom");
// Raw encoded path must NOT match
const rawResult = matchHeaders("/%61pi/hello", headers, reqCtx);
expect(rawResult).toHaveLength(0);
});
it("config rewrite matcher works with decoded percent-encoded paths", async () => {
const { matchRewrite } = await import("../packages/vinext/src/config/config-matchers.js");
const { normalizePath } = await import("../packages/vinext/src/server/normalize-path.js");
const rewrites = [{ source: "/before-rewrite", destination: "/about" }];
const reqCtx = {
headers: new Headers(),
cookies: {},
query: new URLSearchParams(),
host: "localhost",
};
// Decoded path should match
const decoded = normalizePath(decodeURIComponent("/%62efore-rewrite"));
expect(decoded).toBe("/before-rewrite");
const result = matchRewrite(decoded, rewrites, reqCtx);
expect(result).toBe("/about");
// Raw encoded path must NOT match
const rawResult = matchRewrite("/%62efore-rewrite", rewrites, reqCtx);
expect(rawResult).toBeNull();
});
it("double-encoded paths are decoded only once", async () => {
const { normalizePath } = await import("../packages/vinext/src/server/normalize-path.js");
// %2561dmin → first decode → %61dmin (literal text, not /admin)
const doubleEncoded = "/%2561dmin";
const decoded = normalizePath(decodeURIComponent(doubleEncoded));
// Should decode to /%61dmin, NOT to /admin
expect(decoded).toBe("/%61dmin");
expect(decoded).not.toBe("/admin");
});
});
describe("double-encoded path handling in middleware", () => {
it("double-encoded path /%2564ashboard does not match /dashboard middleware pattern", async () => {
const { matchPattern, matchesMiddleware } =
await import("../packages/vinext/src/server/middleware.js");
const { normalizePath } = await import("../packages/vinext/src/server/normalize-path.js");
// /%2564ashboard with a single decode becomes /%64ashboard (NOT /dashboard).
// The pathname should be decoded exactly once at the entry point.
const testPath = "/%2564ashboard";
const decoded = decodeURIComponent(testPath); // Single decode
const normalized = normalizePath(decoded);
// After one decode, this is NOT /dashboard — it's /%64ashboard
expect(normalized).toBe("/%64ashboard");
// Middleware should NOT match /dashboard for this path
expect(matchPattern(normalized, "/dashboard")).toBe(false);
expect(matchesMiddleware(normalized, "/dashboard")).toBe(false);
});
it("double-encoded slash /foo/..%252fdashboard does not resolve to /dashboard", async () => {
const { matchPattern } = await import("../packages/vinext/src/server/middleware.js");
const { normalizePath } = await import("../packages/vinext/src/server/normalize-path.js");
// /foo/..%252fdashboard with a single decode becomes /foo/..%2fdashboard.
// normalizePath does NOT treat %2f as a path separator, so no traversal occurs.
const testPath = "/foo/..%252fdashboard";
const decoded = decodeURIComponent(testPath); // Single decode
const normalized = normalizePath(decoded);
// After one decode + normalize, this should NOT resolve to /dashboard
expect(normalized).not.toBe("/dashboard");
// The .. only traverses if followed by a real /, not an encoded %2f
expect(matchPattern(normalized, "/dashboard")).toBe(false);
});
it("RSC route matching does not double-decode pathnames", async () => {
// Verify the generated entry delegates route matching to the typed helper.
const { generateRscEntry } = await import("../packages/vinext/src/entries/app-rsc-entry.js");
const code = generateRscEntry("/tmp/app", [
{
pattern: "/dashboard",
patternParts: ["dashboard"],
isDynamic: false,
params: [],
pagePath: null,
routePath: null,
layouts: [],
routeSegments: [],
layoutTreePositions: [],
templates: [],
loadingPath: null,
errorPath: null,
layoutErrorPaths: [],
notFoundPath: null,
notFoundPaths: [],
forbiddenPaths: [],
forbiddenPath: null,
unauthorizedPaths: [],
unauthorizedPath: null,
parallelSlots: [],
siblingIntercepts: [],
},
]);
expect(code).toContain("createAppRscRouteMatcher as __createAppRscRouteMatcher");
expect(code).toContain("return __routeMatcher.matchRoute(url);");
const routeMatchingSource = await readFile(
new URL("../packages/vinext/src/server/app-rsc-route-matching.ts", import.meta.url),
"utf8",
);
// Verify it does NOT call decodeURIComponent (the comment mentions it but
// should not have an actual call like `decodeURIComponent(...)`)
expect(routeMatchingSource).not.toMatch(/\bdecodeURIComponent\s*\(/);
});
it("App Router middleware receives a Request with the decoded pathname (not raw URL)", async () => {
const { applyAppMiddleware } = await import("../packages/vinext/src/server/app-middleware.js");
let capturedUrl: string | undefined;
const module = {
default: (req: Request) => {
capturedUrl = req.url;
return new Response(null, {
headers: { "x-middleware-next": "1" },
});
},
};
const result = await applyAppMiddleware({
cleanPathname: "/%64ashboard",
context: { headers: null, requestHeaders: null, status: null },
isProxy: false,
module,
request: new Request("http://localhost:3000/%2564ashboard"),
});
expect(result.kind).toBe("continue");
expect(capturedUrl).toBeDefined();
const mwPathname = new URL(capturedUrl!).pathname;
expect(mwPathname).toBe("/%64ashboard");
expect(mwPathname).not.toBe("/%2564ashboard");
});
it("App Router middleware preserves status from NextResponse.next()", async () => {
const { applyAppMiddleware } = await import("../packages/vinext/src/server/app-middleware.js");
const { NextResponse } = await import("../packages/vinext/src/shims/server.js");
const context = { headers: null, requestHeaders: null, status: null };
const result = await applyAppMiddleware({
cleanPathname: "/",
context,
isProxy: false,
module: {
default: () => NextResponse.next({ status: 404 }),
},
request: new Request("http://localhost:3000/"),
});
expect(result.kind).toBe("continue");
expect(context.status).toBe(404);
});
it("App Router middleware does not see internal Flight headers", async () => {
// Next.js strips FLIGHT_HEADERS before creating the middleware request:
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/web/adapter.ts
const { applyAppMiddleware } = await import("../packages/vinext/src/server/app-middleware.js");
let capturedHeaders: Headers | undefined;
const module = {
default: (req: Request) => {
capturedHeaders = new Headers(req.headers);
return new Response(null, {
headers: { "x-middleware-next": "1" },
});
},
};
const result = await applyAppMiddleware({
cleanPathname: "/dashboard",
context: { headers: null, requestHeaders: null, status: null },
isProxy: false,
module,
request: new Request("http://localhost:3000/dashboard", {
headers: {
rsc: "1",
"next-router-state-tree": "%5B%5D",
"next-router-prefetch": "1",
"next-router-segment-prefetch": "/dashboard",
"next-hmr-refresh": "1",
"x-user-visible": "keep",
},
}),
});
expect(result.kind).toBe("continue");
expect(capturedHeaders?.get("rsc")).toBeNull();
expect(capturedHeaders?.get("next-router-state-tree")).toBeNull();
expect(capturedHeaders?.get("next-router-prefetch")).toBeNull();
expect(capturedHeaders?.get("next-router-segment-prefetch")).toBeNull();
expect(capturedHeaders?.get("next-hmr-refresh")).toBeNull();
expect(capturedHeaders?.get("x-user-visible")).toBe("keep");
});
it("App Router Flight header stripping does not lock the original request body", async () => {
const { applyAppMiddleware } = await import("../packages/vinext/src/server/app-middleware.js");
const request = new Request("http://localhost:3000/actions", {
body: "action-payload",
headers: {
rsc: "1",
"next-router-state-tree": "%5B%5D",
},
method: "POST",
});
const module = {
default: () =>
new Response(null, {
headers: { "x-middleware-next": "1" },
}),
};
const result = await applyAppMiddleware({
cleanPathname: "/actions",
context: { headers: null, requestHeaders: null, status: null },
isProxy: false,
module,
request,
});
expect(result.kind).toBe("continue");
expect(request.body?.locked).toBe(false);
expect(await request.text()).toBe("action-payload");
});
it("App Router middleware can read the body without consuming the downstream request", async () => {
const { applyAppMiddleware } = await import("../packages/vinext/src/server/app-middleware.js");
const request = new Request("http://localhost:3000/api/echo", {
body: JSON.stringify({ message: "hello" }),
headers: { "content-type": "application/json" },
method: "POST",
});
let middlewareBody: unknown;
const module = {
default: async (req: Request) => {
middlewareBody = await req.json();
return new Response(null, {
headers: { "x-middleware-next": "1" },
});
},
};
const result = await applyAppMiddleware({
cleanPathname: "/api/echo",
context: { headers: null, requestHeaders: null, status: null },
isProxy: false,
module,
request,
});
expect(result.kind).toBe("continue");
expect(middlewareBody).toEqual({ message: "hello" });
expect(request.bodyUsed).toBe(false);
await expect(request.json()).resolves.toEqual({ message: "hello" });
});
it("external middleware rewrite proxy strips upstream x-middleware headers without middleware context", async () => {
const { proxyExternalMiddlewareRewrite } =
await import("../packages/vinext/src/server/app-middleware.js");
const http = await import("node:http");
const server = http.createServer((_req, res) => {
res.writeHead(200, {
"content-type": "text/plain",
"x-middleware-rewrite": "/internal",
"x-middleware-next": "1",
"x-visible": "keep",
});
res.end("proxied");
});
await new Promise<void>((resolve) => server.listen(0, resolve));
try {
const address = server.address();
expect(address && typeof address === "object").toBe(true);
const port = address && typeof address === "object" ? address.port : 0;
const response = await proxyExternalMiddlewareRewrite(
new Request("http://localhost:3000/source"),
`http://127.0.0.1:${port}/target`,
{ headers: null, requestHeaders: null, status: null },
);
expect(response.status).toBe(200);
expect(await response.text()).toBe("proxied");
expect(response.headers.get("x-middleware-rewrite")).toBeNull();
expect(response.headers.get("x-middleware-next")).toBeNull();
expect(response.headers.get("x-visible")).toBe("keep");
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("external middleware rewrite proxy preserves credentials when applying partial request overrides", async () => {
const { proxyExternalMiddlewareRewrite } =
await import("../packages/vinext/src/server/app-middleware.js");
const http = await import("node:http");
let capturedHeaders: Record<string, string | string[] | undefined> = {};
const server = http.createServer((req, res) => {
capturedHeaders = req.headers;
res.writeHead(200, { "content-type": "text/plain" });
res.end("proxied");
});
await new Promise<void>((resolve) => server.listen(0, resolve));
try {
const address = server.address();
expect(address && typeof address === "object").toBe(true);
const port = address && typeof address === "object" ? address.port : 0;
const response = await proxyExternalMiddlewareRewrite(
new Request("http://localhost:3000/source", {
headers: {
authorization: "Bearer secret",
cookie: "session=abc",
"x-keep": "original",
},
}),
`http://127.0.0.1:${port}/target`,
{
headers: null,
requestHeaders: new Headers({
"x-middleware-override-headers": "x-added",
"x-middleware-request-x-added": "1",
}),
status: null,
},
);
expect(response.status).toBe(200);
expect(capturedHeaders.authorization).toBe("Bearer secret");
expect(capturedHeaders.cookie).toBe("session=abc");
expect(capturedHeaders["x-added"]).toBe("1");
expect(capturedHeaders["x-keep"]).toBeUndefined();
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("Pages Router runMiddleware passes decoded pathname to middleware function", async () => {
const { runMiddleware } = await import("../packages/vinext/src/server/middleware.js");
// Create a mock Vite server that returns a middleware module
let capturedUrl: string | undefined;
const mockRunner = {
import: async () => ({
default: (req: Request) => {
capturedUrl = req.url;
return new Response("OK", {
headers: { "x-middleware-next": "1" },
});
},
config: { matcher: "/:path*" },
}),
};
// Send a double-encoded path — after single decode, it should be /%64ashboard
const testUrl = "http://localhost:3000/%2564ashboard";
const request = new Request(testUrl);
await runMiddleware(mockRunner as any, "/tmp/middleware.ts", request);
// Middleware should have received the decoded+normalized URL
expect(capturedUrl).toBeDefined();
const mwPathname = new URL(capturedUrl!).pathname;
// After single decode: %25 → %, so /%2564 → /%64
expect(mwPathname).toBe("/%64ashboard");
// It must NOT be the raw /%2564ashboard
expect(mwPathname).not.toBe("/%2564ashboard");
// It must NOT be double-decoded to /dashboard
expect(mwPathname).not.toBe("/dashboard");
});
it("runMiddleware accepts named proxy export", async () => {
const { runMiddleware } = await import("../packages/vinext/src/server/middleware.js");
const mockRunner = {
import: async () => ({
proxy: () => {
const response = new Response(null, { status: 307 });
response.headers.set("location", "/login");
return response;
},
config: { matcher: ["/protected"] },
}),
};
const request = new Request("http://localhost/protected");
const result = await runMiddleware(mockRunner as any, "/tmp/proxy.js", request);
expect(result.continue).toBe(false);
expect(result.redirectUrl).toContain("/login");
expect(result.redirectStatus).toBe(307);
});
it("runMiddleware bubbles up waitUntil promises in result", async () => {
const { runMiddleware } = await import("../packages/vinext/src/server/middleware.js");
let capturedPromise: Promise<unknown> | null = null;
const mockRunner = {
import: async () => ({
middleware: (_req: Request, event: { waitUntil: (p: Promise<unknown>) => void }) => {
const p = Promise.resolve("background-work");
capturedPromise = p;
event.waitUntil(p);
return Response.redirect("http://localhost/login", 307);
},
config: { matcher: ["/protected"] },
}),
};
const request = new Request("http://localhost/protected");
const result = await runMiddleware(mockRunner as any, "/tmp/middleware.ts", request);
// The most critical behavior: waitUntil promises must appear in the result
// so the runtime (e.g. Cloudflare Workers ctx.waitUntil) can keep them alive.
expect(result.continue).toBe(false);
expect(result.redirectUrl).toBeDefined();
expect(result.waitUntilPromises).toBeDefined();
expect(result.waitUntilPromises!.length).toBe(1);
expect(result.waitUntilPromises![0]).toBe(capturedPromise);
});
it("runMiddleware bubbles up waitUntil promises on continue: true path", async () => {
const { runMiddleware } = await import("../packages/vinext/src/server/middleware.js");
const { NextResponse } = await import("../packages/vinext/src/shims/server.js");
let capturedPromise: Promise<unknown> | null = null;
const mockRunner = {
import: async () => ({
middleware: (_req: Request, event: { waitUntil: (p: Promise<unknown>) => void }) => {
const p = Promise.resolve("analytics");
capturedPromise = p;
event.waitUntil(p);
return NextResponse.next();
},
config: { matcher: ["/dashboard"] },
}),
};
const request = new Request("http://localhost/dashboard");
const result = await runMiddleware(mockRunner as any, "/tmp/middleware.ts", request);
expect(result.continue).toBe(true);
expect(result.waitUntilPromises).toBeDefined();
expect(result.waitUntilPromises!.length).toBe(1);
expect(result.waitUntilPromises![0]).toBe(capturedPromise);
});
it("app-router-entry.ts does not double-decode (delegates to RSC handler)", async () => {
// Verify the Cloudflare Worker entry does not decode the pathname itself,
// leaving that responsibility to the RSC handler.
const fs = await import("node:fs");
const entryCode = fs.readFileSync(
new URL("../packages/vinext/src/server/app-router-entry.ts", import.meta.url),
"utf-8",
);
// The entry should validate encoding but NOT normalize+reconstruct the request
// (the RSC handler is the single decode point)
expect(entryCode).not.toMatch(/normalizedRequest\s*=\s*new Request\(normalizedUrl/);
// It should still validate malformed encoding (return 400)
expect(entryCode).toMatch(
/decodeURIComponent\(\s*(?:raw)?[pP]athname|decodeURIComponent\(url\.pathname\)/,
);
// The delegate call should pass the original request object through,
// without reconstructing a normalized Request before delegation.
expect(entryCode).toMatch(/rscHandler\(request(?:,\s*ctx)?\)/);
});
});
// ---------------------------------------------------------------------------
// NextFetchEvent — middleware receives event with waitUntil support
describe("NextFetchEvent passed to middleware", () => {
it("runMiddleware passes NextFetchEvent as second argument", async () => {
const { runMiddleware } = await import("../packages/vinext/src/server/middleware.js");
// Middleware that accesses event.waitUntil — will throw if event is undefined
let receivedEvent: any;
const mockRunner = {
import: async () => ({
middleware: (_req: any, event: any) => {
receivedEvent = event;
event.waitUntil(Promise.resolve("done"));
return new Response(null, {
headers: { "x-middleware-next": "1" },
});
},
config: { matcher: ["/test"] },
}),
};
const request = new Request("http://localhost:3000/test");
const result = await runMiddleware(mockRunner as any, "/tmp/middleware.ts", request);
expect(result.continue).toBe(true);
expect(receivedEvent).toBeDefined();
expect(typeof receivedEvent.waitUntil).toBe("function");
expect(receivedEvent.sourcePage).toBe("/test");
});
it("waitUntil promises are drained after middleware runs", async () => {
const { runMiddleware } = await import("../packages/vinext/src/server/middleware.js");
let sideEffectRan = false;
const mockRunner = {
import: async () => ({
middleware: (_req: any, event: any) => {
event.waitUntil(
Promise.resolve().then(() => {
sideEffectRan = true;
}),
);
return new Response(null, {
headers: { "x-middleware-next": "1" },
});
},
config: { matcher: ["/drain"] },
}),
};
const request = new Request("http://localhost:3000/drain");
await runMiddleware(mockRunner as any, "/tmp/middleware.ts", request);
// The waitUntil promise should have been resolved.
// Flush the microtask queue so Promise.resolve().then(...) callbacks run.
await new Promise((r) => queueMicrotask(r as () => void));
expect(sideEffectRan).toBe(true);
});
});
// ---------------------------------------------------------------------------
// RequestCookies comprehensive tests
describe("RequestCookies API", () => {
it("get() returns cookie by name", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers({ cookie: "token=abc123; session=xyz" });
const cookies = new RequestCookies(headers);
const token = cookies.get("token");
expect(token).toEqual({ name: "token", value: "abc123" });
const session = cookies.get("session");
expect(session).toEqual({ name: "session", value: "xyz" });
});
it("get() returns undefined for missing cookie", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers({ cookie: "token=abc123" });
const cookies = new RequestCookies(headers);
expect(cookies.get("missing")).toBeUndefined();
});
it("getAll() returns all cookies", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers({ cookie: "a=1; b=2; c=3" });
const cookies = new RequestCookies(headers);
const all = cookies.getAll();
expect(all).toHaveLength(3);
expect(all).toContainEqual({ name: "a", value: "1" });
expect(all).toContainEqual({ name: "b", value: "2" });
expect(all).toContainEqual({ name: "c", value: "3" });
});
it("getAll(name) filters by cookie name", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers({ cookie: "a=1; a=2; b=3" });
const cookies = new RequestCookies(headers);
expect(cookies.get("a")).toEqual({ name: "a", value: "2" });
expect(cookies.getAll("a")).toEqual([{ name: "a", value: "2" }]);
expect(cookies.getAll()).toEqual([
{ name: "a", value: "2" },
{ name: "b", value: "3" },
]);
});
it("getAll({ name }) filters by cookie name and missing names return []", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers({ cookie: "a=1; a=2; token=abc=def" });
const cookies = new RequestCookies(headers);
expect(cookies.getAll({ name: "a", value: "ignored" })).toEqual([{ name: "a", value: "2" }]);
expect(cookies.getAll("missing")).toEqual([]);
expect(cookies.getAll({ name: "missing", value: "" })).toEqual([]);
expect(cookies.get("token")).toEqual({ name: "token", value: "abc=def" });
});
it("parses encoded values, skips malformed values, and supports bare tokens", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers({ cookie: "token=abc%3D123; bad=%E0%A4%A; flag; ok=yes" });
const cookies = new RequestCookies(headers);
expect(cookies.get("token")).toEqual({ name: "token", value: "abc=123" });
expect(cookies.get("bad")).toBeUndefined();
expect(cookies.get("flag")).toEqual({ name: "flag", value: "true" });
expect(cookies.getAll()).toEqual([
{ name: "token", value: "abc=123" },
{ name: "flag", value: "true" },
{ name: "ok", value: "yes" },
]);
});
it("preserves explicit empty values", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers({ cookie: "empty=; flag" });
const cookies = new RequestCookies(headers);
expect(cookies.get("empty")).toEqual({ name: "empty", value: "" });
expect(cookies.get("flag")).toEqual({ name: "flag", value: "true" });
expect(cookies.getAll()).toEqual([
{ name: "empty", value: "" },
{ name: "flag", value: "true" },
]);
});
it("preserves whitespace exactly like the Next.js parser", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers({ cookie: "a= 1 ; a =2" });
const cookies = new RequestCookies(headers);
expect(cookies.get("a")).toEqual({ name: "a", value: " 1 " });
expect(cookies.get("a ")).toEqual({ name: "a ", value: "2" });
expect(cookies.getAll()).toEqual([
{ name: "a", value: " 1 " },
{ name: "a ", value: "2" },
]);
});
it("has() checks cookie existence", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers({ cookie: "token=abc" });
const cookies = new RequestCookies(headers);
expect(cookies.has("token")).toBe(true);
expect(cookies.has("missing")).toBe(false);
});
it("iterator yields [name, entry] pairs", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers({ cookie: "x=1; y=2" });
const cookies = new RequestCookies(headers);
const entries = [...cookies];
expect(entries).toHaveLength(2);
expect(entries[0][0]).toBe("x");
expect(entries[0][1]).toEqual({ name: "x", value: "1" });
expect(entries[1][0]).toBe("y");
expect(entries[1][1]).toEqual({ name: "y", value: "2" });
});
it("handles empty cookie header", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new RequestCookies(headers);
expect(cookies.getAll()).toHaveLength(0);
expect(cookies.get("any")).toBeUndefined();
expect(cookies.has("any")).toBe(false);
});
it("handles cookies with = in value", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers({ cookie: "data=base64=encoded=value" });
const cookies = new RequestCookies(headers);
const data = cookies.get("data");
expect(data).toBeDefined();
expect(data!.value).toBe("base64=encoded=value");
});
it("set() adds a cookie and updates the Cookie header", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers({ cookie: "a=1" });
const cookies = new RequestCookies(headers);
cookies.set("b", "2");
expect(cookies.get("b")).toEqual({ name: "b", value: "2" });
// The underlying Cookie header should be updated
expect(headers.get("cookie")).toContain("b=2");
// Original cookie should still be there
expect(cookies.get("a")).toEqual({ name: "a", value: "1" });
});
it("set() overwrites an existing cookie value", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers({ cookie: "token=old" });
const cookies = new RequestCookies(headers);
cookies.set("token", "new");
expect(cookies.get("token")).toEqual({ name: "token", value: "new" });
expect(headers.get("cookie")).toContain("token=new");
expect(headers.get("cookie")).not.toContain("token=old");
});
it("set() accepts an object with name and value", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new RequestCookies(headers);
cookies.set({ name: "session", value: "abc" });
expect(cookies.get("session")).toEqual({ name: "session", value: "abc" });
});
it("set() returns this for chaining", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new RequestCookies(headers);
const result = cookies.set("a", "1").set("b", "2");
expect(result).toBe(cookies);
expect(cookies.get("a")).toEqual({ name: "a", value: "1" });
expect(cookies.get("b")).toEqual({ name: "b", value: "2" });
});
it("set() rejects invalid cookie names", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new RequestCookies(headers);
expect(() => cookies.set("foo=bar; Path=/", "val")).toThrow("Invalid cookie name");
expect(() => cookies.set("foo; HttpOnly", "val")).toThrow("Invalid cookie name");
expect(() => cookies.set("foo\r\nCookie: evil=1", "val")).toThrow("Invalid cookie name");
expect(() => cookies.set("", "val")).toThrow("Invalid cookie name");
});
it("set({ name, value }) rejects invalid cookie names", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new RequestCookies(headers);
expect(() => cookies.set({ name: "foo=bar", value: "val" })).toThrow("Invalid cookie name");
});
it("delete() removes a cookie from the Cookie header", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers({ cookie: "a=1; b=2; c=3" });
const cookies = new RequestCookies(headers);
cookies.delete("b");
expect(cookies.has("b")).toBe(false);
expect(cookies.get("b")).toBeUndefined();
// Other cookies remain
expect(cookies.get("a")).toEqual({ name: "a", value: "1" });
expect(cookies.get("c")).toEqual({ name: "c", value: "3" });
});
it("delete() returns true when cookie existed, false otherwise", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers({ cookie: "a=1; b=2" });
const cookies = new RequestCookies(headers);
expect(cookies.delete("a")).toBe(true);
expect(cookies.delete("nonexistent")).toBe(false);
});
it("delete() accepts an array of names", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers({ cookie: "a=1; b=2; c=3" });
const cookies = new RequestCookies(headers);
const results = cookies.delete(["a", "missing", "c"]);
expect(results).toEqual([true, false, true]);
expect(cookies.has("a")).toBe(false);
expect(cookies.has("c")).toBe(false);
expect(cookies.get("b")).toEqual({ name: "b", value: "2" });
});
it("delete() rejects invalid cookie names", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers({ cookie: "a=1; b=2" });
const cookies = new RequestCookies(headers);
expect(() => cookies.delete("foo=bar")).toThrow("Invalid cookie name");
expect(() => cookies.delete(["a", "foo;bar"])).toThrow("Invalid cookie name");
});
it("delete() is a no-op for missing cookies", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers({ cookie: "a=1" });
const cookies = new RequestCookies(headers);
cookies.delete("nonexistent");
expect(cookies.get("a")).toEqual({ name: "a", value: "1" });
});
it("size returns the number of cookies", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers({ cookie: "a=1; b=2; c=3" });
const cookies = new RequestCookies(headers);
expect(cookies.size).toBe(3);
});
it("size is 0 for empty cookie header", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new RequestCookies(headers);
expect(cookies.size).toBe(0);
});
it("toString() serializes cookies back to a cookie header string", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers({ cookie: "a=1; b=2" });
const cookies = new RequestCookies(headers);
const str = cookies.toString();
expect(str).toContain("a=1");
expect(str).toContain("b=2");
});
it("set() round-trips values with special characters", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new RequestCookies(headers);
cookies.set("data", "hello;world=foo");
expect(cookies.get("data")).toEqual({ name: "data", value: "hello;world=foo" });
// Header should be encoded
expect(headers.get("cookie")).toBe("data=hello%3Bworld%3Dfoo");
});
it("set() preserves existing percent-encoded cookies", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers({ cookie: "token=100%25done; sid=abc" });
const cookies = new RequestCookies(headers);
cookies.set("new", "value");
expect(cookies.get("token")).toEqual({ name: "token", value: "100%done" });
expect(cookies.get("new")).toEqual({ name: "new", value: "value" });
});
it("delete() after set() removes the cookie", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers({ cookie: "a=1" });
const cookies = new RequestCookies(headers);
cookies.set("b", "2");
expect(cookies.get("b")).toEqual({ name: "b", value: "2" });
cookies.delete("b");
expect(cookies.has("b")).toBe(false);
expect(cookies.get("a")).toEqual({ name: "a", value: "1" });
});
it("clear() removes all cookies", async () => {
const { RequestCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers({ cookie: "a=1; b=2; c=3" });
const cookies = new RequestCookies(headers);
cookies.clear();
expect(cookies.size).toBe(0);
expect(cookies.getAll()).toHaveLength(0);
expect(headers.get("cookie")).toBeNull();
});
});
// ---------------------------------------------------------------------------
// ResponseCookies comprehensive tests
describe("ResponseCookies API", () => {
it("set() creates Set-Cookie header with options", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("token", "abc123", {
path: "/",
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: 3600,
});
const setCookie = headers.getSetCookie();
expect(setCookie).toHaveLength(1);
expect(setCookie[0]).toContain("token=abc123");
expect(setCookie[0]).toContain("Path=/");
expect(setCookie[0]).toContain("HttpOnly");
expect(setCookie[0]).toContain("Secure");
expect(setCookie[0]).toContain("SameSite=Lax");
expect(setCookie[0]).toContain("Max-Age=3600");
});
it("set() multiple cookies appends multiple Set-Cookie headers", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("a", "1");
cookies.set("b", "2");
const setCookie = headers.getSetCookie();
expect(setCookie).toHaveLength(2);
expect(setCookie[0]).toContain("a=1");
expect(setCookie[1]).toContain("b=2");
});
it("get() retrieves a cookie from Set-Cookie headers", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("token", "xyz");
const result = cookies.get("token");
expect(result).toEqual({ name: "token", value: "xyz" });
});
it("getAll() returns all set cookies", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("a", "1");
cookies.set("b", "2");
cookies.set("c", "3");
const all = cookies.getAll();
expect(all).toHaveLength(3);
expect(all).toContainEqual({ name: "a", value: "1" });
expect(all).toContainEqual({ name: "b", value: "2" });
expect(all).toContainEqual({ name: "c", value: "3" });
});
it("has() checks whether a response cookie exists", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("session", "abc");
expect(cookies.has("session")).toBe(true);
expect(cookies.has("missing")).toBe(false);
});
it("delete() expires the cookie (matching edge-runtime)", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.delete("session");
const setCookie = headers.getSetCookie();
expect(setCookie).toHaveLength(1);
expect(setCookie[0]).toContain("session=");
expect(setCookie[0]).toContain("Expires=Thu, 01 Jan 1970 00:00:00 GMT");
expect(setCookie[0]).toContain("Path=/");
});
it("set() URL-encodes cookie values", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("data", "hello world; special=chars");
const setCookie = headers.getSetCookie();
expect(setCookie[0]).toContain("data=hello%20world%3B%20special%3Dchars");
// get() should decode it back
const result = cookies.get("data");
expect(result?.value).toBe("hello world; special=chars");
});
it("iterator yields [name, entry] pairs", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("x", "1");
cookies.set("y", "2");
const entries = [...cookies];
expect(entries).toHaveLength(2);
expect(entries[0][0]).toBe("x");
expect(entries[0][1]).toEqual({ name: "x", value: "1" });
expect(entries[1][0]).toBe("y");
expect(entries[1][1]).toEqual({ name: "y", value: "2" });
});
it("set() with domain option includes Domain directive", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("token", "abc", { domain: ".example.com" });
const setCookie = headers.getSetCookie();
expect(setCookie[0]).toContain("Domain=.example.com");
});
it("set() with expires option includes Expires directive", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
const expires = new Date("2030-01-01T00:00:00Z");
cookies.set("token", "abc", { expires });
const setCookie = headers.getSetCookie();
expect(setCookie[0]).toContain("Expires=");
expect(setCookie[0]).toContain("2030");
});
});
// ---------------------------------------------------------------------------
// ResponseCookies correctness (Next.js parity)
// Ported from @edge-runtime/cookies: packages/cookies/src/response-cookies.ts
// https://github.com/vercel/edge-runtime/blob/main/packages/cookies/src/response-cookies.ts
describe("ResponseCookies correctness", () => {
// Bug 1: set() same cookie name twice should replace, not duplicate
it("set() same cookie name twice replaces the header (no duplicates)", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("session", "old-value");
cookies.set("session", "new-value");
const setCookie = headers.getSetCookie();
expect(setCookie).toHaveLength(1);
expect(setCookie[0]).toContain("session=new-value");
});
it("get() returns the latest value after set() overwrites", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("token", "first");
cookies.set("token", "second");
const result = cookies.get("token");
expect(result?.value).toBe("second");
});
it("set() replaces only the matching cookie, preserves others", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("a", "1");
cookies.set("b", "2");
cookies.set("a", "3");
const setCookie = headers.getSetCookie();
expect(setCookie).toHaveLength(2);
// 'a' was replaced, 'b' stays
const aHeader = setCookie.find((h: string) => h.startsWith("a="));
const bHeader = setCookie.find((h: string) => h.startsWith("b="));
expect(aHeader).toContain("a=3");
expect(bHeader).toContain("b=2");
});
// Bug 2: set() should accept object form
it("set() accepts object form { name, value, ... }", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set({ name: "token", value: "abc", httpOnly: true, path: "/api" });
const setCookie = headers.getSetCookie();
expect(setCookie).toHaveLength(1);
expect(setCookie[0]).toContain("token=abc");
expect(setCookie[0]).toContain("HttpOnly");
expect(setCookie[0]).toContain("Path=/api");
const result = cookies.get("token");
expect(result?.value).toBe("abc");
});
it("set() object form replaces existing cookie of same name", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("token", "old");
cookies.set({ name: "token", value: "new", secure: true });
const setCookie = headers.getSetCookie();
expect(setCookie).toHaveLength(1);
expect(setCookie[0]).toContain("token=new");
expect(setCookie[0]).toContain("Secure");
});
// Bug 3: getAll() should accept optional name filter
it("getAll(name) filters by cookie name", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("a", "1");
cookies.set("b", "2");
cookies.set("c", "3");
const filtered = cookies.getAll("b");
expect(filtered).toHaveLength(1);
expect(filtered[0]).toEqual({ name: "b", value: "2" });
});
it("getAll({ name }) filters by cookie name (object form)", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("x", "10");
cookies.set("y", "20");
const filtered = cookies.getAll({ name: "x" });
expect(filtered).toHaveLength(1);
expect(filtered[0]).toEqual({ name: "x", value: "10" });
});
it("getAll() with no args returns all cookies", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("a", "1");
cookies.set("b", "2");
const all = cookies.getAll();
expect(all).toHaveLength(2);
});
it("getAll(name) returns empty array for non-existent cookie", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("a", "1");
const filtered = cookies.getAll("missing");
expect(filtered).toHaveLength(0);
});
// Bug 4: delete() should accept object and array forms
it("delete({ name, path, domain }) sets correct expiry cookie with path and domain", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.delete({ name: "session", path: "/app", domain: ".example.com" });
const setCookie = headers.getSetCookie();
expect(setCookie).toHaveLength(1);
expect(setCookie[0]).toContain("session=");
expect(setCookie[0]).toContain("Path=/app");
expect(setCookie[0]).toContain("Domain=.example.com");
// Should expire the cookie
expect(setCookie[0]).toContain("Expires=");
});
it("delete() forwards httpOnly, secure, and sameSite attributes", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.delete({
name: "session",
path: "/app",
httpOnly: true,
secure: true,
sameSite: "Lax",
});
const setCookie = headers.getSetCookie();
expect(setCookie).toHaveLength(1);
expect(setCookie[0]).toContain("HttpOnly");
expect(setCookie[0]).toContain("Secure");
expect(setCookie[0]).toContain("SameSite=Lax");
expect(setCookie[0]).toContain("Path=/app");
expect(setCookie[0]).toContain("Expires=");
});
it("delete() replaces existing cookie's Set-Cookie header", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("session", "abc123", { path: "/app" });
cookies.delete("session");
// Should have exactly one Set-Cookie for 'session' (the deletion one)
const setCookie = headers.getSetCookie();
const sessionHeaders = setCookie.filter((h: string) => h.startsWith("session="));
expect(sessionHeaders).toHaveLength(1);
expect(sessionHeaders[0]).toContain("Expires=");
});
// Constructor should parse existing Set-Cookie headers
it("constructor parses existing Set-Cookie headers", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
headers.append("Set-Cookie", "existing=value; Path=/");
const cookies = new ResponseCookies(headers);
const result = cookies.get("existing");
expect(result?.value).toBe("value");
});
// has() should work with internal map
it("has() returns false after delete()", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("session", "abc");
expect(cookies.has("session")).toBe(true);
cookies.delete("session");
// After delete, a new expired cookie replaces the old one.
// The cookie still "exists" in the map (with empty value and past expiry),
// matching edge-runtime behavior where delete() calls set().
// has() returns true because the entry exists in the map.
expect(cookies.has("session")).toBe(true);
});
// get() with object form (matching edge-runtime)
it("get() accepts object { name } form", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("token", "abc");
const result = cookies.get({ name: "token" });
expect(result?.value).toBe("abc");
});
});
// ---------------------------------------------------------------------------
// Cookie name/value injection prevention (RFC 6265)
describe("cookie name validation", () => {
it("RequestCookies.set() rejects names with = (injection)", async () => {
const headersModule = await import("../packages/vinext/src/shims/headers.js");
headersModule.setHeadersContext({ headers: new Headers(), cookies: new Map() });
const previousPhase = headersModule.setHeadersAccessPhase("route-handler");
try {
const jar = await headersModule.cookies();
expect(() => jar.set("foo=bar; Path=/; Domain=evil.com", "val")).toThrow(
"Invalid cookie name",
);
} finally {
headersModule.setHeadersAccessPhase(previousPhase);
}
});
it("RequestCookies.set() rejects names with semicolons", async () => {
const headersModule = await import("../packages/vinext/src/shims/headers.js");
headersModule.setHeadersContext({ headers: new Headers(), cookies: new Map() });
const previousPhase = headersModule.setHeadersAccessPhase("route-handler");
try {
const jar = await headersModule.cookies();
expect(() => jar.set("foo; HttpOnly", "val")).toThrow("Invalid cookie name");
} finally {
headersModule.setHeadersAccessPhase(previousPhase);
}
});
it("RequestCookies.set() rejects names with newlines", async () => {
const headersModule = await import("../packages/vinext/src/shims/headers.js");
headersModule.setHeadersContext({ headers: new Headers(), cookies: new Map() });
const previousPhase = headersModule.setHeadersAccessPhase("route-handler");
try {
const jar = await headersModule.cookies();
expect(() => jar.set("foo\r\nSet-Cookie: evil=1", "val")).toThrow("Invalid cookie name");
} finally {
headersModule.setHeadersAccessPhase(previousPhase);
}
});
it("RequestCookies.set() rejects empty names", async () => {
const headersModule = await import("../packages/vinext/src/shims/headers.js");
headersModule.setHeadersContext({ headers: new Headers(), cookies: new Map() });
const previousPhase = headersModule.setHeadersAccessPhase("route-handler");
try {
const jar = await headersModule.cookies();
expect(() => jar.set("", "val")).toThrow("Invalid cookie name");
} finally {
headersModule.setHeadersAccessPhase(previousPhase);
}
});
it("RequestCookies.set() accepts valid cookie names", async () => {
const headersModule = await import("../packages/vinext/src/shims/headers.js");
headersModule.setHeadersContext({ headers: new Headers(), cookies: new Map() });
const previousPhase = headersModule.setHeadersAccessPhase("route-handler");
try {
const jar = await headersModule.cookies();
// These should not throw
jar.set("valid-name", "value");
jar.set("__Host-token", "value");
jar.set("session_id", "value");
jar.set("CSRF.Token", "value");
} finally {
headersModule.setHeadersAccessPhase(previousPhase);
}
});
it("RequestCookies.delete() rejects invalid names", async () => {
const headersModule = await import("../packages/vinext/src/shims/headers.js");
headersModule.setHeadersContext({ headers: new Headers(), cookies: new Map() });
const previousPhase = headersModule.setHeadersAccessPhase("route-handler");
try {
const jar = await headersModule.cookies();
expect(() => jar.delete("foo=bar")).toThrow("Invalid cookie name");
} finally {
headersModule.setHeadersAccessPhase(previousPhase);
}
});
it("RequestCookies.set() rejects path with semicolons", async () => {
const headersModule = await import("../packages/vinext/src/shims/headers.js");
headersModule.setHeadersContext({ headers: new Headers(), cookies: new Map() });
const previousPhase = headersModule.setHeadersAccessPhase("route-handler");
try {
const jar = await headersModule.cookies();
expect(() => jar.set("name", "val", { path: "/; Domain=evil.com" })).toThrow(
"Invalid cookie Path",
);
} finally {
headersModule.setHeadersAccessPhase(previousPhase);
}
});
it("ResponseCookies.set() rejects names with = (injection)", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
expect(() => cookies.set("foo=bar; Path=/", "val")).toThrow("Invalid cookie name");
});
it("ResponseCookies.set() rejects domain with control chars", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
expect(() => cookies.set("name", "val", { domain: "evil.com\r\nSet-Cookie: hack=1" })).toThrow(
"Invalid cookie Domain",
);
});
it("ResponseCookies.set() accepts valid cookie names and options", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
// These should not throw
cookies.set("valid-name", "value", { path: "/", domain: ".example.com" });
cookies.set("__Secure-token", "abc", { secure: true, httpOnly: true });
});
});
// ---------------------------------------------------------------------------
// NextRequest API tests
describe("NextRequest API", () => {
it("throws canonical 'Please use only absolute URLs' error for relative URL input", async () => {
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
// Matches Next.js's documented behaviour — middleware tests assert on this
// exact message via the middleware-relative-urls docs link.
expect(() => new NextRequest("/foo")).toThrow(/Please use only absolute URLs/);
expect(() => new NextRequest("/urls-b")).toThrow(
/URL is malformed "\/urls-b"\. Please use only absolute URLs/,
);
});
it("cookies reads request cookies", async () => {
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
const req = new NextRequest("http://localhost/test", {
headers: { cookie: "session=abc; theme=dark" },
});
expect(req.cookies.get("session")).toEqual({ name: "session", value: "abc" });
expect(req.cookies.get("theme")).toEqual({ name: "theme", value: "dark" });
expect(req.cookies.has("session")).toBe(true);
expect(req.cookies.has("missing")).toBe(false);
});
it("nextUrl provides URL properties", async () => {
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
const req = new NextRequest("http://localhost:3000/api/test?key=value#hash");
expect(req.nextUrl.pathname).toBe("/api/test");
expect(req.nextUrl.search).toBe("?key=value");
expect(req.nextUrl.searchParams.get("key")).toBe("value");
expect(req.nextUrl.host).toBe("localhost:3000");
expect(req.nextUrl.hostname).toBe("localhost");
expect(req.nextUrl.protocol).toBe("http:");
expect(req.nextUrl.hash).toBe("#hash");
});
it("nextUrl.clone() creates independent copy", async () => {
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
const req = new NextRequest("http://localhost/test");
const cloned = req.nextUrl.clone();
cloned.pathname = "/other";
expect(req.nextUrl.pathname).toBe("/test");
expect(cloned.pathname).toBe("/other");
});
it("nextUrl supports all URL setters (port, host, hostname, protocol, href)", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://example.com:8080/path?q=1#hash");
// port setter
url.port = "9090";
expect(url.port).toBe("9090");
// hostname setter
url.hostname = "other.com";
expect(url.hostname).toBe("other.com");
// host setter (hostname + port)
url.host = "third.com:3000";
expect(url.host).toBe("third.com:3000");
expect(url.hostname).toBe("third.com");
expect(url.port).toBe("3000");
// protocol setter
url.protocol = "https:";
expect(url.protocol).toBe("https:");
// href setter
url.href = "http://new.com/new-path";
expect(url.href).toBe("http://new.com/new-path");
expect(url.hostname).toBe("new.com");
expect(url.pathname).toBe("/new-path");
});
it("nextUrl.clone() setters work for next-intl compatibility", async () => {
// next-intl's getAlternateLinksHeaderValue does: cloned.port = ""; cloned.host = h
// This test ensures those setter operations work without throwing.
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
const req = new NextRequest("http://example.com:8080/en");
const cloned = req.nextUrl.clone();
// Should not throw — next-intl strips port when x-forwarded-host is present
cloned.port = "";
expect(cloned.port).toBe("");
// Should not throw — next-intl sets host from x-forwarded-host header
cloned.host = "example.com";
expect(cloned.hostname).toBe("example.com");
// Original should be unaffected
expect(req.nextUrl.port).toBe("8080");
});
it("ip reads x-forwarded-for header", async () => {
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
const req = new NextRequest("http://localhost/", {
headers: { "x-forwarded-for": "1.2.3.4, 5.6.7.8" },
});
expect(req.ip).toBe("1.2.3.4");
});
it("ip returns undefined when no header", async () => {
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
const req = new NextRequest("http://localhost/");
expect(req.ip).toBeUndefined();
});
it("geo reads Cloudflare headers", async () => {
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
const req = new NextRequest("http://localhost/", {
headers: {
"cf-ipcountry": "US",
"cf-ipcity": "San Francisco",
},
});
expect(req.geo?.country).toBe("US");
expect(req.geo?.city).toBe("San Francisco");
});
it("geo returns undefined when no geo headers", async () => {
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
const req = new NextRequest("http://localhost/");
expect(req.geo).toBeUndefined();
});
it("nextUrl.buildId returns process.env.__VINEXT_BUILD_ID when set", async () => {
const original = process.env.__VINEXT_BUILD_ID;
try {
process.env.__VINEXT_BUILD_ID = "test-build-123";
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
const req = new NextRequest("http://localhost/");
expect(req.nextUrl.buildId).toBe("test-build-123");
} finally {
if (original === undefined) {
delete process.env.__VINEXT_BUILD_ID;
} else {
process.env.__VINEXT_BUILD_ID = original;
}
}
});
it("nextUrl.buildId returns undefined when __VINEXT_BUILD_ID is not set", async () => {
const original = process.env.__VINEXT_BUILD_ID;
try {
delete process.env.__VINEXT_BUILD_ID;
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
const req = new NextRequest("http://localhost/");
expect(req.nextUrl.buildId).toBeUndefined();
} finally {
if (original !== undefined) {
process.env.__VINEXT_BUILD_ID = original;
}
}
});
it("buildId pass-through on NextRequest delegates to nextUrl.buildId", async () => {
const original = process.env.__VINEXT_BUILD_ID;
try {
process.env.__VINEXT_BUILD_ID = "test-build-456";
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
const req = new NextRequest("http://localhost/");
expect(req.buildId).toBe(req.nextUrl.buildId);
} finally {
if (original === undefined) {
delete process.env.__VINEXT_BUILD_ID;
} else {
process.env.__VINEXT_BUILD_ID = original;
}
}
});
});
// ---------------------------------------------------------------------------
// NextURL basePath and locale properties
describe("NextURL basePath and locale properties", () => {
const i18nConfig = {
nextConfig: {
i18n: {
locales: ["en", "fr", "de"],
defaultLocale: "en",
},
},
};
it("basePath defaults to empty string when no config provided", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/dashboard");
expect(url.basePath).toBe("");
});
it("basePath is empty when URL does not start with the configured prefix", async () => {
// Matches Next.js getNextPathnameInfo behavior: basePath is only set when
// the URL's pathname actually starts with the configured basePath prefix.
// This is critical for middleware "absolute path" support — middleware
// receiving a request without the basePath prefix should see basePath === "".
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/dashboard", undefined, {
basePath: "/app",
});
expect(url.basePath).toBe("");
});
it("basePath is set when URL starts with the configured prefix", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/app/dashboard", undefined, {
basePath: "/app",
});
expect(url.basePath).toBe("/app");
// pathname is stripped of the basePath prefix
expect(url.pathname).toBe("/dashboard");
});
it("basePath setter normalizes leading slash", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/dashboard");
url.basePath = "app";
expect(url.basePath).toBe("/app");
});
it("href reassignment re-derives basePath from the configured value", async () => {
// Next.js's NextURL.analyze() re-runs getNextPathnameInfo on every href
// set, so basePath toggles based on whether the new pathname carries the
// configured prefix.
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/dashboard", undefined, {
basePath: "/app",
});
expect(url.basePath).toBe("");
// Moving inside the basePath re-activates it from the configured value.
url.href = "http://localhost/app/settings";
expect(url.basePath).toBe("/app");
expect(url.pathname).toBe("/settings");
// Moving back outside the basePath clears it again.
url.href = "http://localhost/elsewhere";
expect(url.basePath).toBe("");
expect(url.pathname).toBe("/elsewhere");
});
it("basePath is empty in clone when URL does not have the prefix", async () => {
// When the URL doesn't have the basePath prefix, basePath is cleared during
// construction, and the clone reflects that empty basePath.
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/dashboard", undefined, {
basePath: "/docs",
});
const cloned = url.clone();
expect(cloned.basePath).toBe("");
});
it("basePath is preserved through clone() when URL has the prefix", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/docs/page", undefined, {
basePath: "/docs",
});
const cloned = url.clone();
expect(cloned.basePath).toBe("/docs");
});
it("locale defaults to empty string when no i18n config", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/about");
expect(url.locale).toBe("");
expect(url.defaultLocale).toBeUndefined();
});
it("locale returns the detected locale from pathname", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/fr/about", undefined, i18nConfig);
expect(url.locale).toBe("fr");
expect(url.defaultLocale).toBe("en");
expect(url.pathname).toBe("/about");
});
it("locale falls back to defaultLocale when no locale in pathname", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/about", undefined, i18nConfig);
expect(url.locale).toBe("en");
expect(url.pathname).toBe("/about");
});
it("uses the matching domain default locale", async () => {
// Ported from Next.js: packages/next/src/server/web/next-url.ts
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("https://example.fr/about", undefined, {
nextConfig: {
i18n: {
locales: ["en", "fr", "de"],
defaultLocale: "en",
domains: [{ domain: "example.fr", defaultLocale: "fr", locales: ["fr"] }],
},
},
});
expect(url.defaultLocale).toBe("fr");
expect(url.locale).toBe("fr");
expect(url.domainLocale).toEqual({
domain: "example.fr",
defaultLocale: "fr",
locales: ["fr"],
});
expect(url.pathname).toBe("/about");
});
it("pathname locale overrides the matching domain default locale", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("https://example.fr/de/about", undefined, {
nextConfig: {
i18n: {
locales: ["en", "fr", "de"],
defaultLocale: "en",
domains: [{ domain: "example.fr", defaultLocale: "fr", locales: ["fr"] }],
},
},
});
expect(url.defaultLocale).toBe("fr");
expect(url.locale).toBe("de");
expect(url.pathname).toBe("/about");
});
it("does not add locale prefixes when formatting API paths", async () => {
// Ported from Next.js: packages/next/src/shared/lib/router/utils/add-locale.ts
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("https://example.com/fr/api/example", undefined, i18nConfig);
expect(url.locale).toBe("fr");
expect(url.pathname).toBe("/api/example");
expect(url.href).toBe("https://example.com/api/example");
expect(url.clone().href).toBe("https://example.com/api/example");
});
it("detects API paths case-insensitively when formatting locale URLs", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("https://example.com/fr/API/example", undefined, i18nConfig);
expect(url.href).toBe("https://example.com/API/example");
});
it("re-analyzes domain locale after href reassignment", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("https://example.com/about", undefined, {
nextConfig: {
i18n: {
locales: ["en", "fr"],
defaultLocale: "en",
domains: [{ domain: "example.fr", defaultLocale: "fr", locales: ["fr"] }],
},
},
});
expect(url.defaultLocale).toBe("en");
url.href = "https://example.fr/about";
expect(url.defaultLocale).toBe("fr");
expect(url.locale).toBe("fr");
expect(url.domainLocale?.domain).toBe("example.fr");
});
it("locale detection is case-insensitive", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/FR/about", undefined, i18nConfig);
expect(url.locale).toBe("fr");
expect(url.pathname).toBe("/about");
});
it("locale setter updates the locale and affects href", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/fr/about", undefined, i18nConfig);
expect(url.locale).toBe("fr");
url.locale = "de";
expect(url.locale).toBe("de");
expect(url.href).toContain("/de/about");
});
it("locale setter throws on invalid locale", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/fr/about", undefined, i18nConfig);
expect(() => {
url.locale = "es";
}).toThrow(TypeError);
});
it("locales returns a copy of the configured locales array", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/about", undefined, i18nConfig);
const locales = url.locales!;
expect(locales).toEqual(["en", "fr", "de"]);
// Mutating the returned array must not affect internals
locales.push("es");
expect(url.locales).toEqual(["en", "fr", "de"]);
});
it("locales returns undefined without i18n config", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/about");
expect(url.locales).toBeUndefined();
});
// --- href / toString() reconstruction ---
it("toString() preserves locale prefix in serialized URL", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/fr/about", undefined, i18nConfig);
expect(url.toString()).toBe("http://localhost/fr/about");
expect(url.href).toBe("http://localhost/fr/about");
});
it("toString() omits defaultLocale prefix (matches Next.js)", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/about", undefined, i18nConfig);
expect(url.locale).toBe("en"); // defaultLocale
expect(url.toString()).toBe("http://localhost/about");
});
it("setting locale changes the serialized href", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/fr/about", undefined, i18nConfig);
url.locale = "de";
expect(url.href).toBe("http://localhost/de/about");
});
it("href includes basePath prefix", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/app/dashboard", undefined, {
basePath: "/app",
});
expect(url.pathname).toBe("/dashboard");
expect(url.href).toBe("http://localhost/app/dashboard");
});
it("href includes both basePath and locale prefix", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/app/fr/about", undefined, {
basePath: "/app",
...i18nConfig,
});
expect(url.pathname).toBe("/about");
expect(url.href).toBe("http://localhost/app/fr/about");
});
it("href preserves port, search, and hash when basePath is active", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost:3000/app/dashboard?q=1#top", undefined, {
basePath: "/app",
});
expect(url.pathname).toBe("/dashboard");
expect(url.href).toBe("http://localhost:3000/app/dashboard?q=1#top");
});
it("root locale path /fr produces pathname /", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/fr", undefined, i18nConfig);
expect(url.locale).toBe("fr");
expect(url.pathname).toBe("/");
expect(url.href).toBe("http://localhost/fr");
});
it("href setter re-analyzes locale", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/fr/about", undefined, i18nConfig);
expect(url.locale).toBe("fr");
url.href = "http://localhost/de/contact";
expect(url.locale).toBe("de");
expect(url.pathname).toBe("/contact");
});
it("href setter re-strips basePath before locale analysis", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/app/fr/about", undefined, {
basePath: "/app",
...i18nConfig,
});
url.href = "http://localhost/app/de/contact";
expect(url.locale).toBe("de");
expect(url.pathname).toBe("/contact");
expect(url.basePath).toBe("/app");
});
it("basePath setter to empty string clears basePath", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/app/dashboard", undefined, {
basePath: "/app",
});
expect(url.basePath).toBe("/app");
url.basePath = "";
expect(url.basePath).toBe("");
expect(url.href).toBe("http://localhost/dashboard");
});
it("basePath root path has no trailing slash", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/app", undefined, {
basePath: "/app",
});
expect(url.pathname).toBe("/");
expect(url.href).toBe("http://localhost/app");
});
it("basePath is stripped from input URL (basePath-only, no i18n)", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/app/dashboard", undefined, {
basePath: "/app",
});
expect(url.pathname).toBe("/dashboard");
expect(url.basePath).toBe("/app");
expect(url.href).toBe("http://localhost/app/dashboard");
});
it("pathname setter does not re-analyze locale", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/fr/about", undefined, i18nConfig);
url.pathname = "/contact";
expect(url.locale).toBe("fr"); // unchanged
expect(url.pathname).toBe("/contact");
expect(url.href).toBe("http://localhost/fr/contact");
});
it("basePath root path with default locale has no trailing slash", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/app", undefined, {
basePath: "/app",
...i18nConfig,
});
expect(url.locale).toBe("en"); // default locale, no prefix in output
expect(url.pathname).toBe("/");
expect(url.href).toBe("http://localhost/app");
});
it("locale setter resets to defaultLocale when set to undefined with i18n", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/fr/about", undefined, i18nConfig);
expect(url.locale).toBe("fr");
url.locale = undefined;
expect(url.locale).toBe("en"); // falls back to defaultLocale
expect(url.href).toBe("http://localhost/about"); // default locale omitted from prefix
});
it("locale setter resets to defaultLocale when set to empty string with i18n", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/de/contact", undefined, i18nConfig);
url.locale = "";
expect(url.locale).toBe("en");
});
it("searchParams mutations are reflected in href with basePath and locale", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/app/fr/about", undefined, {
basePath: "/app",
...i18nConfig,
});
url.searchParams.set("q", "2");
expect(url.href).toBe("http://localhost/app/fr/about?q=2");
});
// --- clone() ---
it("clone() preserves locale, basePath, and config through constructor", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/app/fr/about", undefined, {
basePath: "/app",
...i18nConfig,
});
const cloned = url.clone();
expect(cloned.basePath).toBe("/app");
expect(cloned.locale).toBe("fr");
expect(cloned.defaultLocale).toBe("en");
expect(cloned.pathname).toBe("/about");
expect(cloned.href).toBe("http://localhost/app/fr/about");
// Mutations on clone don't affect original
cloned.locale = "de";
expect(url.locale).toBe("fr");
});
it("matches domain locale from the detected pathname locale", async () => {
// Ported from Next.js: packages/next/src/shared/lib/i18n/detect-domain-locale.ts
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("https://example.com/fr/about", undefined, {
nextConfig: {
i18n: {
locales: ["en", "fr", "de"],
defaultLocale: "en",
domains: [
{ domain: "example.fr", defaultLocale: "fr", locales: ["fr"] },
{ domain: "example.de", defaultLocale: "de", locales: ["de"] },
],
},
},
});
expect(url.domainLocale?.domain).toBe("example.fr");
expect(url.defaultLocale).toBe("fr");
expect(url.locale).toBe("fr");
expect(url.pathname).toBe("/about");
});
// --- NextRequest integration ---
it("NextRequest passes basePath and i18n config through to nextUrl", async () => {
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
const req = new NextRequest("http://localhost/app/fr/dashboard", {
nextConfig: {
basePath: "/app",
i18n: {
locales: ["en", "fr"],
defaultLocale: "en",
},
},
});
expect(req.nextUrl.basePath).toBe("/app");
expect(req.nextUrl.locale).toBe("fr");
expect(req.nextUrl.defaultLocale).toBe("en");
expect(req.nextUrl.pathname).toBe("/dashboard");
expect(req.nextUrl.href).toBe("http://localhost/app/fr/dashboard");
});
it("NextRequest.url reflects the normalized nextUrl href", async () => {
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
const req = new NextRequest("http://localhost/app/fr/dashboard?tab=settings", {
nextConfig: {
basePath: "/app",
i18n: { locales: ["en", "fr"], defaultLocale: "en" },
},
});
expect(req.nextUrl.href).toBe("http://localhost/app/fr/dashboard?tab=settings");
expect(req.url).toBe(req.nextUrl.href);
});
it("NextRequest.url preserves raw input when middleware URL normalization is disabled", async () => {
const previous = process.env.__NEXT_NO_MIDDLEWARE_URL_NORMALIZE;
process.env.__NEXT_NO_MIDDLEWARE_URL_NORMALIZE = "1";
try {
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
const req = new NextRequest("http://localhost/app/fr/dashboard?tab=settings", {
nextConfig: {
basePath: "/app",
i18n: { locales: ["en", "fr"], defaultLocale: "en" },
},
});
expect(req.nextUrl.href).toBe("http://localhost/app/fr/dashboard?tab=settings");
expect(req.url).toBe("http://localhost/app/fr/dashboard?tab=settings");
} finally {
if (previous === undefined) {
delete process.env.__NEXT_NO_MIDDLEWARE_URL_NORMALIZE;
} else {
process.env.__NEXT_NO_MIDDLEWARE_URL_NORMALIZE = previous;
}
}
});
it("NextRequest passes config when input is a Request object", async () => {
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
const raw = new Request("http://localhost/app/fr/dashboard");
const req = new NextRequest(raw, {
nextConfig: {
basePath: "/app",
i18n: { locales: ["en", "fr"], defaultLocale: "en" },
},
});
expect(req.nextUrl.basePath).toBe("/app");
expect(req.nextUrl.locale).toBe("fr");
expect(req.nextUrl.pathname).toBe("/dashboard");
expect(req.nextUrl.href).toBe("http://localhost/app/fr/dashboard");
});
it("NextRequest.url normalizes Request input through nextUrl", async () => {
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
const raw = new Request("http://localhost/app/fr/dashboard?tab=settings");
const req = new NextRequest(raw, {
nextConfig: {
basePath: "/app",
i18n: { locales: ["en", "fr"], defaultLocale: "en" },
},
});
expect(req.nextUrl.href).toBe("http://localhost/app/fr/dashboard?tab=settings");
expect(req.url).toBe(req.nextUrl.href);
});
});
// ---------------------------------------------------------------------------
// NextURL trailingSlash policy (regression for issue #1332)
//
// The trailingSlash config must reach NextURL so that
// `NextResponse.redirect(request.nextUrl)` and friends emit a Location header
// that honours the user's slash policy. Mirrors Next.js's
// `formatNextPathnameInfo` behaviour.
describe("NextURL trailingSlash policy", () => {
it("appends a trailing slash to href when trailingSlash is true", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/about", undefined, {
nextConfig: { trailingSlash: true },
});
expect(url.href).toBe("http://localhost/about/");
expect(url.toString()).toBe("http://localhost/about/");
});
it("does not add a trailing slash to the root path", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/", undefined, {
nextConfig: { trailingSlash: true },
});
expect(url.href).toBe("http://localhost/");
});
it("preserves trailing slash when trailingSlash is true and input already has one", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/about/", undefined, {
nextConfig: { trailingSlash: true },
});
expect(url.href).toBe("http://localhost/about/");
});
it("strips trailing slash when trailingSlash is false", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/about/", undefined, {
nextConfig: { trailingSlash: false },
});
expect(url.href).toBe("http://localhost/about");
});
it("defaults to no trailing slash when trailingSlash is not configured", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/about/");
expect(url.href).toBe("http://localhost/about");
});
it("applies trailingSlash after setting pathname", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/x", undefined, {
nextConfig: { trailingSlash: true },
});
url.pathname = "/somewhere";
expect(url.href).toBe("http://localhost/somewhere/");
});
it("applies trailingSlash with basePath", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/app/dashboard", undefined, {
basePath: "/app",
nextConfig: { trailingSlash: true },
});
expect(url.pathname).toBe("/dashboard");
expect(url.href).toBe("http://localhost/app/dashboard/");
});
it("applies trailingSlash with locale", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/fr/about", undefined, {
nextConfig: {
i18n: { locales: ["en", "fr"], defaultLocale: "en" },
trailingSlash: true,
},
});
expect(url.locale).toBe("fr");
expect(url.href).toBe("http://localhost/fr/about/");
});
it("preserves search and hash when adding trailing slash", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/about?q=1#top", undefined, {
nextConfig: { trailingSlash: true },
});
expect(url.href).toBe("http://localhost/about/?q=1#top");
});
it("clone() preserves the trailingSlash config", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/about", undefined, {
nextConfig: { trailingSlash: true },
});
const cloned = url.clone();
cloned.pathname = "/contact";
expect(cloned.href).toBe("http://localhost/contact/");
});
});
describe("NextRequest plumbs trailingSlash into nextUrl", () => {
it("forwards trailingSlash from nextConfig into the NextURL", async () => {
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
const req = new NextRequest("http://localhost/about", {
nextConfig: { trailingSlash: true },
});
expect(req.nextUrl.href).toBe("http://localhost/about/");
});
it("NextResponse.redirect(request.nextUrl) emits a Location that honours trailingSlash", async () => {
const { NextRequest, NextResponse } = await import("../packages/vinext/src/shims/server.js");
const req = new NextRequest("http://localhost/redirect", {
nextConfig: { trailingSlash: true },
});
const url = req.nextUrl.clone();
url.pathname = "/somewhere";
const res = NextResponse.redirect(url);
expect(res.status).toBe(307);
const location = res.headers.get("location");
expect(location).not.toBeNull();
// Location is fully-qualified per the spec; the pathname must include the slash.
expect(new URL(location!).pathname).toBe("/somewhere/");
});
it("NextResponse.redirect(request.nextUrl) drops trailing slash when trailingSlash is false", async () => {
const { NextRequest, NextResponse } = await import("../packages/vinext/src/shims/server.js");
const req = new NextRequest("http://localhost/redirect/", {
nextConfig: { trailingSlash: false },
});
const url = req.nextUrl.clone();
url.pathname = "/somewhere/";
const res = NextResponse.redirect(url);
const location = res.headers.get("location");
expect(location).not.toBeNull();
expect(new URL(location!).pathname).toBe("/somewhere");
});
});
// ---------------------------------------------------------------------------
// NextResponse.next() with request header forwarding
describe("NextResponse.next() request header forwarding", () => {
it("forwards request headers as x-middleware-request-* headers", async () => {
const { NextResponse } = await import("../packages/vinext/src/shims/server.js");
const res = NextResponse.next({
request: {
headers: new Headers({
"x-custom-header": "custom-value",
authorization: "Bearer token123",
}),
},
});
expect(res.headers.get("x-middleware-next")).toBe("1");
expect(res.headers.get("x-middleware-request-x-custom-header")).toBe("custom-value");
expect(res.headers.get("x-middleware-request-authorization")).toBe("Bearer token123");
});
it("serializes the full override set so omitted headers can be deleted downstream", async () => {
const { NextResponse } = await import("../packages/vinext/src/shims/server.js");
const forwardedHeaders = new Headers({
"x-custom-header": "custom-value",
"x-added": "1",
});
const res = NextResponse.next({
request: {
headers: forwardedHeaders,
},
});
const overrideHeaders = res.headers.get("x-middleware-override-headers");
expect(overrideHeaders).not.toBeNull();
expect(overrideHeaders!.split(",").sort()).toEqual([...forwardedHeaders.keys()].sort());
expect(res.headers.get("x-middleware-request-x-custom-header")).toBe("custom-value");
expect(res.headers.get("x-middleware-request-x-added")).toBe("1");
});
});
describe("middleware request header overrides", () => {
// Ported from Next.js: test/e2e/middleware-request-header-overrides/test/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-request-header-overrides/test/index.test.ts
it("config-matchers applyMiddlewareRequestHeaders deletes omitted headers from the request", async () => {
const { applyMiddlewareRequestHeaders } =
await import("../packages/vinext/src/config/config-matchers.js");
const middlewareHeaders: Record<string, string> = {
"x-middleware-override-headers": "x-keep,x-added",
"x-middleware-request-x-keep": "updated",
"x-middleware-request-x-added": "1",
"x-middleware-next": "1",
};
const request = new Request("http://localhost/test", {
headers: {
authorization: "Bearer secret",
cookie: "a=1; b=2",
"x-keep": "original",
},
});
const { request: nextRequest, postMwReqCtx } = applyMiddlewareRequestHeaders(
middlewareHeaders,
request,
);
expect(nextRequest.headers.get("authorization")).toBeNull();
expect(nextRequest.headers.get("cookie")).toBeNull();
expect(nextRequest.headers.get("x-keep")).toBe("updated");
expect(nextRequest.headers.get("x-added")).toBe("1");
expect(Object.keys(postMwReqCtx.cookies)).toEqual([]);
expect(middlewareHeaders).toEqual({});
});
it("config-matchers applyMiddlewareRequestHeaders preserves existing headers in add-only overrides", async () => {
const { applyMiddlewareRequestHeaders } =
await import("../packages/vinext/src/config/config-matchers.js");
const request = new Request("http://localhost/test", {
headers: {
authorization: "Bearer secret",
cookie: "a=1; b=2",
"x-keep": "original",
},
});
const forwardedHeaders = new Headers(request.headers);
forwardedHeaders.set("x-added", "1");
const middlewareHeaders: Record<string, string> = {
"x-middleware-override-headers": [...forwardedHeaders.keys()].join(","),
"x-middleware-request-authorization": forwardedHeaders.get("authorization")!,
"x-middleware-request-cookie": forwardedHeaders.get("cookie")!,
"x-middleware-request-x-keep": forwardedHeaders.get("x-keep")!,
"x-middleware-request-x-added": "1",
"x-middleware-next": "1",
};
const { request: nextRequest, postMwReqCtx } = applyMiddlewareRequestHeaders(
middlewareHeaders,
request,
);
expect(nextRequest.headers.get("authorization")).toBe("Bearer secret");
expect(nextRequest.headers.get("cookie")).toBe("a=1; b=2");
expect(nextRequest.headers.get("x-keep")).toBe("original");
expect(nextRequest.headers.get("x-added")).toBe("1");
expect(postMwReqCtx.cookies).toEqual({ a: "1", b: "2" });
expect(middlewareHeaders).toEqual({});
});
it("next/headers applyMiddlewareRequestHeaders replaces the live request header set", async () => {
const {
applyMiddlewareRequestHeaders,
cookies,
headers,
headersContextFromRequest,
runWithHeadersContext,
} = await import("../packages/vinext/src/shims/headers.js");
const request = new Request("http://localhost/test", {
headers: {
authorization: "Bearer secret",
cookie: "a=1; b=2",
"x-keep": "original",
},
});
await runWithHeadersContext(headersContextFromRequest(request), async () => {
applyMiddlewareRequestHeaders(
new Headers({
"x-middleware-override-headers": "x-keep,x-added",
"x-middleware-request-x-keep": "updated",
"x-middleware-request-x-added": "1",
}),
);
const liveHeaders = await headers();
const liveCookies = await cookies();
expect(liveHeaders.get("authorization")).toBeNull();
expect(liveHeaders.get("cookie")).toBeNull();
expect(liveHeaders.get("x-keep")).toBe("updated");
expect(liveHeaders.get("x-added")).toBe("1");
expect(liveCookies.getAll()).toEqual([]);
});
});
it("next/headers applyMiddlewareRequestHeaders invalidates headers() snapshot taken before the override", async () => {
// Regression: a middleware that reads `headers()` (or `cookies()`) before
// applying a request-header override would prime a sealed read-only
// snapshot built from the *pre*-override request. Discovered with
// @clerk/nextjs whose `clerkClient()` reads `headers()` via
// `buildRequestLike()` during middleware execution; before the fix, the
// Server Component subsequently received the stale snapshot and saw the
// pre-override credentials and missing middleware-injected headers.
const {
applyMiddlewareRequestHeaders,
cookies,
headers,
headersContextFromRequest,
runWithHeadersContext,
} = await import("../packages/vinext/src/shims/headers.js");
const request = new Request("http://localhost/test", {
headers: {
authorization: "Bearer secret",
cookie: "a=1; b=2",
"x-keep": "original",
},
});
await runWithHeadersContext(headersContextFromRequest(request), async () => {
// 1. Prime the sealed snapshot — this is exactly what
// `clerkMiddleware()` does internally via `buildRequestLike()`.
const preHeadersPromise = headers();
const preCookiesPromise = cookies();
const preHeaders = await preHeadersPromise;
const preCookies = await preCookiesPromise;
expect(preHeaders.get("authorization")).toBe("Bearer secret");
expect(preHeaders.get("x-keep")).toBe("original");
expect(preCookies.getAll()).toEqual([
{ name: "a", value: "1" },
{ name: "b", value: "2" },
]);
// 2. Apply the override — drops `authorization`/`cookie`, adds `x-added`,
// and updates `x-keep`.
applyMiddlewareRequestHeaders(
new Headers({
"x-middleware-override-headers": "x-keep,x-added",
"x-middleware-request-x-keep": "updated",
"x-middleware-request-x-added": "1",
}),
);
// 3. A subsequent `headers()` call — for example from the Server
// Component's render — must observe the override, not the snapshot
// captured in step 1.
const postHeadersPromise = headers();
const postCookiesPromise = cookies();
const postHeaders = await postHeadersPromise;
const postCookies = await postCookiesPromise;
expect(postHeadersPromise).not.toBe(preHeadersPromise);
expect(postCookiesPromise).not.toBe(preCookiesPromise);
expect(postHeaders.get("authorization")).toBeNull();
expect(postHeaders.get("cookie")).toBeNull();
expect(postHeaders.get("x-keep")).toBe("updated");
expect(postHeaders.get("x-added")).toBe("1");
expect(postCookies.getAll()).toEqual([]);
});
});
});
// ---------------------------------------------------------------------------
// NextResponse.redirect() with different status codes
describe("NextResponse.redirect() status codes", () => {
it("defaults to 307 Temporary Redirect", async () => {
const { NextResponse } = await import("../packages/vinext/src/shims/server.js");
const res = NextResponse.redirect("https://example.com");
expect(res.status).toBe(307);
});
it("supports 301 Permanent Redirect", async () => {
const { NextResponse } = await import("../packages/vinext/src/shims/server.js");
const res = NextResponse.redirect("https://example.com", 301);
expect(res.status).toBe(301);
// validateURL() normalizes via `new URL()`, adding a trailing slash for origin-only URLs.
expect(res.headers.get("Location")).toBe("https://example.com/");
});
it("supports 302 Found", async () => {
const { NextResponse } = await import("../packages/vinext/src/shims/server.js");
const res = NextResponse.redirect("https://example.com", 302);
expect(res.status).toBe(302);
});
it("supports 308 Permanent Redirect", async () => {
const { NextResponse } = await import("../packages/vinext/src/shims/server.js");
const res = NextResponse.redirect("https://example.com", 308);
expect(res.status).toBe(308);
});
it("accepts URL object", async () => {
const { NextResponse } = await import("../packages/vinext/src/shims/server.js");
const url = new URL("https://example.com/target");
const res = NextResponse.redirect(url);
expect(res.headers.get("Location")).toBe("https://example.com/target");
});
it("supports 303 See Other", async () => {
const { NextResponse } = await import("../packages/vinext/src/shims/server.js");
const res = NextResponse.redirect("https://example.com", 303);
expect(res.status).toBe(303);
});
// Ported from Next.js: packages/next/src/server/web/spec-extension/response.ts
// Next.js validates redirect status codes and throws RangeError for invalid ones.
it("throws RangeError for non-redirect status code 200", async () => {
const { NextResponse } = await import("../packages/vinext/src/shims/server.js");
expect(() => NextResponse.redirect("https://example.com", 200)).toThrow(RangeError);
});
it("throws RangeError for non-redirect status code 418", async () => {
const { NextResponse } = await import("../packages/vinext/src/shims/server.js");
expect(() => NextResponse.redirect("https://example.com", 418)).toThrow(RangeError);
});
it("throws RangeError with descriptive message for invalid status", async () => {
const { NextResponse } = await import("../packages/vinext/src/shims/server.js");
expect(() => NextResponse.redirect("https://example.com", 200)).toThrow(
/Failed to execute "redirect" on "response": Invalid status code/,
);
});
});
// ---------------------------------------------------------------------------
// matchConfigPattern unit tests (next.config.js redirects/rewrites)
describe("matchConfigPattern", () => {
it("matches exact paths", async () => {
const { matchConfigPattern } = await import("../packages/vinext/src/config/config-matchers.js");
expect(matchConfigPattern("/about", "/about")).toEqual({});
expect(matchConfigPattern("/", "/")).toEqual({});
expect(matchConfigPattern("/about", "/other")).toBeNull();
});
it("matches single :param segments", async () => {
const { matchConfigPattern } = await import("../packages/vinext/src/config/config-matchers.js");
const result = matchConfigPattern("/blog/hello-world", "/blog/:slug");
expect(result).toEqual({ slug: "hello-world" });
});
it("matches multiple :param segments", async () => {
const { matchConfigPattern } = await import("../packages/vinext/src/config/config-matchers.js");
const result = matchConfigPattern("/blog/2024/my-post", "/blog/:year/:slug");
expect(result).toEqual({ year: "2024", slug: "my-post" });
});
it("rejects when segment count differs for non-wildcard patterns", async () => {
const { matchConfigPattern } = await import("../packages/vinext/src/config/config-matchers.js");
expect(matchConfigPattern("/blog/a/b", "/blog/:slug")).toBeNull();
expect(matchConfigPattern("/blog", "/blog/:slug")).toBeNull();
});
it("matches :path* catch-all (zero or more segments)", async () => {
const { matchConfigPattern } = await import("../packages/vinext/src/config/config-matchers.js");
// Zero segments
expect(matchConfigPattern("/docs", "/docs/:path*")).toEqual({ path: "" });
// One segment
expect(matchConfigPattern("/docs/intro", "/docs/:path*")).toEqual({ path: "intro" });
// Multiple segments
expect(matchConfigPattern("/docs/guide/getting-started", "/docs/:path*")).toEqual({
path: "guide/getting-started",
});
});
it("matches :path+ catch-all (one or more segments)", async () => {
const { matchConfigPattern } = await import("../packages/vinext/src/config/config-matchers.js");
// One segment
expect(matchConfigPattern("/api/users", "/api/:path+")).toEqual({ path: "users" });
// Multiple segments
expect(matchConfigPattern("/api/users/123", "/api/:path+")).toEqual({ path: "users/123" });
// Zero segments — should NOT match
expect(matchConfigPattern("/api", "/api/:path+")).toBeNull();
});
it("matches regex group patterns", async () => {
const { matchConfigPattern } = await import("../packages/vinext/src/config/config-matchers.js");
// Common Next.js pattern: /:path(\\d+) for numeric paths
const result = matchConfigPattern("/123", "/:id(\\d+)");
if (result) {
expect(result.id).toBe("123");
}
// Non-numeric should not match
expect(matchConfigPattern("/abc", "/:id(\\d+)")).toBeNull();
});
it("handles dots in patterns", async () => {
const { matchConfigPattern } = await import("../packages/vinext/src/config/config-matchers.js");
expect(matchConfigPattern("/feed.xml", "/feed.xml")).toEqual({});
// Dot should not match any character
expect(matchConfigPattern("/feedXxml", "/feed.xml")).toBeNull();
});
it("matches :path* with literal suffix (e.g. /:path*.md)", async () => {
const { matchConfigPattern } = await import("../packages/vinext/src/config/config-matchers.js");
// Should match URLs ending in .md
expect(matchConfigPattern("/article.md", "/:path*.md")).toEqual({ path: "article" });
expect(matchConfigPattern("/news/my-article.md", "/:path*.md")).toEqual({
path: "news/my-article",
});
expect(matchConfigPattern("/docs/guide/intro.md", "/:path*.md")).toEqual({
path: "docs/guide/intro",
});
// Should NOT match URLs without .md suffix
expect(matchConfigPattern("/", "/:path*.md")).toBeNull();
expect(matchConfigPattern("/about", "/:path*.md")).toBeNull();
expect(matchConfigPattern("/news", "/:path*.md")).toBeNull();
expect(matchConfigPattern("/article.txt", "/:path*.md")).toBeNull();
});
it("matches :path+ with literal suffix (e.g. /:path+.json)", async () => {
const { matchConfigPattern } = await import("../packages/vinext/src/config/config-matchers.js");
// Should match URLs ending in .json with at least one path segment
expect(matchConfigPattern("/data.json", "/:path+.json")).toEqual({ path: "data" });
expect(matchConfigPattern("/api/users.json", "/:path+.json")).toEqual({ path: "api/users" });
// Should NOT match bare .json (zero segments before suffix)
expect(matchConfigPattern("/.json", "/:path+.json")).toBeNull();
// Should NOT match URLs without .json suffix
expect(matchConfigPattern("/data", "/:path+.json")).toBeNull();
expect(matchConfigPattern("/", "/:path+.json")).toBeNull();
});
it("matches :path* with prefix and suffix (e.g. /docs/:path*.md)", async () => {
const { matchConfigPattern } = await import("../packages/vinext/src/config/config-matchers.js");
expect(matchConfigPattern("/docs/intro.md", "/docs/:path*.md")).toEqual({ path: "intro" });
expect(matchConfigPattern("/docs/guide/getting-started.md", "/docs/:path*.md")).toEqual({
path: "guide/getting-started",
});
// Should NOT match without .md
expect(matchConfigPattern("/docs/intro", "/docs/:path*.md")).toBeNull();
// Should NOT match different prefix
expect(matchConfigPattern("/blog/intro.md", "/docs/:path*.md")).toBeNull();
});
it("matches :param with literal suffix (e.g. /:slug.md)", async () => {
const { matchConfigPattern } = await import("../packages/vinext/src/config/config-matchers.js");
// Should match URLs with the .md suffix and extract the param
expect(matchConfigPattern("/hello-world.md", "/:slug.md")).toEqual({ slug: "hello-world" });
expect(matchConfigPattern("/my-post.md", "/:slug.md")).toEqual({ slug: "my-post" });
// Should NOT match URLs without .md suffix
expect(matchConfigPattern("/", "/:slug.md")).toBeNull();
expect(matchConfigPattern("/hello-world", "/:slug.md")).toBeNull();
expect(matchConfigPattern("/hello-world.txt", "/:slug.md")).toBeNull();
// Should NOT match paths with extra segments
expect(matchConfigPattern("/blog/hello-world.md", "/:slug.md")).toBeNull();
});
it("matches :param with literal suffix via config-matchers module", async () => {
const { matchConfigPattern } = await import("../packages/vinext/src/config/config-matchers.js");
expect(matchConfigPattern("/hello-world.md", "/:slug.md")).toEqual({ slug: "hello-world" });
expect(matchConfigPattern("/", "/:slug.md")).toBeNull();
expect(matchConfigPattern("/hello-world", "/:slug.md")).toBeNull();
});
it("still matches plain :path* catch-all (no suffix) correctly", async () => {
const { matchConfigPattern } = await import("../packages/vinext/src/config/config-matchers.js");
// Ensure the fix doesn't regress existing catch-all behavior
expect(matchConfigPattern("/docs", "/docs/:path*")).toEqual({ path: "" });
expect(matchConfigPattern("/docs/intro", "/docs/:path*")).toEqual({ path: "intro" });
expect(matchConfigPattern("/docs/guide/getting-started", "/docs/:path*")).toEqual({
path: "guide/getting-started",
});
});
// Regression test for: catch-all prefix overmatch
// /foobar was incorrectly matched by /foo/:path* because startsWith("/foo")
// passed without checking for a segment boundary after the prefix.
// https://github.com/cloudflare/vinext/pull/368
it("regression: does not overmatch catch-all when pathname shares a prefix but not a segment boundary", async () => {
const { matchConfigPattern } = await import("../packages/vinext/src/config/config-matchers.js");
// Core regression case: /foobar must NOT match /foo/:path*
expect(matchConfigPattern("/foobar", "/foo/:path*")).toBeNull();
// Similarly for :path+
expect(matchConfigPattern("/foobar", "/foo/:path+")).toBeNull();
// A legitimate sub-path still matches
expect(matchConfigPattern("/foo/bar", "/foo/:path*")).toEqual({ path: "bar" });
// An exact prefix (zero segments) still matches for :path*
expect(matchConfigPattern("/foo", "/foo/:path*")).toEqual({ path: "" });
// An exact prefix (zero segments) still does NOT match for :path+
expect(matchConfigPattern("/foo", "/foo/:path+")).toBeNull();
// Deeper false-prefix: /football must NOT match /foot/:path*
expect(matchConfigPattern("/football", "/foot/:path*")).toBeNull();
// But /foot/ball should match
expect(matchConfigPattern("/foot/ball", "/foot/:path*")).toEqual({ path: "ball" });
});
it("regression: catch-all prefix overmatch via config-matchers module", async () => {
const { matchConfigPattern } = await import("../packages/vinext/src/config/config-matchers.js");
// Same cases exercised against the standalone config-matchers module
expect(matchConfigPattern("/foobar", "/foo/:path*")).toBeNull();
expect(matchConfigPattern("/foobar", "/foo/:path+")).toBeNull();
expect(matchConfigPattern("/foo/bar", "/foo/:path*")).toEqual({ path: "bar" });
expect(matchConfigPattern("/foo", "/foo/:path*")).toEqual({ path: "" });
expect(matchConfigPattern("/foo", "/foo/:path+")).toBeNull();
});
// Regression for #1331: a trailing slash on the request pathname must not
// hide a config rewrite/redirect/header source written without one. This
// mirrors Next.js's conditional `source + '(/)?'` suffix under
// `trailingSlash: true` — see `stripTrailingSlashForConfigMatch`.
it("strips a trailing slash on the incoming pathname before matching", async () => {
const { matchConfigPattern } = await import("../packages/vinext/src/config/config-matchers.js");
// Exact match: trailing slash on the pathname is ignored.
expect(matchConfigPattern("/about/", "/about")).toEqual({});
// :param match across the segment with a trailing slash on the pathname.
expect(matchConfigPattern("/blog/hello-world/", "/blog/:slug")).toEqual({
slug: "hello-world",
});
// Multi-segment :param with a trailing slash.
expect(matchConfigPattern("/blog/2024/my-post/", "/blog/:year/:slug")).toEqual({
year: "2024",
slug: "my-post",
});
// Regex-group pattern with a trailing slash.
expect(matchConfigPattern("/123/", "/:id(\\d+)")).toEqual({ id: "123" });
});
it("preserves the root path and catch-all semantics under trailing slash", async () => {
const { matchConfigPattern } = await import("../packages/vinext/src/config/config-matchers.js");
// The root pathname "/" stays "/" — never stripped to "".
expect(matchConfigPattern("/", "/")).toEqual({});
// Catch-alls already consume the trailing slash; the strip must not turn
// `/docs/` into `/docs` in a way that breaks zero-segment matching.
expect(matchConfigPattern("/docs/", "/docs/:path*")).toEqual({ path: "" });
// One-segment catch-all with trailing slash on the pathname.
expect(matchConfigPattern("/docs/intro/", "/docs/:path*")).toEqual({ path: "intro" });
// :path+ still requires at least one segment when the only "extra" was a slash.
expect(matchConfigPattern("/api/", "/api/:path+")).toBeNull();
});
});
// ---------------------------------------------------------------------------
// isSafeRegex / safeRegExp unit tests (ReDoS prevention)
describe("isSafeRegex", () => {
it("accepts simple patterns", async () => {
const { isSafeRegex } = await import("../packages/vinext/src/config/config-matchers.js");
expect(isSafeRegex("^/about$")).toBe(true);
expect(isSafeRegex("^/blog/[^/]+$")).toBe(true);
expect(isSafeRegex("^/docs/(.*)$")).toBe(true);
expect(isSafeRegex("^/api/(.+)$")).toBe(true);
expect(isSafeRegex("\\d+")).toBe(true);
expect(isSafeRegex("^/feed\\.xml$")).toBe(true);
});
it("accepts non-nested quantifiers inside groups", async () => {
const { isSafeRegex } = await import("../packages/vinext/src/config/config-matchers.js");
// A single quantifier inside a group without a quantifier on the group itself
expect(isSafeRegex("(a+)")).toBe(true);
expect(isSafeRegex("([^/]+)")).toBe(true);
expect(isSafeRegex("(\\d{2,4})")).toBe(true);
});
it("rejects nested quantifiers: (a+)+", async () => {
const { isSafeRegex } = await import("../packages/vinext/src/config/config-matchers.js");
expect(isSafeRegex("(a+)+")).toBe(false);
});
it("rejects nested quantifiers: (a+)*", async () => {
const { isSafeRegex } = await import("../packages/vinext/src/config/config-matchers.js");
expect(isSafeRegex("(a+)*")).toBe(false);
});
it("rejects nested quantifiers: (.*)*", async () => {
const { isSafeRegex } = await import("../packages/vinext/src/config/config-matchers.js");
expect(isSafeRegex("(.*)*")).toBe(false);
});
it("rejects nested quantifiers: (a*)+", async () => {
const { isSafeRegex } = await import("../packages/vinext/src/config/config-matchers.js");
expect(isSafeRegex("(a*)+")).toBe(false);
});
it("rejects nested quantifiers: ([^/]+)+", async () => {
const { isSafeRegex } = await import("../packages/vinext/src/config/config-matchers.js");
expect(isSafeRegex("([^/]+)+")).toBe(false);
});
it("rejects nested quantifiers with braces: (a+){2,}", async () => {
const { isSafeRegex } = await import("../packages/vinext/src/config/config-matchers.js");
expect(isSafeRegex("(a+){2,}")).toBe(false);
});
it("accepts quantifier on group without inner quantifier", async () => {
const { isSafeRegex } = await import("../packages/vinext/src/config/config-matchers.js");
// (ab)+ is fine — no inner quantifier
expect(isSafeRegex("(ab)+")).toBe(true);
expect(isSafeRegex("(foo|bar)*")).toBe(true);
});
it("treats escaped characters as safe", async () => {
const { isSafeRegex } = await import("../packages/vinext/src/config/config-matchers.js");
// \\+ is a literal +, not a quantifier
expect(isSafeRegex("(a\\+)+")).toBe(true);
});
it("treats quantifiers inside character classes as safe", async () => {
const { isSafeRegex } = await import("../packages/vinext/src/config/config-matchers.js");
// [+*] is a character class, not a quantifier
expect(isSafeRegex("([+*])+")).toBe(true);
});
it("rejects nested optional quantifiers: (a?)+", async () => {
const { isSafeRegex } = await import("../packages/vinext/src/config/config-matchers.js");
// '?' inside group + quantifier on group = catastrophic backtracking
expect(isSafeRegex("(a?)+")).toBe(false);
expect(isSafeRegex("(a?)+b")).toBe(false);
});
it("rejects nested optional quantifiers: (.?)+", async () => {
const { isSafeRegex } = await import("../packages/vinext/src/config/config-matchers.js");
expect(isSafeRegex("(.?)+")).toBe(false);
});
it("rejects nested optional quantifiers: (a?)*", async () => {
const { isSafeRegex } = await import("../packages/vinext/src/config/config-matchers.js");
expect(isSafeRegex("(a?)*")).toBe(false);
});
it("accepts outer '?' on group (zero-or-one is not unbounded repetition)", async () => {
const { isSafeRegex } = await import("../packages/vinext/src/config/config-matchers.js");
// '?' means zero or one — only 2 paths, not exponential backtracking
// This is safe even with inner quantifiers (e.g. URL patterns like (?:/.*)? )
expect(isSafeRegex("(a+)?")).toBe(true);
expect(isSafeRegex("(?:/.*)?")).toBe(true);
});
it("treats non-greedy modifier as safe, not as quantifier", async () => {
const { isSafeRegex } = await import("../packages/vinext/src/config/config-matchers.js");
// a+? is non-greedy '+', not a nested quantifier
expect(isSafeRegex("(a+?)")).toBe(true);
// (a*?) is non-greedy '*', still just one quantifier
expect(isSafeRegex("(a*?)")).toBe(true);
});
});
describe("safeRegExp", () => {
it("returns RegExp for safe patterns", async () => {
const { safeRegExp } = await import("../packages/vinext/src/config/config-matchers.js");
const re = safeRegExp("^/about$");
expect(re).toBeInstanceOf(RegExp);
expect(re!.test("/about")).toBe(true);
});
it("returns null for pathological patterns", async () => {
const { safeRegExp } = await import("../packages/vinext/src/config/config-matchers.js");
// lgtm[js/redos] — deliberate pathological regex to test safeRegExp guard
const re = safeRegExp("(a+)+b");
expect(re).toBeNull();
});
it("returns null for invalid regex syntax", async () => {
const { safeRegExp } = await import("../packages/vinext/src/config/config-matchers.js");
const re = safeRegExp("(?P<name>");
expect(re).toBeNull();
});
});
describe("escapeHeaderSource", () => {
it("passes through literal paths unchanged", async () => {
const { escapeHeaderSource } = await import("../packages/vinext/src/config/config-matchers.js");
expect(escapeHeaderSource("/api/users")).toBe("/api/users");
});
it("escapes dots", async () => {
const { escapeHeaderSource } = await import("../packages/vinext/src/config/config-matchers.js");
expect(escapeHeaderSource("/file.txt")).toBe("/file\\.txt");
});
it("converts named param to [^/]+", async () => {
const { escapeHeaderSource } = await import("../packages/vinext/src/config/config-matchers.js");
expect(escapeHeaderSource("/user/:id")).toBe("/user/[^/]+");
});
it("converts glob * to .*", async () => {
const { escapeHeaderSource } = await import("../packages/vinext/src/config/config-matchers.js");
expect(escapeHeaderSource("/api/*")).toBe("/api/.*");
});
it("escapes + and ?", async () => {
const { escapeHeaderSource } = await import("../packages/vinext/src/config/config-matchers.js");
expect(escapeHeaderSource("/path+query")).toBe("/path\\+query");
expect(escapeHeaderSource("/maybe?")).toBe("/maybe\\?");
});
it("handles constrained param :param(constraint)", async () => {
const { escapeHeaderSource } = await import("../packages/vinext/src/config/config-matchers.js");
expect(escapeHeaderSource("/api/:version(\\d+)/users")).toBe("/api/(\\d+)/users");
});
it("handles constrained param with alternation", async () => {
const { escapeHeaderSource } = await import("../packages/vinext/src/config/config-matchers.js");
expect(escapeHeaderSource("/:lang(en|fr)/page")).toBe("/(en|fr)/page");
});
it("preserves standalone regex groups", async () => {
const { escapeHeaderSource } = await import("../packages/vinext/src/config/config-matchers.js");
expect(escapeHeaderSource("/api/(v1|v2)/users")).toBe("/api/(v1|v2)/users");
});
it("handles multiple groups and params", async () => {
const { escapeHeaderSource } = await import("../packages/vinext/src/config/config-matchers.js");
expect(escapeHeaderSource("/:lang(en|fr)/:id(\\d+)/page")).toBe("/(en|fr)/(\\d+)/page");
});
});
describe("matchConfigPattern rejects ReDoS patterns", () => {
it("returns null for pathological source patterns", async () => {
const { matchConfigPattern } = await import("../packages/vinext/src/config/config-matchers.js");
// This pattern has nested quantifiers: the compiled regex would be (a+)+b
// which causes catastrophic backtracking. matchConfigPattern should return
// null (no match) rather than hanging.
// lgtm[js/redos] — deliberate pathological regex to test safeRegExp guard
const result = matchConfigPattern("/aaaaaaaaaaaaaaaaaaaac", "/:id((a+)+b)");
expect(result).toBeNull();
});
});
describe("matchConfigPattern compiled pattern cache", () => {
it("returns consistent results when the same pattern is called multiple times", async () => {
// Regression test for the per-request recompilation bug: patterns that
// enter the regex branch (containing `(`, `\`, or param suffixes) were
// previously re-running isSafeRegex + new RegExp() on every call, which
// dominated CPU profiles on apps with many locale-prefixed redirect rules.
// After the fix the compiled RegExp is cached at module level.
const { matchConfigPattern } = await import("../packages/vinext/src/config/config-matchers.js");
// Locale capture-group pattern — the kind that triggered the bottleneck.
const localePattern = "/:locale(en|es|fr|id|ja|ko|pt-br|pt|ro|ta|tr|uk|zh-cn|zh-tw)?/security";
// First call — populates the cache.
const first = matchConfigPattern("/en/security", localePattern);
expect(first).not.toBeNull();
expect(first!.locale).toBe("en");
// Second call — must hit the cache and return the same result.
const second = matchConfigPattern("/en/security", localePattern);
expect(second).toEqual(first);
// Different pathname, same pattern — still uses the cached RegExp.
const third = matchConfigPattern("/fr/security", localePattern);
expect(third).not.toBeNull();
expect(third!.locale).toBe("fr");
// Non-matching pathname — cache must not corrupt the null path.
const fourth = matchConfigPattern("/de/security", localePattern);
expect(fourth).toBeNull();
// Plain no-match when locale omitted and path wrong.
const fifth = matchConfigPattern("/security/extra", localePattern);
expect(fifth).toBeNull();
});
it("caches rejection for unsafe (ReDoS) patterns and returns null on repeat calls", async () => {
const { matchConfigPattern } = await import("../packages/vinext/src/config/config-matchers.js");
// lgtm[js/redos] — deliberate pathological regex to test cache-of-null path
const unsafe = "/:id((a+)+b)";
expect(matchConfigPattern("/x", unsafe)).toBeNull();
// Second call must not re-run isSafeRegex — just return null from cache.
expect(matchConfigPattern("/x", unsafe)).toBeNull();
});
});
// ---------------------------------------------------------------------------
// matchRedirect locale-static index tests
// Verifies the O(1) locale-prefix optimization in matchRedirect.
describe("matchRedirect locale-static index", () => {
const emptyCtx = {
headers: new Headers(),
cookies: {},
query: new URLSearchParams(),
host: "localhost",
};
// 63 locale-prefix rules — matches the profiled bottleneck scenario.
const locales = "en|es|fr|id|ja|ko|pt-br|pt|ro|ta|tr|uk|zh-cn|zh-tw|";
function makeLocaleRules(suffixes: string[]) {
return suffixes.map((s) => ({
source: `/:locale(${locales})?${s}`,
destination: `/:locale${s}-dest`,
permanent: false as const,
}));
}
it("matches a locale-prefixed pathname (locale present)", async () => {
const { matchRedirect } = await import("../packages/vinext/src/config/config-matchers.js");
const redirects = makeLocaleRules(["/security", "/advisory-board"]);
const result = matchRedirect("/en/security", redirects, emptyCtx);
expect(result).not.toBeNull();
expect(result!.destination).toBe("/en/security-dest");
expect(result!.permanent).toBe(false);
});
it("matches a locale-prefixed pathname (locale omitted)", async () => {
const { matchRedirect } = await import("../packages/vinext/src/config/config-matchers.js");
const redirects = makeLocaleRules(["/security", "/advisory-board"]);
// When locale is omitted the destination :locale param substitutes to "".
// sanitizeDestination collapses the leading double slash.
const result = matchRedirect("/security", redirects, emptyCtx);
expect(result).not.toBeNull();
expect(result!.destination).toBe("/security-dest");
});
it("returns null when pathname does not match any rule", async () => {
const { matchRedirect } = await import("../packages/vinext/src/config/config-matchers.js");
const redirects = makeLocaleRules(["/security", "/advisory-board"]);
// /blog is not in any indexed suffix
expect(matchRedirect("/blog", redirects, emptyCtx)).toBeNull();
expect(matchRedirect("/en/blog", redirects, emptyCtx)).toBeNull();
});
it("returns null when locale segment is not in the alternation", async () => {
const { matchRedirect } = await import("../packages/vinext/src/config/config-matchers.js");
const redirects = makeLocaleRules(["/security"]);
// "de" is not in the locales alternation
expect(matchRedirect("/de/security", redirects, emptyCtx)).toBeNull();
});
it("matches multi-segment locale codes like pt-br and zh-cn", async () => {
const { matchRedirect } = await import("../packages/vinext/src/config/config-matchers.js");
const redirects = makeLocaleRules(["/security"]);
const ptBr = matchRedirect("/pt-br/security", redirects, emptyCtx);
expect(ptBr).not.toBeNull();
expect(ptBr!.destination).toBe("/pt-br/security-dest");
const zhCn = matchRedirect("/zh-cn/security", redirects, emptyCtx);
expect(zhCn).not.toBeNull();
expect(zhCn!.destination).toBe("/zh-cn/security-dest");
});
it("preserves ordering: linear rule earlier than locale-static wins", async () => {
const { matchRedirect } = await import("../packages/vinext/src/config/config-matchers.js");
// Rule 0 is a linear catch-all; rule 1 is locale-static.
// For /en/security, the linear rule (index 0) matches first.
const redirects = [
{ source: "/:path*", destination: "/catchall", permanent: false as const },
...makeLocaleRules(["/security"]),
];
const result = matchRedirect("/en/security", redirects, emptyCtx);
expect(result).not.toBeNull();
expect(result!.destination).toBe("/catchall");
});
it("preserves ordering: locale-static rule earlier than linear wins", async () => {
const { matchRedirect } = await import("../packages/vinext/src/config/config-matchers.js");
// Rule 0 is locale-static; rule 1 is linear.
// For /en/security, the locale-static rule (index 0) matches first.
const redirects = [
...makeLocaleRules(["/security"]),
{ source: "/:path*", destination: "/catchall", permanent: false as const },
];
const result = matchRedirect("/en/security", redirects, emptyCtx);
expect(result).not.toBeNull();
expect(result!.destination).toBe("/en/security-dest");
});
it("returns null efficiently for 63 rules on a non-matching path (no regex exec on hot path)", async () => {
const { matchRedirect } = await import("../packages/vinext/src/config/config-matchers.js");
// Construct 63 locale-prefixed rules (matches the profiled bottleneck).
const suffixes = Array.from({ length: 63 }, (_, i) => `/page-${i}`);
const redirects = makeLocaleRules(suffixes);
// /blog does not match any rule.
const result = matchRedirect("/blog", redirects, emptyCtx);
expect(result).toBeNull();
// /en/blog also does not match.
const result2 = matchRedirect("/en/blog", redirects, emptyCtx);
expect(result2).toBeNull();
});
it("respects has/missing conditions on locale-static rules", async () => {
const { matchRedirect } = await import("../packages/vinext/src/config/config-matchers.js");
const redirects = [
{
source: `/:locale(en|fr)?/gated`,
destination: `/:locale/gated-dest`,
permanent: false as const,
has: [{ type: "header" as const, key: "x-auth", value: "1" }],
},
];
// Without the header — should NOT match.
const noHeader = matchRedirect("/en/gated", redirects, emptyCtx);
expect(noHeader).toBeNull();
// With the header — should match.
const withHeader = matchRedirect("/en/gated", redirects, {
headers: new Headers({ "x-auth": "1" }),
cookies: {},
query: new URLSearchParams(),
host: "localhost",
});
expect(withHeader).not.toBeNull();
expect(withHeader!.destination).toBe("/en/gated-dest");
});
it("lets has captures override locale params in locale-static redirects", async () => {
const { matchRedirect } = await import("../packages/vinext/src/config/config-matchers.js");
const redirects = [
{
source: `/:locale(en|fr)?/docs`,
destination: `/target/:locale`,
permanent: false as const,
has: [{ type: "header" as const, key: "x-locale", value: "(?<locale>forced)" }],
},
];
const withLocalePrefix = matchRedirect("/en/docs", redirects, {
headers: new Headers({ "x-locale": "forced" }),
cookies: {},
query: new URLSearchParams(),
host: "localhost",
});
expect(withLocalePrefix).toEqual({ destination: "/target/forced", permanent: false });
const withoutLocalePrefix = matchRedirect("/docs", redirects, {
headers: new Headers({ "x-locale": "forced" }),
cookies: {},
query: new URLSearchParams(),
host: "localhost",
});
expect(withoutLocalePrefix).toEqual({ destination: "/target/forced", permanent: false });
});
it("falls back to linear matching for rules that are not locale-static", async () => {
const { matchRedirect } = await import("../packages/vinext/src/config/config-matchers.js");
// A mix: some locale-static rules and one catch-all that matches /other.
const redirects = [
...makeLocaleRules(["/security", "/advisory-board"]),
{ source: "/other", destination: "/other-dest", permanent: true as const },
];
const result = matchRedirect("/other", redirects, emptyCtx);
expect(result).not.toBeNull();
expect(result!.destination).toBe("/other-dest");
expect(result!.permanent).toBe(true);
});
});
describe("matchConfigPattern handles parameterized suffix patterns", () => {
it("matches :path* with literal suffix (e.g. /:path*.md)", async () => {
const { matchConfigPattern } = await import("../packages/vinext/src/config/config-matchers.js");
// Should match URLs ending in .md
expect(matchConfigPattern("/article.md", "/:path*.md")).toEqual({ path: "article" });
expect(matchConfigPattern("/news/my-article.md", "/:path*.md")).toEqual({
path: "news/my-article",
});
// Should NOT match URLs without .md suffix
expect(matchConfigPattern("/", "/:path*.md")).toBeNull();
expect(matchConfigPattern("/about", "/:path*.md")).toBeNull();
expect(matchConfigPattern("/news", "/:path*.md")).toBeNull();
});
it("matches :path+ with literal suffix (e.g. /:path+.json)", async () => {
const { matchConfigPattern } = await import("../packages/vinext/src/config/config-matchers.js");
expect(matchConfigPattern("/data.json", "/:path+.json")).toEqual({ path: "data" });
expect(matchConfigPattern("/api/users.json", "/:path+.json")).toEqual({ path: "api/users" });
// Zero segments before suffix — should NOT match for :path+
expect(matchConfigPattern("/.json", "/:path+.json")).toBeNull();
expect(matchConfigPattern("/", "/:path+.json")).toBeNull();
});
it("does not regress plain :path* catch-all (no suffix)", async () => {
const { matchConfigPattern } = await import("../packages/vinext/src/config/config-matchers.js");
expect(matchConfigPattern("/docs", "/docs/:path*")).toEqual({ path: "" });
expect(matchConfigPattern("/docs/intro", "/docs/:path*")).toEqual({ path: "intro" });
expect(matchConfigPattern("/docs/guide/getting-started", "/docs/:path*")).toEqual({
path: "guide/getting-started",
});
});
});
// ---------------------------------------------------------------------------
// has/missing condition matching unit tests (next.config.js redirects/rewrites)
describe("parseCookies", () => {
it("parses standard cookie header", async () => {
const { parseCookies } = await import("../packages/vinext/src/config/config-matchers.js");
expect(parseCookies("a=1; b=2; c=three")).toEqual({ a: "1", b: "2", c: "three" });
});
it("returns empty object for null", async () => {
const { parseCookies } = await import("../packages/vinext/src/config/config-matchers.js");
expect(parseCookies(null)).toEqual({});
});
it("returns empty object for empty string", async () => {
const { parseCookies } = await import("../packages/vinext/src/config/config-matchers.js");
expect(parseCookies("")).toEqual({});
});
it("handles cookies with = in value", async () => {
const { parseCookies } = await import("../packages/vinext/src/config/config-matchers.js");
expect(parseCookies("token=abc=def")).toEqual({ token: "abc=def" });
});
it("trims whitespace around keys and values", async () => {
const { parseCookies } = await import("../packages/vinext/src/config/config-matchers.js");
expect(parseCookies(" a = 1 ; b = 2 ")).toEqual({ a: "1", b: "2" });
});
// Next.js uses the same compiled `cookie` parser for request cookies.
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/api-utils/get-cookie-parser.ts
it("matches Next.js duplicate, prototype-key, and empty-name semantics", async () => {
const { parseCookies } = await import("../packages/vinext/src/config/config-matchers.js");
const cookies = parseCookies(
'session=trusted; session=attacker; =empty-name; __proto__=prototype-cookie; constructor=constructor-cookie; toString=string-cookie; encoded=hello%20world; malformed=%E0%A4%A; quoted="quoted value"',
);
expect(Object.getPrototypeOf(cookies)).toBe(Object.prototype);
expect(cookies.hasOwnProperty("session")).toBe(true);
expect(Object.hasOwn(cookies, "toString")).toBe(false);
expect(Object.prototype.toString.call(cookies)).toBe("[object Object]");
expect(cookies.session).toBe("trusted");
expect(cookies[""]).toBe("empty-name");
expect(cookies.encoded).toBe("hello world");
expect(cookies.malformed).toBe("%E0%A4%A");
expect(cookies.quoted).toBe("quoted value");
expect(Object.hasOwn(cookies, "__proto__")).toBe(false);
expect(Object.hasOwn(cookies, "constructor")).toBe(false);
expect(Object.hasOwn(cookies, "toString")).toBe(false);
});
});
describe("checkHasConditions", () => {
function makeCtx(
overrides: Partial<{
headers: Record<string, string>;
cookies: Record<string, string>;
query: Record<string, string>;
host: string;
}> = {},
) {
const headers = new Headers(overrides.headers ?? {});
if (overrides.cookies) {
headers.set(
"cookie",
Object.entries(overrides.cookies)
.map(([k, v]) => `${k}=${v}`)
.join("; "),
);
}
const query = new URLSearchParams(overrides.query ?? {});
return {
headers,
cookies: overrides.cookies ?? {},
query,
host: overrides.host ?? "localhost",
};
}
it("returns true when no conditions", async () => {
const { checkHasConditions } = await import("../packages/vinext/src/config/config-matchers.js");
expect(checkHasConditions(undefined, undefined, makeCtx())).toBe(true);
});
// -- header conditions --
it("has header: passes when header present", async () => {
const { checkHasConditions } = await import("../packages/vinext/src/config/config-matchers.js");
const ctx = makeCtx({ headers: { "x-custom": "yes" } });
expect(checkHasConditions([{ type: "header", key: "x-custom" }], undefined, ctx)).toBe(true);
});
it("has header: fails when header absent", async () => {
const { checkHasConditions } = await import("../packages/vinext/src/config/config-matchers.js");
const ctx = makeCtx({});
expect(checkHasConditions([{ type: "header", key: "x-custom" }], undefined, ctx)).toBe(false);
});
it("has header with value: matches regex", async () => {
const { checkHasConditions } = await import("../packages/vinext/src/config/config-matchers.js");
const ctx = makeCtx({ headers: { "x-auth": "yes" } });
expect(
checkHasConditions(
[{ type: "header", key: "x-auth", value: "(?:yes|true)" }],
undefined,
ctx,
),
).toBe(true);
expect(
checkHasConditions(
[{ type: "header", key: "x-auth", value: "(?:no|false)" }],
undefined,
ctx,
),
).toBe(false);
});
// -- cookie conditions --
it("has cookie: passes when cookie present", async () => {
const { checkHasConditions } = await import("../packages/vinext/src/config/config-matchers.js");
const ctx = makeCtx({ cookies: { session: "abc" } });
expect(checkHasConditions([{ type: "cookie", key: "session" }], undefined, ctx)).toBe(true);
});
it("has cookie: fails when cookie absent", async () => {
const { checkHasConditions } = await import("../packages/vinext/src/config/config-matchers.js");
const ctx = makeCtx({ cookies: {} });
expect(checkHasConditions([{ type: "cookie", key: "session" }], undefined, ctx)).toBe(false);
});
it("has cookie with exact value", async () => {
const { checkHasConditions } = await import("../packages/vinext/src/config/config-matchers.js");
const ctx = makeCtx({ cookies: { authorized: "true" } });
expect(
checkHasConditions([{ type: "cookie", key: "authorized", value: "true" }], undefined, ctx),
).toBe(true);
expect(
checkHasConditions([{ type: "cookie", key: "authorized", value: "false" }], undefined, ctx),
).toBe(false);
});
it("config cookie conditions ignore inherited names", async () => {
const { checkHasConditions, requestContextFromRequest } =
await import("../packages/vinext/src/config/config-matchers.js");
const ctx = requestContextFromRequest(
new Request("https://example.com/account", {
headers: {
cookie:
"session=trusted; session=attacker; constructor=constructor-cookie; toString=string-cookie",
},
}),
);
expect(
checkHasConditions([{ type: "cookie", key: "session", value: "trusted" }], undefined, ctx),
).toBe(true);
expect(
checkHasConditions([{ type: "cookie", key: "session", value: "attacker" }], undefined, ctx),
).toBe(false);
expect(checkHasConditions([{ type: "cookie", key: "constructor" }], undefined, ctx)).toBe(
false,
);
expect(checkHasConditions([{ type: "cookie", key: "toString" }], undefined, ctx)).toBe(false);
});
// -- query conditions --
it("has query: passes when query param present", async () => {
const { checkHasConditions } = await import("../packages/vinext/src/config/config-matchers.js");
const ctx = makeCtx({ query: { page: "home" } });
expect(checkHasConditions([{ type: "query", key: "page" }], undefined, ctx)).toBe(true);
});
it("has query: fails when query param absent", async () => {
const { checkHasConditions } = await import("../packages/vinext/src/config/config-matchers.js");
const ctx = makeCtx({ query: {} });
expect(checkHasConditions([{ type: "query", key: "page" }], undefined, ctx)).toBe(false);
});
it("has query with regex value", async () => {
const { checkHasConditions } = await import("../packages/vinext/src/config/config-matchers.js");
const ctx = makeCtx({ query: { page: "home" } });
expect(
checkHasConditions([{ type: "query", key: "page", value: "home|about" }], undefined, ctx),
).toBe(true);
expect(
checkHasConditions([{ type: "query", key: "page", value: "^settings$" }], undefined, ctx),
).toBe(false);
});
// -- host conditions --
it("has host: matches exact value", async () => {
const { checkHasConditions } = await import("../packages/vinext/src/config/config-matchers.js");
const ctx = makeCtx({ host: "example.com" });
expect(
checkHasConditions([{ type: "host", key: "", value: "example.com" }], undefined, ctx),
).toBe(true);
expect(
checkHasConditions([{ type: "host", key: "", value: "other.com" }], undefined, ctx),
).toBe(false);
});
it("has host: matches regex value", async () => {
const { checkHasConditions } = await import("../packages/vinext/src/config/config-matchers.js");
const ctx = makeCtx({ host: "staging.example.com" });
expect(
checkHasConditions([{ type: "host", key: "", value: ".*\\.example\\.com" }], undefined, ctx),
).toBe(true);
});
// -- missing conditions --
it("missing header: passes when header absent", async () => {
const { checkHasConditions } = await import("../packages/vinext/src/config/config-matchers.js");
const ctx = makeCtx({});
expect(checkHasConditions(undefined, [{ type: "header", key: "x-block" }], ctx)).toBe(true);
});
it("missing header: fails when header present", async () => {
const { checkHasConditions } = await import("../packages/vinext/src/config/config-matchers.js");
const ctx = makeCtx({ headers: { "x-block": "1" } });
expect(checkHasConditions(undefined, [{ type: "header", key: "x-block" }], ctx)).toBe(false);
});
it("missing cookie: passes when cookie absent", async () => {
const { checkHasConditions } = await import("../packages/vinext/src/config/config-matchers.js");
const ctx = makeCtx({ cookies: {} });
expect(checkHasConditions(undefined, [{ type: "cookie", key: "stay-here" }], ctx)).toBe(true);
});
it("missing cookie: fails when cookie present", async () => {
const { checkHasConditions } = await import("../packages/vinext/src/config/config-matchers.js");
const ctx = makeCtx({ cookies: { "stay-here": "1" } });
expect(checkHasConditions(undefined, [{ type: "cookie", key: "stay-here" }], ctx)).toBe(false);
});
// -- combined has + missing --
it("both has and missing must pass", async () => {
const { checkHasConditions } = await import("../packages/vinext/src/config/config-matchers.js");
const ctx = makeCtx({ cookies: { auth: "yes" } });
// has: cookie auth present (passes), missing: cookie block absent (passes)
expect(
checkHasConditions(
[{ type: "cookie", key: "auth" }],
[{ type: "cookie", key: "block" }],
ctx,
),
).toBe(true);
// has: cookie auth present (passes), missing: cookie auth absent (fails — it's present)
expect(
checkHasConditions([{ type: "cookie", key: "auth" }], [{ type: "cookie", key: "auth" }], ctx),
).toBe(false);
});
it("all has conditions must match (conjunction)", async () => {
const { checkHasConditions } = await import("../packages/vinext/src/config/config-matchers.js");
const ctx = makeCtx({ cookies: { a: "1" }, query: { page: "home" } });
// Both match
expect(
checkHasConditions(
[
{ type: "cookie", key: "a" },
{ type: "query", key: "page" },
],
undefined,
ctx,
),
).toBe(true);
// One doesn't match
expect(
checkHasConditions(
[
{ type: "cookie", key: "a" },
{ type: "query", key: "missing" },
],
undefined,
ctx,
),
).toBe(false);
});
});
describe("matchHeaders", () => {
function makeCtx(
overrides: Partial<{
headers: Record<string, string>;
cookies: Record<string, string>;
query: Record<string, string>;
host: string;
}> = {},
) {
const headers = new Headers(overrides.headers ?? {});
if (overrides.cookies) {
headers.set(
"cookie",
Object.entries(overrides.cookies)
.map(([k, v]) => `${k}=${v}`)
.join("; "),
);
}
const query = new URLSearchParams(overrides.query ?? {});
return {
headers,
cookies: overrides.cookies ?? {},
query,
host: overrides.host ?? "localhost",
};
}
it("applies headers when has header condition is satisfied", async () => {
const { matchHeaders } = await import("../packages/vinext/src/config/config-matchers.js");
const rules: any[] = [
{
source: "/about",
has: [{ type: "header", key: "x-user-tier", value: "pro" }],
headers: [{ key: "x-conditional-header", value: "enabled" }],
},
];
const matched = matchHeaders("/about", rules, makeCtx({ headers: { "x-user-tier": "pro" } }));
expect(matched).toEqual([{ key: "x-conditional-header", value: "enabled" }]);
});
it("does not apply headers when missing cookie condition fails", async () => {
const { matchHeaders } = await import("../packages/vinext/src/config/config-matchers.js");
const rules: any[] = [
{
source: "/about",
missing: [{ type: "cookie", key: "no-config-header" }],
headers: [{ key: "x-conditional-header", value: "enabled" }],
},
];
const matched = matchHeaders(
"/about",
rules,
makeCtx({ cookies: { "no-config-header": "1" } }),
);
expect(matched).toEqual([]);
});
it("applies headers when has query condition is satisfied", async () => {
const { matchHeaders } = await import("../packages/vinext/src/config/config-matchers.js");
const rules: any[] = [
{
source: "/about",
has: [{ type: "query", key: "preview", value: "1" }],
headers: [{ key: "x-preview-header", value: "true" }],
},
];
const matched = matchHeaders("/about", rules, makeCtx({ query: { preview: "1" } }));
expect(matched).toEqual([{ key: "x-preview-header", value: "true" }]);
});
it("skips conditional header rule when has condition is not met", async () => {
const { matchHeaders } = await import("../packages/vinext/src/config/config-matchers.js");
const rules: any[] = [
{
source: "/about",
has: [{ type: "header", key: "x-user-tier", value: "pro" }],
headers: [{ key: "x-conditional-header", value: "enabled" }],
},
];
// Request without the required header should not match
const matched = matchHeaders("/about", rules, makeCtx());
expect(matched).toEqual([]);
});
// Regression for #1331: under `trailingSlash: true` the incoming pathname
// arrives as `/about/`, but header source patterns are written without a
// trailing slash. `matchHeaders` must strip the slash before matching.
it("matches when the request pathname has a trailing slash", async () => {
const { matchHeaders } = await import("../packages/vinext/src/config/config-matchers.js");
const rules: any[] = [
{
source: "/about",
headers: [{ key: "x-static-header", value: "1" }],
},
{
source: "/api/:path*",
headers: [{ key: "x-api-header", value: "1" }],
},
];
const aboutMatched = matchHeaders("/about/", rules, makeCtx());
expect(aboutMatched).toEqual([{ key: "x-static-header", value: "1" }]);
const apiMatched = matchHeaders("/api/users/", rules, makeCtx());
expect(apiMatched).toEqual([{ key: "x-api-header", value: "1" }]);
});
});
describe("matchHeaders compiled source cache", () => {
// Regression test: escapeHeaderSource() + safeRegExp() were re-run on every
// request for every header rule. The result is now cached in _compiledHeaderSourceCache
// keyed by rule.source so subsequent calls skip the tokeniser and isSafeRegex.
function makeCtx(h: Record<string, string> = {}) {
return {
headers: new Headers(h),
cookies: {},
query: new URLSearchParams(),
host: "localhost",
};
}
it("returns consistent results when the same source is matched multiple times", async () => {
const { matchHeaders } = await import("../packages/vinext/src/config/config-matchers.js");
const rules: any[] = [
{
source: "/blog/:slug",
headers: [{ key: "x-content-type", value: "article" }],
},
];
// First call — populates _compiledHeaderSourceCache.
const first = matchHeaders("/blog/hello-world", rules, makeCtx());
expect(first).toEqual([{ key: "x-content-type", value: "article" }]);
// Second call — must hit the cache and return the same result.
const second = matchHeaders("/blog/hello-world", rules, makeCtx());
expect(second).toEqual(first);
// Different matching pathname, same rule — still uses cached regex.
const third = matchHeaders("/blog/another-post", rules, makeCtx());
expect(third).toEqual([{ key: "x-content-type", value: "article" }]);
// Non-matching pathname.
const fourth = matchHeaders("/about", rules, makeCtx());
expect(fourth).toEqual([]);
});
it("caches regex-bearing source patterns (containing `(`)", async () => {
const { matchHeaders } = await import("../packages/vinext/src/config/config-matchers.js");
const rules: any[] = [
{
source: "/:locale(en|fr|de)/blog",
headers: [{ key: "x-locale-blog", value: "1" }],
},
];
const first = matchHeaders("/en/blog", rules, makeCtx());
expect(first).toEqual([{ key: "x-locale-blog", value: "1" }]);
// Cache hit — same source pattern, different matching pathname.
const second = matchHeaders("/fr/blog", rules, makeCtx());
expect(second).toEqual([{ key: "x-locale-blog", value: "1" }]);
// Non-matching locale.
const third = matchHeaders("/zh/blog", rules, makeCtx());
expect(third).toEqual([]);
});
});
describe("checkHasConditions condition value cache", () => {
// Regression test: safeRegExp(condition.value) was called on every request
// for every has/missing condition. The result is now cached in
// _compiledConditionCache keyed by value so isSafeRegex runs at most once.
function makeCtx(
overrides: {
headers?: Record<string, string>;
cookies?: Record<string, string>;
query?: Record<string, string>;
host?: string;
} = {},
) {
return {
headers: new Headers(overrides.headers ?? {}),
cookies: overrides.cookies ?? {},
query: new URLSearchParams(overrides.query ?? {}),
host: overrides.host ?? "localhost",
};
}
it("returns consistent results when the same condition value is evaluated multiple times", async () => {
const { checkHasConditions } = await import("../packages/vinext/src/config/config-matchers.js");
const has = [{ type: "header" as const, key: "x-tier", value: "pro|enterprise" }];
// First call — populates _compiledConditionCache for "pro|enterprise".
expect(checkHasConditions(has, undefined, makeCtx({ headers: { "x-tier": "pro" } }))).toBe(
true,
);
// Second call — must hit the cache.
expect(
checkHasConditions(has, undefined, makeCtx({ headers: { "x-tier": "enterprise" } })),
).toBe(true);
// Non-matching value.
expect(checkHasConditions(has, undefined, makeCtx({ headers: { "x-tier": "free" } }))).toBe(
false,
);
});
it("caches condition values across all condition types (header, cookie, query, host)", async () => {
const { checkHasConditions } = await import("../packages/vinext/src/config/config-matchers.js");
const sharedPattern = "^v\\d+$"; // a pattern that will be cached once and reused
// header
const hasHeader = [{ type: "header" as const, key: "x-version", value: sharedPattern }];
expect(
checkHasConditions(hasHeader, undefined, makeCtx({ headers: { "x-version": "v3" } })),
).toBe(true);
expect(
checkHasConditions(hasHeader, undefined, makeCtx({ headers: { "x-version": "v3" } })),
).toBe(true);
// cookie — same pattern string, should hit cache populated by header call above
const hasCookie = [{ type: "cookie" as const, key: "ver", value: sharedPattern }];
expect(checkHasConditions(hasCookie, undefined, makeCtx({ cookies: { ver: "v1" } }))).toBe(
true,
);
expect(checkHasConditions(hasCookie, undefined, makeCtx({ cookies: { ver: "beta" } }))).toBe(
false,
);
// query
const hasQuery = [{ type: "query" as const, key: "v", value: sharedPattern }];
expect(checkHasConditions(hasQuery, undefined, makeCtx({ query: { v: "v2" } }))).toBe(true);
// host
const hasHost = [{ type: "host" as const, key: "", value: sharedPattern }];
expect(checkHasConditions(hasHost, undefined, makeCtx({ host: "v9" }))).toBe(true);
expect(checkHasConditions(hasHost, undefined, makeCtx({ host: "prod" }))).toBe(false);
});
});
// ---------------------------------------------------------------------------
// isExternalUrl unit tests (external rewrite detection)
describe("isExternalUrl", () => {
it("returns true for https:// URLs", async () => {
const { isExternalUrl } = await import("../packages/vinext/src/config/config-matchers.js");
expect(isExternalUrl("https://example.com/path")).toBe(true);
expect(isExternalUrl("https://us.i.posthog.com/decide?v=3")).toBe(true);
});
it("returns true for http:// URLs", async () => {
const { isExternalUrl } = await import("../packages/vinext/src/config/config-matchers.js");
expect(isExternalUrl("http://example.com/api")).toBe(true);
});
it("returns false for relative paths", async () => {
const { isExternalUrl } = await import("../packages/vinext/src/config/config-matchers.js");
expect(isExternalUrl("/about")).toBe(false);
expect(isExternalUrl("/api/test")).toBe(false);
expect(isExternalUrl("/")).toBe(false);
});
it("returns true for protocol-relative URLs", async () => {
const { isExternalUrl } = await import("../packages/vinext/src/config/config-matchers.js");
expect(isExternalUrl("//example.com")).toBe(true);
expect(isExternalUrl("//cdn.example.com/image.png")).toBe(true);
});
it("returns true for exotic URL schemes (data:, javascript:, blob:, ftp:)", async () => {
const { isExternalUrl } = await import("../packages/vinext/src/config/config-matchers.js");
expect(isExternalUrl("data:text/html,<h1>hi</h1>")).toBe(true);
expect(isExternalUrl("javascript:alert(1)")).toBe(true);
expect(isExternalUrl("blob:http://localhost/abc")).toBe(true);
expect(isExternalUrl("ftp://files.example.com/pub")).toBe(true);
});
it("returns false for hash-only and bare strings", async () => {
const { isExternalUrl } = await import("../packages/vinext/src/config/config-matchers.js");
expect(isExternalUrl("#section")).toBe(false);
expect(isExternalUrl("about")).toBe(false);
});
});
// ---------------------------------------------------------------------------
// proxyExternalRequest unit tests
describe("proxyExternalRequest", () => {
it("proxies request to external URL and returns upstream response", async () => {
const { proxyExternalRequest } =
await import("../packages/vinext/src/config/config-matchers.js");
// Use a well-known public URL that returns a predictable response
const request = new Request("http://localhost:3000/test?extra=1", {
method: "GET",
headers: { "user-agent": "vinext-test" },
});
// We test the function constructs the right request by mocking fetch
const originalFetch = globalThis.fetch;
let capturedUrl: string | undefined;
let capturedInit: any;
globalThis.fetch = async (url: any, init: any) => {
capturedUrl = typeof url === "string" ? url : url.toString();
capturedInit = init;
return new Response("proxied body", {
status: 200,
headers: { "content-type": "text/plain", "x-upstream": "true" },
});
};
try {
const response = await proxyExternalRequest(request, "https://api.example.com/endpoint");
expect(capturedUrl).toContain("https://api.example.com/endpoint");
// Extra query param from original request should be merged
expect(capturedUrl).toContain("extra=1");
expect(capturedInit.method).toBe("GET");
expect(capturedInit.redirect).toBe("manual");
// Host header should be set to the external target
expect(capturedInit.headers.get("host")).toBe("api.example.com");
expect(response.status).toBe(200);
expect(response.headers.get("x-upstream")).toBe("true");
const body = await response.text();
expect(body).toBe("proxied body");
} finally {
globalThis.fetch = originalFetch;
}
});
it("preserves query parameters from the rewrite destination", async () => {
const { proxyExternalRequest } =
await import("../packages/vinext/src/config/config-matchers.js");
const request = new Request("http://localhost:3000/test", {
method: "GET",
});
const originalFetch = globalThis.fetch;
let capturedUrl: string | undefined;
globalThis.fetch = async (url: any, _init: any) => {
capturedUrl = typeof url === "string" ? url : url.toString();
return new Response("ok", { status: 200 });
};
try {
await proxyExternalRequest(request, "https://api.example.com/v1?key=abc");
expect(capturedUrl).toContain("key=abc");
} finally {
globalThis.fetch = originalFetch;
}
});
it("preserves repeated original query params when the destination does not define that key", async () => {
const { proxyExternalRequest } =
await import("../packages/vinext/src/config/config-matchers.js");
const request = new Request("http://localhost:3000/test?a=1&a=2&b=3", {
method: "GET",
});
const originalFetch = globalThis.fetch;
let capturedEntries: Array<[string, string]> | undefined;
globalThis.fetch = async (url: any, _init: any) => {
capturedEntries = [...new URL(typeof url === "string" ? url : url.toString()).searchParams];
return new Response("ok", { status: 200 });
};
try {
await proxyExternalRequest(request, "https://api.example.com/v1");
expect(capturedEntries).toEqual([
["a", "1"],
["a", "2"],
["b", "3"],
]);
} finally {
globalThis.fetch = originalFetch;
}
});
it("keeps destination query params authoritative while preserving repeated values for other keys", async () => {
const { proxyExternalRequest } =
await import("../packages/vinext/src/config/config-matchers.js");
const request = new Request("http://localhost:3000/test?a=1&a=2&b=3&b=4", {
method: "GET",
});
const originalFetch = globalThis.fetch;
let capturedEntries: Array<[string, string]> | undefined;
globalThis.fetch = async (url: any, _init: any) => {
capturedEntries = [...new URL(typeof url === "string" ? url : url.toString()).searchParams];
return new Response("ok", { status: 200 });
};
try {
await proxyExternalRequest(request, "https://api.example.com/v1?a=dest");
expect(capturedEntries).toEqual([
["a", "dest"],
["b", "3"],
["b", "4"],
]);
} finally {
globalThis.fetch = originalFetch;
}
});
it("strips hop-by-hop headers from upstream response", async () => {
const { proxyExternalRequest } =
await import("../packages/vinext/src/config/config-matchers.js");
const request = new Request("http://localhost:3000/test");
const originalFetch = globalThis.fetch;
globalThis.fetch = async (_url: any, _init: any) =>
new Response("ok", {
status: 200,
headers: {
"content-type": "text/plain",
"x-custom": "preserved",
// Note: "transfer-encoding" and "connection" are hop-by-hop headers
// that should be stripped. However, the fetch API may not allow
// setting them on Response, so we test with headers that can be set.
},
});
try {
const response = await proxyExternalRequest(request, "https://api.example.com/test");
expect(response.headers.get("content-type")).toBe("text/plain");
expect(response.headers.get("x-custom")).toBe("preserved");
} finally {
globalThis.fetch = originalFetch;
}
});
it("passes through non-200 status codes", async () => {
const { proxyExternalRequest } =
await import("../packages/vinext/src/config/config-matchers.js");
const request = new Request("http://localhost:3000/test");
const originalFetch = globalThis.fetch;
globalThis.fetch = async (_url: any, _init: any) => new Response("Not Found", { status: 404 });
try {
const response = await proxyExternalRequest(request, "https://api.example.com/missing");
expect(response.status).toBe(404);
} finally {
globalThis.fetch = originalFetch;
}
});
it("forwards credential headers and strips x-middleware-* headers from proxied requests", async () => {
const { proxyExternalRequest } =
await import("../packages/vinext/src/config/config-matchers.js");
const request = new Request("http://localhost:3000/proxy", {
method: "GET",
headers: {
cookie: "session=secret123",
authorization: "Bearer tok_secret",
"x-api-key": "sk_live_secret",
"proxy-authorization": "Basic cHJveHk=",
"x-middleware-rewrite": "/internal",
"x-middleware-next": "1",
"x-vinext-prerender-secret": "build-secret-123",
"x-vinext-prerender-route-params": "%7B%22id%22%3A%22forged%22%7D",
"x-custom-header": "keep-me",
"user-agent": "vinext-test",
},
});
const originalFetch = globalThis.fetch;
let capturedHeaders: Headers | undefined;
globalThis.fetch = async (_url: any, init: any) => {
capturedHeaders = init.headers;
return new Response("ok", { status: 200 });
};
try {
await proxyExternalRequest(request, "https://api.example.com/data");
expect(capturedHeaders).toBeDefined();
// Credential headers must be forwarded to match Next.js external rewrite proxying.
expect(capturedHeaders!.get("cookie")).toBe("session=secret123");
expect(capturedHeaders!.get("authorization")).toBe("Bearer tok_secret");
expect(capturedHeaders!.get("x-api-key")).toBe("sk_live_secret");
expect(capturedHeaders!.get("proxy-authorization")).toBe("Basic cHJveHk=");
// Internal middleware headers must be stripped
expect(capturedHeaders!.get("x-middleware-rewrite")).toBeNull();
expect(capturedHeaders!.get("x-middleware-next")).toBeNull();
expect(capturedHeaders!.get("x-vinext-prerender-secret")).toBeNull();
expect(capturedHeaders!.get("x-vinext-prerender-route-params")).toBeNull();
// Non-sensitive headers must be preserved
expect(capturedHeaders!.get("x-custom-header")).toBe("keep-me");
expect(capturedHeaders!.get("user-agent")).toBe("vinext-test");
} finally {
globalThis.fetch = originalFetch;
}
});
it("strips hop-by-hop request headers before proxying external rewrites", async () => {
const { proxyExternalRequest } =
await import("../packages/vinext/src/config/config-matchers.js");
const request = new Request("http://localhost:3000/proxy", {
method: "DELETE",
headers: {
connection: "keep-alive, x-custom-hop",
"keep-alive": "timeout=5",
te: "trailers",
trailers: "x-trailer",
"transfer-encoding": "chunked",
upgrade: "websocket",
"x-custom-hop": "secret",
"proxy-authorization": "Basic cHJveHk=",
"x-custom-header": "keep-me",
},
});
const originalFetch = globalThis.fetch;
let capturedHeaders: Headers | undefined;
globalThis.fetch = async (_url: any, init: any) => {
capturedHeaders = init.headers;
return new Response("ok", { status: 200 });
};
try {
await proxyExternalRequest(request, "https://api.example.com/data");
expect(capturedHeaders).toBeDefined();
expect(capturedHeaders!.get("connection")).toBeNull();
expect(capturedHeaders!.get("keep-alive")).toBeNull();
expect(capturedHeaders!.get("te")).toBeNull();
expect(capturedHeaders!.get("trailers")).toBeNull();
expect(capturedHeaders!.get("transfer-encoding")).toBeNull();
expect(capturedHeaders!.get("upgrade")).toBeNull();
expect(capturedHeaders!.get("x-custom-hop")).toBeNull();
// Request credentials that are not connection-scoped should still forward.
expect(capturedHeaders!.get("proxy-authorization")).toBe("Basic cHJveHk=");
expect(capturedHeaders!.get("x-custom-header")).toBe("keep-me");
} finally {
globalThis.fetch = originalFetch;
}
});
it("forwards redirect responses without following them", async () => {
const { proxyExternalRequest } =
await import("../packages/vinext/src/config/config-matchers.js");
const request = new Request("http://localhost:3000/test");
const originalFetch = globalThis.fetch;
globalThis.fetch = async (_url: any, init: any) => {
// Verify redirect: "manual" was set
expect(init.redirect).toBe("manual");
return new Response(null, {
status: 301,
headers: { location: "https://other.example.com/new" },
});
};
try {
const response = await proxyExternalRequest(request, "https://api.example.com/old");
expect(response.status).toBe(301);
expect(response.headers.get("location")).toBe("https://other.example.com/new");
} finally {
globalThis.fetch = originalFetch;
}
});
it("strips content-encoding and content-length from proxied response to avoid double-decompression", async () => {
const { proxyExternalRequest } =
await import("../packages/vinext/src/config/config-matchers.js");
const request = new Request("http://localhost:3000/test");
const originalFetch = globalThis.fetch;
globalThis.fetch = async (_url: any, _init: any) =>
new Response("decompressed body", {
status: 200,
headers: {
"content-type": "application/json",
"content-encoding": "gzip",
"content-length": "42",
"x-custom": "keep",
},
});
try {
const response = await proxyExternalRequest(request, "https://api.example.com/data");
// content-encoding and content-length must be stripped to prevent
// ERR_CONTENT_DECODING_FAILED in the browser (double-decompression bug).
expect(response.headers.get("content-encoding")).toBeNull();
expect(response.headers.get("content-length")).toBeNull();
// Other headers must be preserved
expect(response.headers.get("content-type")).toBe("application/json");
expect(response.headers.get("x-custom")).toBe("keep");
} finally {
globalThis.fetch = originalFetch;
}
});
});
// ---------------------------------------------------------------------------
// matchRewrite + isExternalUrl integration (config-matchers)
describe("matchRewrite with external URLs", () => {
const emptyCtx = {
headers: new Headers(),
cookies: {},
query: new URLSearchParams(),
host: "localhost",
};
it("returns full external URL when destination is external", async () => {
const { matchRewrite, isExternalUrl } =
await import("../packages/vinext/src/config/config-matchers.js");
const rewrites = [{ source: "/ph/:path*", destination: "https://us.i.posthog.com/:path*" }];
const result = matchRewrite("/ph/decide", rewrites, emptyCtx);
expect(result).toBe("https://us.i.posthog.com/decide");
expect(isExternalUrl(result!)).toBe(true);
});
it("returns full external URL for static path rewrites", async () => {
const { matchRewrite, isExternalUrl } =
await import("../packages/vinext/src/config/config-matchers.js");
const rewrites = [
{ source: "/ph/static/:path*", destination: "https://us-assets.i.posthog.com/static/:path*" },
];
const result = matchRewrite("/ph/static/array.js", rewrites, emptyCtx);
expect(result).toBe("https://us-assets.i.posthog.com/static/array.js");
expect(isExternalUrl(result!)).toBe(true);
});
it("returns internal path for non-external rewrites", async () => {
const { matchRewrite, isExternalUrl } =
await import("../packages/vinext/src/config/config-matchers.js");
const rewrites = [{ source: "/posts/:id", destination: "/blog/:id" }];
const result = matchRewrite("/posts/hello", rewrites, emptyCtx);
expect(result).toBe("/blog/hello");
expect(isExternalUrl(result!)).toBe(false);
});
it("appends unused rewrite source params to the destination query", async () => {
const { matchRewrite } = await import("../packages/vinext/src/config/config-matchers.js");
const rewrites = [{ source: "/blog-:param", destination: "/blog/post-3" }];
const result = matchRewrite("/blog-post-3", rewrites, emptyCtx);
expect(result).toBe("/blog/post-3?param=post-3");
});
it("does not append rewrite source params consumed by the destination hash", async () => {
const { matchRewrite } = await import("../packages/vinext/src/config/config-matchers.js");
const rewrites = [{ source: "/old/:id", destination: "/new#section-:id" }];
const result = matchRewrite("/old/123", rewrites, emptyCtx);
expect(result).toBe("/new#section-123");
});
it("replaces repeated params in rewrite destinations", async () => {
const { matchRewrite } = await import("../packages/vinext/src/config/config-matchers.js");
const rewrites = [{ source: "/post/:id", destination: "/api/:id/:id" }];
const result = matchRewrite("/post/123", rewrites, emptyCtx);
expect(result).toBe("/api/123/123");
});
it("replaces adjacent params separated by literal characters", async () => {
const { matchRewrite } = await import("../packages/vinext/src/config/config-matchers.js");
const rewrites = [{ source: "/legacy/:year/:month", destination: "/archive/:year-:month" }];
const result = matchRewrite("/legacy/2024/06", rewrites, emptyCtx);
expect(result).toBe("/archive/2024-06");
});
it("replaces hyphenated param names without truncating them", async () => {
const { matchRewrite } = await import("../packages/vinext/src/config/config-matchers.js");
const rewrites = [{ source: "/auth/:auth-method", destination: "/signin/:auth-method" }];
const result = matchRewrite("/auth/google", rewrites, emptyCtx);
expect(result).toBe("/signin/google");
});
it("treats hyphen as a literal delimiter when only the shorter param key exists", async () => {
const { matchRewrite } = await import("../packages/vinext/src/config/config-matchers.js");
const rewrites = [{ source: "/item/:foo", destination: "/dest/:foo-bar" }];
const result = matchRewrite("/item/123", rewrites, emptyCtx);
expect(result).toBe("/dest/123-bar");
});
it("substitutes named captures from has conditions into rewrite destinations", async () => {
const { matchRewrite } = await import("../packages/vinext/src/config/config-matchers.js");
// Ported from documented Next.js behavior:
// https://github.com/vercel/next.js/blob/canary/docs/01-app/03-api-reference/05-config/01-next-config-js/rewrites.mdx
const rewrites = [
{
source: "/:path*",
has: [{ type: "header" as const, key: "x-authorized", value: "(?<authorized>yes|true)" }],
destination: "/home?authorized=:authorized&path=:path*",
},
];
const result = matchRewrite("/docs/intro", rewrites, {
...emptyCtx,
headers: new Headers({ "x-authorized": "yes" }),
});
expect(result).toBe("/home?authorized=yes&path=docs/intro");
});
it("appends source params not referenced by the rewrite destination", async () => {
const { matchRewrite } = await import("../packages/vinext/src/config/config-matchers.js");
const result = matchRewrite(
"/query-rewrite/hello/world",
[
{
source: "/query-rewrite/:section/:name",
destination: "/with-params?first=:section&second=:name",
},
],
emptyCtx,
);
expect(result).toBe("/with-params?first=hello&second=world&section=hello&name=world");
});
it("appends unused condition captures to the rewrite destination query", async () => {
const { matchRewrite } = await import("../packages/vinext/src/config/config-matchers.js");
const result = matchRewrite(
"/source",
[
{
source: "/source",
destination: "/target",
has: [{ type: "query", key: "preview", value: "(?<mode>draft)" }],
},
],
{ ...emptyCtx, query: new URLSearchParams("preview=draft") },
);
expect(result).toBe("/target?mode=draft");
});
it("does not append rewrite params consumed by the destination fragment", async () => {
const { matchRewrite } = await import("../packages/vinext/src/config/config-matchers.js");
const result = matchRewrite(
"/docs/500",
[{ source: "/docs/:code", destination: "/status#:code" }],
emptyCtx,
);
expect(result).toBe("/status#500");
});
});
describe("matchRedirect destination param substitution", () => {
const emptyCtx = {
headers: new Headers(),
cookies: {},
query: new URLSearchParams(),
host: "localhost",
};
it("replaces repeated params in redirect destinations", async () => {
const { matchRedirect } = await import("../packages/vinext/src/config/config-matchers.js");
const redirects = [{ source: "/post/:id", destination: "/api/:id/:id", permanent: false }];
const result = matchRedirect("/post/123", redirects, emptyCtx);
expect(result).toEqual({ destination: "/api/123/123", permanent: false });
});
it("replaces adjacent params separated by literal characters in redirect destinations", async () => {
const { matchRedirect } = await import("../packages/vinext/src/config/config-matchers.js");
const redirects = [
{ source: "/legacy/:year/:month", destination: "/archive/:year-:month", permanent: true },
];
const result = matchRedirect("/legacy/2024/06", redirects, emptyCtx);
expect(result).toEqual({ destination: "/archive/2024-06", permanent: true });
});
it("replaces repeated locale params in locale-static redirect destinations", async () => {
const { matchRedirect } = await import("../packages/vinext/src/config/config-matchers.js");
const redirects = [
{
source: "/:locale(en|fr)?/docs",
destination: "/:locale/:locale/docs",
permanent: false,
},
];
const result = matchRedirect("/en/docs", redirects, emptyCtx);
expect(result).toEqual({ destination: "/en/en/docs", permanent: false });
});
it("substitutes named captures from has conditions into redirect destinations", async () => {
const { matchRedirect } = await import("../packages/vinext/src/config/config-matchers.js");
// Ported from documented Next.js behavior:
// https://github.com/vercel/next.js/blob/canary/docs/01-app/03-api-reference/05-config/01-next-config-js/redirects.mdx
const redirects = [
{
source: "/",
has: [{ type: "header" as const, key: "x-authorized", value: "(?<authorized>yes|true)" }],
destination: "/home?authorized=:authorized",
permanent: false,
},
];
const result = matchRedirect("/", redirects, {
...emptyCtx,
headers: new Headers({ "x-authorized": "yes" }),
});
expect(result).toEqual({ destination: "/home?authorized=yes", permanent: false });
});
});
// ---------------------------------------------------------------------------
// sanitizeDestination — protocol-relative URL handling
describe("sanitizeDestination", () => {
it("collapses leading // to / for relative URLs", async () => {
const { sanitizeDestination } =
await import("../packages/vinext/src/config/config-matchers.js");
expect(sanitizeDestination("//evil.com")).toBe("/evil.com");
expect(sanitizeDestination("///evil.com")).toBe("/evil.com");
expect(sanitizeDestination("////evil.com/path")).toBe("/evil.com/path");
});
it("preserves external http:// and https:// URLs", async () => {
const { sanitizeDestination } =
await import("../packages/vinext/src/config/config-matchers.js");
expect(sanitizeDestination("https://example.com/path")).toBe("https://example.com/path");
expect(sanitizeDestination("http://example.com")).toBe("http://example.com");
});
it("normalizes leading backslashes (browsers treat \\ as /)", async () => {
const { sanitizeDestination } =
await import("../packages/vinext/src/config/config-matchers.js");
expect(sanitizeDestination("\\/evil.com")).toBe("/evil.com");
expect(sanitizeDestination("\\\\evil.com")).toBe("/evil.com");
expect(sanitizeDestination("\\\\/evil.com")).toBe("/evil.com");
expect(sanitizeDestination("/\\evil.com")).toBe("/evil.com");
});
it("preserves normal relative paths", async () => {
const { sanitizeDestination } =
await import("../packages/vinext/src/config/config-matchers.js");
expect(sanitizeDestination("/about")).toBe("/about");
expect(sanitizeDestination("/blog/hello")).toBe("/blog/hello");
expect(sanitizeDestination("/")).toBe("/");
});
});
// ---------------------------------------------------------------------------
// Catch-all redirect destination sanitization
describe("open redirect prevention in catch-all redirects", () => {
const emptyCtx = {
headers: new Headers(),
cookies: {},
query: new URLSearchParams(),
host: "localhost",
};
it("matchRedirect sanitizes decoded %2F that would produce //evil.com", async () => {
const { matchRedirect } = await import("../packages/vinext/src/config/config-matchers.js");
// In the real request flow, the entry point decodes %2F to / and
// normalizePath collapses // to /. So /old/%2Fevil.com arrives as
// /old/evil.com (after decode + normalize).
// Test with the already-decoded path (how matchRedirect is actually called).
const redirects = [{ source: "/old/:path*", destination: "/:path*", permanent: false }];
const result = matchRedirect("/old/evil.com", redirects, emptyCtx);
expect(result).not.toBeNull();
expect(result!.destination).toBe("/evil.com");
// Verify it does NOT start with // (protocol-relative)
expect(result!.destination.startsWith("//")).toBe(false);
});
it("matchRedirect sanitizes double-slash in already-decoded paths", async () => {
const { matchRedirect } = await import("../packages/vinext/src/config/config-matchers.js");
const redirects = [{ source: "/old/:path*", destination: "/:path*", permanent: false }];
// Even if an already-decoded path somehow contains //, the sanitizer should handle it
const result = matchRedirect("/old//evil.com", redirects, emptyCtx);
expect(result).not.toBeNull();
expect(result!.destination.startsWith("//")).toBe(false);
});
it("matchRedirect preserves valid external redirect destinations", async () => {
const { matchRedirect } = await import("../packages/vinext/src/config/config-matchers.js");
const redirects = [
{ source: "/go/:path*", destination: "https://example.com/:path*", permanent: false },
];
const result = matchRedirect("/go/page", redirects, emptyCtx);
expect(result).not.toBeNull();
expect(result!.destination).toBe("https://example.com/page");
});
it("matchRewrite sanitizes decoded %2F that would produce //evil.com", async () => {
const { matchRewrite } = await import("../packages/vinext/src/config/config-matchers.js");
const rewrites = [{ source: "/old/:path*", destination: "/:path*" }];
// In the real request flow, the entry point decodes and normalizePath
// collapses //. Test with already-decoded path.
const result = matchRewrite("/old/evil.com", rewrites, emptyCtx);
expect(result).not.toBeNull();
expect(result!).toBe("/evil.com");
expect(result!.startsWith("//")).toBe(false);
});
});
describe("next/form shim", () => {
it("exports default Form component", async () => {
const mod = await import("../packages/vinext/src/shims/form.js");
expect(mod.default).toBeDefined();
expect(typeof mod.default).toBe("object"); // forwardRef returns an object
});
it("re-exports useActionState from React", async () => {
const mod = await import("../packages/vinext/src/shims/form.js");
expect(typeof mod.useActionState).toBe("function");
});
it("renders a form element with string action in SSR", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const { default: Form } = await import("../packages/vinext/src/shims/form.js");
const html = renderToStaticMarkup(
React.createElement(
Form,
{ action: "/search" },
React.createElement("input", { name: "q" }),
React.createElement("button", { type: "submit" }, "Search"),
),
);
expect(html).toContain("<form");
expect(html).toContain('action="/search"');
expect(html).toContain('name="q"');
expect(html).toContain("Search");
});
it("renders a form with method prop — strips method and warns", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const { default: Form } = await import("../packages/vinext/src/shims/form.js");
const warnSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const html = renderToStaticMarkup(
React.createElement(
Form,
// Cast to any: testing that a disallowed prop is stripped and warned about
{ action: "/api/submit", method: "POST" } as any,
React.createElement("button", { type: "submit" }, "Submit"),
),
);
expect(html).toContain("<form");
// method is a DISALLOWED_FORM_PROP — it must be stripped from the rendered output
expect(html).not.toContain('method="POST"');
// a dev warning must be emitted
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("<Form> does not support changing `method`"),
);
warnSpy.mockRestore();
});
it("renders children inside the form", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const { default: Form } = await import("../packages/vinext/src/shims/form.js");
const html = renderToStaticMarkup(
React.createElement(
Form,
{ action: "/search" },
React.createElement("label", null, "Query:"),
React.createElement("input", { name: "q", placeholder: "Search..." }),
React.createElement("button", null, "Go"),
),
);
expect(html).toContain("Query:");
expect(html).toContain('placeholder="Search..."');
expect(html).toContain("Go");
});
it("passes className and id through to form element", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const { default: Form } = await import("../packages/vinext/src/shims/form.js");
const html = renderToStaticMarkup(
React.createElement(Form, { action: "/search", className: "search-form", id: "main-search" }),
);
expect(html).toContain('class="search-form"');
expect(html).toContain('id="main-search"');
});
});
describe("next/font/google shim", () => {
it("returns className and style without variable unless requested for a Google Font", async () => {
const { createFontLoader } = await import("../packages/vinext/src/shims/font-google.js");
const Inter = createFontLoader("Inter");
const result = Inter({ subsets: ["latin"], weight: ["400", "700"] });
expect(result.className).toMatch(/^__font_inter_/);
expect(result.style.fontFamily).toContain("Inter");
expect(result.variable).toBeUndefined();
});
it("Proxy returns font loaders for any family", async () => {
const mod = await import("../packages/vinext/src/shims/font-google.js");
const googleFonts = mod.default;
const loader = googleFonts.Poppins;
expect(typeof loader).toBe("function");
const result = loader({ weight: "400" });
expect(result.className).toMatch(/^__font_poppins_/);
expect(result.style.fontFamily).toContain("Poppins");
});
it("converts PascalCase to font family name", async () => {
const mod = await import("../packages/vinext/src/shims/font-google.js");
const googleFonts = mod.default;
const result = googleFonts.RobotoMono({ weight: "400" });
expect(result.style.fontFamily).toContain("Roboto Mono");
expect(result.variable).toBeUndefined();
});
it("uses custom variable name when provided", async () => {
const { createFontLoader } = await import("../packages/vinext/src/shims/font-google.js");
const Inter = createFontLoader("Inter");
const result = Inter({ variable: "--custom-font" });
// When custom variable is provided, the generated class still sets that variable
// The returned value is still a class name, not the CSS variable name itself
expect(result.variable).toMatch(/^__variable_inter_/);
});
it("uses custom fallback fonts", async () => {
const { createFontLoader } = await import("../packages/vinext/src/shims/font-google.js");
const Inter = createFontLoader("Inter");
const result = Inter({ fallback: ["Helvetica", "Arial", "sans-serif"] });
expect(result.style.fontFamily).toContain("Helvetica");
expect(result.style.fontFamily).toContain("Arial");
});
it("generates CSS rules for className (SSR)", async () => {
const { createFontLoader, getSSRFontStyles } =
await import("../packages/vinext/src/shims/font-google.js");
// Clear any previously collected styles
getSSRFontStyles();
const Inter = createFontLoader("Inter");
const result = Inter({ subsets: ["latin"], weight: ["400"] });
// getSSRFontStyles should return CSS rules mapping className to font-family
const styles = getSSRFontStyles();
const allCss = styles.join("\n");
expect(allCss).toContain(`.${result.className}`);
expect(allCss).toContain("font-family:");
expect(allCss).toContain("Inter");
});
it("generates CSS variable rule when variable is specified", async () => {
const { createFontLoader, getSSRFontStyles } =
await import("../packages/vinext/src/shims/font-google.js");
getSSRFontStyles(); // clear
const Inter = createFontLoader("Inter");
Inter({ variable: "--font-inter" });
const styles = getSSRFontStyles();
const allCss = styles.join("\n");
expect(allCss).toContain("--font-inter:");
});
});
describe("next/font/local shim", () => {
it("returns className, style for a local font", async () => {
const { default: localFont } = await import("../packages/vinext/src/shims/font-local.js");
const result = localFont({ src: "./my-font.woff2" });
expect(result.className).toMatch(/^__font_local_/);
expect(result.style.fontFamily).toMatch(/__local_font_/);
});
it("includes variable as generated class name when specified", async () => {
const { default: localFont } = await import("../packages/vinext/src/shims/font-local.js");
const result = localFont({
src: "./my-font.woff2",
variable: "--font-custom",
});
// variable should be a generated class name, not the raw CSS variable
expect(result.variable).toMatch(/^__variable_local_/);
expect(result.variable).not.toBe("--font-custom");
});
it("accepts array of font sources", async () => {
const { default: localFont } = await import("../packages/vinext/src/shims/font-local.js");
const result = localFont({
src: [
{ path: "./regular.woff2", weight: "400" },
{ path: "./bold.woff2", weight: "700" },
],
});
expect(result.className).toMatch(/^__font_local_/);
expect(result.style.fontFamily).toBeTruthy();
});
it("does not include variable when not specified", async () => {
const { default: localFont } = await import("../packages/vinext/src/shims/font-local.js");
const result = localFont({ src: "./no-var.woff2" });
expect(result.variable).toBeUndefined();
});
it("generates SSR font styles for className rules", async () => {
const fontLocal = await import("../packages/vinext/src/shims/font-local.js");
const localFont = fontLocal.default;
// In test (Node), typeof document === "undefined", so SSR path is used
const result = localFont({
src: "./ssr-test.woff2",
variable: "--font-ssr-test",
});
const ssrStyles = fontLocal.getSSRFontStyles();
expect(ssrStyles.length).toBeGreaterThan(0);
// Should contain @font-face rule
const allCSS = ssrStyles.join("\n");
expect(allCSS).toContain("@font-face");
expect(allCSS).toContain("ssr-test.woff2");
// Should contain className rule
expect(allCSS).toContain(`.${result.className}`);
expect(allCSS).toContain("font-family:");
// Should contain variable class rule with :root fallback
expect(allCSS).toContain(`.${result.variable}`);
expect(allCSS).toContain("--font-ssr-test");
expect(allCSS).toContain(":root");
});
it("generates unique classNames and variableClassNames", async () => {
const { default: localFont } = await import("../packages/vinext/src/shims/font-local.js");
const a = localFont({ src: "./a.woff2", variable: "--font-a" });
const b = localFont({ src: "./b.woff2", variable: "--font-b" });
expect(a.className).not.toBe(b.className);
expect(a.variable).not.toBe(b.variable);
expect(a.variable).toMatch(/^__variable_local_/);
expect(b.variable).toMatch(/^__variable_local_/);
});
it("exports getSSRFontPreloads function", async () => {
const fontLocal = await import("../packages/vinext/src/shims/font-local.js");
expect(typeof fontLocal.getSSRFontPreloads).toBe("function");
});
it("collects preload data for fonts with absolute URLs", async () => {
const fontLocal = await import("../packages/vinext/src/shims/font-local.js");
const localFont = fontLocal.default;
// Simulate a font with an absolute URL (as resolved by Vite transform)
localFont({ src: "/assets/my-font-abc123.woff2" });
const preloads = fontLocal.getSSRFontPreloads();
const match = preloads.find((p: any) => p.href === "/assets/my-font-abc123.woff2");
expect(match).toBeDefined();
expect(match!.type).toBe("font/woff2");
});
it("collects preload data for array font sources with absolute URLs", async () => {
const fontLocal = await import("../packages/vinext/src/shims/font-local.js");
const localFont = fontLocal.default;
localFont({
src: [
{ path: "/assets/regular-abc.woff2", weight: "400" },
{ path: "/assets/bold-def.woff", weight: "700" },
],
});
const preloads = fontLocal.getSSRFontPreloads();
const woff2 = preloads.find((p: any) => p.href === "/assets/regular-abc.woff2");
const woff = preloads.find((p: any) => p.href === "/assets/bold-def.woff");
expect(woff2).toBeDefined();
expect(woff2!.type).toBe("font/woff2");
expect(woff).toBeDefined();
expect(woff!.type).toBe("font/woff");
});
it("does not collect preload data for relative URLs", async () => {
const fontLocal = await import("../packages/vinext/src/shims/font-local.js");
const localFont = fontLocal.default;
const preloadsBefore = fontLocal.getSSRFontPreloads().length;
localFont({ src: "./relative-font.woff2" });
const preloadsAfter = fontLocal.getSSRFontPreloads().length;
// Relative URLs should NOT be added to preloads
expect(preloadsAfter).toBe(preloadsBefore);
});
it("deduplicates preload entries by href", async () => {
const fontLocal = await import("../packages/vinext/src/shims/font-local.js");
const localFont = fontLocal.default;
// Call twice with the same font URL
localFont({ src: "/assets/dedup-test.woff2" });
localFont({ src: "/assets/dedup-test.woff2" });
const preloads = fontLocal.getSSRFontPreloads();
const matches = preloads.filter((p: any) => p.href === "/assets/dedup-test.woff2");
expect(matches.length).toBe(1);
});
});
describe("next/og shim", () => {
it("exports ImageResponse class", async () => {
const og = await import("../packages/vinext/src/shims/og.js");
expect(og.ImageResponse).toBeDefined();
expect(typeof og.ImageResponse).toBe("function");
});
it("ImageResponse extends Response", async () => {
const og = await import("../packages/vinext/src/shims/og.js");
// Check the prototype chain
expect(og.ImageResponse.prototype instanceof Response).toBe(true);
});
it("generates a PNG image from JSX", async () => {
const React = await import("react");
const og = await import("../packages/vinext/src/shims/og.js");
// Simple colored div — no text so no font needed
const element = React.createElement("div", {
style: {
display: "flex",
width: "100%",
height: "100%",
backgroundColor: "#ff6600",
alignItems: "center",
justifyContent: "center",
},
});
const response = new og.ImageResponse(element, {
width: 100,
height: 100,
});
expect(response).toBeInstanceOf(Response);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("image/png");
// Read the response body — should be valid PNG data
const buffer = await response.arrayBuffer();
const bytes = new Uint8Array(buffer);
// PNG magic bytes: 0x89 0x50 0x4E 0x47
expect(bytes[0]).toBe(0x89);
expect(bytes[1]).toBe(0x50); // P
expect(bytes[2]).toBe(0x4e); // N
expect(bytes[3]).toBe(0x47); // G
});
it("sets Next.js metadata image cache headers by default", async () => {
const React = await import("react");
const og = await import("../packages/vinext/src/shims/og.js");
const element = React.createElement("div", {
style: { display: "flex", width: "100%", height: "100%", backgroundColor: "blue" },
});
const response = new og.ImageResponse(element, {
width: 50,
height: 50,
});
expect(response.headers.get("cache-control")).toBe("public, max-age=0, must-revalidate");
});
it("respects custom status and headers", async () => {
const React = await import("react");
const og = await import("../packages/vinext/src/shims/og.js");
const element = React.createElement("div", {
style: { display: "flex", width: "100%", height: "100%", backgroundColor: "blue" },
});
const response = new og.ImageResponse(element, {
width: 50,
height: 50,
status: 201,
headers: { "x-custom": "test-value" },
});
expect(response.status).toBe(201);
expect(response.headers.get("x-custom")).toBe("test-value");
expect(response.headers.get("content-type")).toBe("image/png");
});
it("uses default dimensions of 1200x630", async () => {
const React = await import("react");
const og = await import("../packages/vinext/src/shims/og.js");
const element = React.createElement("div", {
style: { display: "flex", width: "100%", height: "100%", backgroundColor: "green" },
});
// No width/height specified — should use defaults
const response = new og.ImageResponse(element);
expect(response).toBeInstanceOf(Response);
// Verify it produces valid PNG
const buffer = await response.arrayBuffer();
const bytes = new Uint8Array(buffer);
expect(bytes[0]).toBe(0x89); // PNG magic
});
});
describe("metadata route serializers", () => {
it("sitemapToXml converts sitemap entries to valid XML", async () => {
const { sitemapToXml } = await import("../packages/vinext/src/server/metadata-routes.js");
const xml = sitemapToXml([
{ url: "https://example.com", lastModified: "2025-01-01", priority: 1 },
{ url: "https://example.com/about", changeFrequency: "monthly" as const },
]);
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>');
expect(xml).toContain("<urlset");
expect(xml).toContain("<loc>https://example.com</loc>");
expect(xml).toContain("<lastmod>2025-01-01</lastmod>");
expect(xml).toContain("<priority>1</priority>");
expect(xml).toContain("<loc>https://example.com/about</loc>");
expect(xml).toContain("<changefreq>monthly</changefreq>");
});
it("sitemapToXml handles Date objects", async () => {
const { sitemapToXml } = await import("../packages/vinext/src/server/metadata-routes.js");
const xml = sitemapToXml([
{ url: "https://example.com", lastModified: new Date("2025-06-15") },
]);
expect(xml).toContain("<lastmod>2025-06-15T00:00:00.000Z</lastmod>");
});
it("robotsToText converts robots config to text", async () => {
const { robotsToText } = await import("../packages/vinext/src/server/metadata-routes.js");
const text = robotsToText({
rules: { userAgent: "*", allow: "/", disallow: "/private/" },
sitemap: "https://example.com/sitemap.xml",
});
expect(text).toContain("User-Agent: *");
expect(text).toContain("Allow: /");
expect(text).toContain("Disallow: /private/");
expect(text).toContain("Sitemap: https://example.com/sitemap.xml");
});
it("robotsToText handles multiple rules", async () => {
const { robotsToText } = await import("../packages/vinext/src/server/metadata-routes.js");
const text = robotsToText({
rules: [
{ userAgent: "Googlebot", allow: "/" },
{ userAgent: "Bingbot", disallow: "/secret" },
],
});
expect(text).toContain("User-Agent: Googlebot");
expect(text).toContain("User-Agent: Bingbot");
expect(text).toContain("Disallow: /secret");
});
it("manifestToJson converts manifest config to JSON", async () => {
const { manifestToJson } = await import("../packages/vinext/src/server/metadata-routes.js");
const json = manifestToJson({
name: "Test App",
short_name: "Test",
start_url: "/",
display: "standalone",
});
const parsed = JSON.parse(json);
expect(parsed.name).toBe("Test App");
expect(parsed.display).toBe("standalone");
});
it("scanMetadataFiles discovers metadata files in app directory", async () => {
const { scanMetadataFiles } = await import("../packages/vinext/src/server/metadata-routes.js");
const appDir = path.resolve(import.meta.dirname, "./fixtures/app-basic/app");
const routes = scanMetadataFiles(appDir);
// Should find our test fixture files
const types = routes.map((r: { type: string }) => r.type);
expect(types).toContain("sitemap");
expect(types).toContain("robots");
expect(types).toContain("manifest");
expect(types).toContain("favicon");
// Root sitemap should be dynamic (.ts)
const sitemap = routes.find(
(r: { type: string; servedUrl: string }) =>
r.type === "sitemap" && r.servedUrl === "/sitemap.xml",
);
expect(sitemap).toBeDefined();
expect(sitemap!.isDynamic).toBe(true);
expect(sitemap!.contentType).toBe("application/xml");
// Favicon should be static (.ico)
const favicon = routes.find((r: { type: string }) => r.type === "favicon");
expect(favicon).toBeDefined();
expect(favicon!.isDynamic).toBe(false);
expect(favicon!.servedUrl).toBe("/favicon.ico");
expect(favicon!.contentType).toBe("image/x-icon");
});
});
describe("next/dynamic shim", () => {
it("exports a default function", async () => {
const mod = await import("../packages/vinext/src/shims/dynamic.js");
expect(typeof mod.default).toBe("function");
});
it("exports flushPreloads", async () => {
const mod = await import("../packages/vinext/src/shims/dynamic.js");
expect(typeof mod.flushPreloads).toBe("function");
});
it("returns a component for SSR-enabled dynamic imports", async () => {
const { default: dynamic } = await import("../packages/vinext/src/shims/dynamic.js");
const React = await import("react");
const { renderToReadableStream } = await import("react-dom/server.edge");
const FakeComponent = () => React.createElement("div", null, "Hello from dynamic");
const DynamicComponent = dynamic(() => Promise.resolve({ default: FakeComponent }));
// renderToReadableStream handles React.lazy + Suspense
const stream = await renderToReadableStream(React.createElement(DynamicComponent));
await stream.allReady;
const html = await new Response(stream).text();
expect(html).toContain("Hello from dynamic");
});
it("renders loading state for ssr: false on server", async () => {
const { default: dynamic } = await import("../packages/vinext/src/shims/dynamic.js");
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const FakeComponent = () => React.createElement("div", null, "Should not appear");
const Loading = () => React.createElement("span", null, "Loading...");
const DynamicComponent = dynamic(() => Promise.resolve({ default: FakeComponent }), {
ssr: false,
loading: Loading,
});
// On server with ssr: false, should render loading, not the component
const html = renderToStaticMarkup(React.createElement(DynamicComponent));
expect(html).toContain("Loading...");
expect(html).not.toContain("Should not appear");
});
it("renders nothing for ssr: false without loading on server", async () => {
const { default: dynamic } = await import("../packages/vinext/src/shims/dynamic.js");
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const FakeComponent = () => React.createElement("div", null, "Should not appear");
const DynamicComponent = dynamic(() => Promise.resolve({ default: FakeComponent }), {
ssr: false,
});
// On server with ssr: false and no loading component, should render nothing
const html = renderToStaticMarkup(React.createElement(DynamicComponent));
expect(html).toBe("");
});
it("accepts module without default export (bare component)", async () => {
const { default: dynamic } = await import("../packages/vinext/src/shims/dynamic.js");
const React = await import("react");
const { renderToReadableStream } = await import("react-dom/server.edge");
const BareComponent = () => React.createElement("p", null, "Bare export");
const DynamicComponent = dynamic(() => Promise.resolve(BareComponent));
const stream = await renderToReadableStream(React.createElement(DynamicComponent));
await stream.allReady;
const html = await new Response(stream).text();
expect(html).toContain("Bare export");
});
it("forwards props to the underlying component", async () => {
const { default: dynamic } = await import("../packages/vinext/src/shims/dynamic.js");
const React = await import("react");
const { renderToReadableStream } = await import("react-dom/server.edge");
const Greeter = ({ name }: { name: string }) =>
React.createElement("span", null, `Hello ${name}`);
const DynamicGreeter = dynamic(() => Promise.resolve({ default: Greeter }));
const stream = await renderToReadableStream(
React.createElement(DynamicGreeter, { name: "World" }),
);
await stream.allReady;
const html = await new Response(stream).text();
expect(html).toContain("Hello World");
});
it("renders loading fallback when component not yet resolved (SSR)", async () => {
const { default: dynamic } = await import("../packages/vinext/src/shims/dynamic.js");
const React = await import("react");
const { renderToReadableStream } = await import("react-dom/server.edge");
let resolveLoader!: (val: any) => void;
const loaderPromise = new Promise((r) => {
resolveLoader = r;
});
const SlowComponent = () => React.createElement("div", null, "Loaded");
const Loading = () => React.createElement("span", null, "Please wait...");
const DynamicSlow = dynamic(() => loaderPromise as any, { loading: Loading });
// Start streaming — the shell includes the Suspense fallback
const stream = await renderToReadableStream(React.createElement(DynamicSlow));
// Resolve the loader so the stream can complete
resolveLoader({ default: SlowComponent });
await stream.allReady;
const html = await new Response(stream).text();
// The final HTML should contain the resolved component, but the Suspense
// fallback ("Please wait...") was sent as part of the shell before it resolved
expect(html).toContain("Loaded");
});
it("streaming renderer resolves multiple dynamic components", async () => {
const { default: dynamic } = await import("../packages/vinext/src/shims/dynamic.js");
const React = await import("react");
const { renderToReadableStream } = await import("react-dom/server.edge");
const CompA = () => React.createElement("div", null, "Component A");
const CompB = () => React.createElement("div", null, "Component B");
const DynA = dynamic(
() => new Promise<any>((r) => setTimeout(() => r({ default: CompA }), 10)),
);
const DynB = dynamic(
() => new Promise<any>((r) => setTimeout(() => r({ default: CompB }), 10)),
);
// renderToReadableStream handles React.lazy via Suspense
const streamA = await renderToReadableStream(React.createElement(DynA));
await streamA.allReady;
const htmlA = await new Response(streamA).text();
const streamB = await renderToReadableStream(React.createElement(DynB));
await streamB.allReady;
const htmlB = await new Response(streamB).text();
expect(htmlA).toContain("Component A");
expect(htmlB).toContain("Component B");
});
it("flushPreloads second call resolves immediately (queue drained)", async () => {
const { flushPreloads } = await import("../packages/vinext/src/shims/dynamic.js");
// First call should drain whatever's in the queue
await flushPreloads();
// Second call should resolve immediately with empty array
const result = await flushPreloads();
expect(result).toEqual([]);
});
it("loading component receives App Router loading props (pastDelay:true)", async () => {
const { default: dynamic } = await import("../packages/vinext/src/shims/dynamic.js");
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
let receivedProps: any = null;
const Loading = (props: any) => {
receivedProps = props;
return React.createElement("span", null, "Loading");
};
const FakeComp = () => React.createElement("div", null, "Content");
const DynComp = dynamic(() => Promise.resolve({ default: FakeComp }), {
ssr: false,
loading: Loading,
});
renderToStaticMarkup(React.createElement(DynComp));
expect(receivedProps).not.toBeNull();
expect(receivedProps.isLoading).toBe(true);
// pastDelay is true on the server to match the client first render and the
// Next.js App Router contract (issue 1967).
expect(receivedProps.pastDelay).toBe(true);
expect(receivedProps.error).toBeNull();
});
it("renders loading fallback for ssr: false with props forwarded", async () => {
const { default: dynamic } = await import("../packages/vinext/src/shims/dynamic.js");
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const HeavyChart = ({ title }: { title: string }) => React.createElement("canvas", null, title);
const Loading = () => React.createElement("div", null, "Chart loading...");
const DynamicChart = dynamic(() => Promise.resolve({ default: HeavyChart }), {
ssr: false,
loading: Loading,
});
// On server: should show loading, not the chart
const html = renderToStaticMarkup(React.createElement(DynamicChart, { title: "Revenue" }));
expect(html).toContain("Chart loading...");
expect(html).not.toContain("Revenue");
});
it("handles module with both default and named exports", async () => {
const { default: dynamic } = await import("../packages/vinext/src/shims/dynamic.js");
const React = await import("react");
const { renderToReadableStream } = await import("react-dom/server.edge");
const MainComponent = () => React.createElement("div", null, "Main");
const namedHelper = () => "helper";
const DynComp = dynamic(() => Promise.resolve({ default: MainComponent, namedHelper }));
const stream = await renderToReadableStream(React.createElement(DynComp));
await stream.allReady;
const html = await new Response(stream).text();
expect(html).toContain("Main");
});
it("loader rejection does not crash flushPreloads", async () => {
const { default: dynamic, flushPreloads } =
await import("../packages/vinext/src/shims/dynamic.js");
dynamic(() => Promise.reject(new Error("Module not found")));
// flushPreloads should not throw (it's now a no-op for the server lazy path,
// but kept for backward compatibility with Pages Router)
await expect(flushPreloads()).resolves.not.toThrow();
});
it("loader rejection renders loading component with error", async () => {
const { default: dynamic } = await import("../packages/vinext/src/shims/dynamic.js");
const React = await import("react");
const { renderToReadableStream } = await import("react-dom/server.edge");
const LoadingComp = (props: { error?: Error | null; isLoading?: boolean }) => {
if (props.error) {
return React.createElement("div", null, `Error: ${props.error.message}`);
}
return React.createElement("div", null, "Loading...");
};
const DynComp = dynamic(() => Promise.reject(new Error("chunk load fail")), {
loading: LoadingComp,
});
// The error boundary renders the loading component with the error
const stream = await renderToReadableStream(React.createElement(DynComp));
await stream.allReady;
const html = await new Response(stream).text();
expect(html).toContain("Error: chunk load fail");
});
it("loader rejection without loading component propagates via onError", async () => {
const { default: dynamic } = await import("../packages/vinext/src/shims/dynamic.js");
const React = await import("react");
const { renderToReadableStream } = await import("react-dom/server.edge");
const DynComp = dynamic(() => Promise.reject(new Error("fail")));
// Without a loading component, the Suspense fallback is null.
// The rejected loader throws during rendering, caught by onError.
const errors: Error[] = [];
const stream = await renderToReadableStream(React.createElement(DynComp), {
onError(err: unknown) {
if (err instanceof Error) errors.push(err);
},
});
await stream.allReady.catch(() => {});
expect(errors.some((e) => e.message === "fail")).toBe(true);
});
it("loader rejection with non-Error value is caught during SSR", async () => {
const { default: dynamic } = await import("../packages/vinext/src/shims/dynamic.js");
const React = await import("react");
const { renderToReadableStream } = await import("react-dom/server.edge");
const DynComp = dynamic(() => Promise.reject("string error"));
// Non-Error rejection values are caught by React's SSR error handling
const errors: unknown[] = [];
const stream = await renderToReadableStream(React.createElement(DynComp), {
onError(err: unknown) {
errors.push(err);
},
});
await stream.allReady.catch(() => {});
expect(errors.length).toBeGreaterThan(0);
});
});
describe("basePath config validation", () => {
it("resolveNextConfig preserves basePath with leading slash", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const config = await resolveNextConfig({ basePath: "/my-app" });
expect(config.basePath).toBe("/my-app");
});
it("resolveNextConfig handles nested basePath", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const config = await resolveNextConfig({ basePath: "/a/b/c" });
expect(config.basePath).toBe("/a/b/c");
});
it("resolveNextConfig defaults to empty string", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const config = await resolveNextConfig({});
expect(config.basePath).toBe("");
});
it("resolveNextConfig handles undefined basePath", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const config = await resolveNextConfig({ basePath: undefined });
expect(config.basePath).toBe("");
});
});
describe("pageExtensions config", () => {
it("resolveNextConfig defaults pageExtensions to Next.js defaults", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const config = await resolveNextConfig({});
expect(config.pageExtensions).toEqual(["tsx", "ts", "jsx", "js"]);
});
it("resolveNextConfig reads pageExtensions from config", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const config = await resolveNextConfig({
pageExtensions: ["js", "jsx", "ts", "tsx", "mdx"],
});
expect(config.pageExtensions).toEqual(["js", "jsx", "ts", "tsx", "mdx"]);
});
it("resolveNextConfig strips leading dots and whitespace from pageExtensions entries", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const config = await resolveNextConfig({
pageExtensions: [".tsx", " ts ", "tsx", "", ".mdx"],
});
expect(config.pageExtensions).toEqual(["tsx", "ts", "tsx", "mdx"]);
});
});
describe("cacheComponents config (Next.js 16)", () => {
it("resolveNextConfig defaults cacheComponents to false", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const config = await resolveNextConfig({});
expect(config.cacheComponents).toBe(false);
});
it("resolveNextConfig reads cacheComponents: true", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const config = await resolveNextConfig({ cacheComponents: true });
expect(config.cacheComponents).toBe(true);
});
it("resolveNextConfig reads cacheComponents: false", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const config = await resolveNextConfig({ cacheComponents: false });
expect(config.cacheComponents).toBe(false);
});
it("resolveNextConfig handles null input with cacheComponents default", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const config = await resolveNextConfig(null);
expect(config.cacheComponents).toBe(false);
});
it("resolveNextConfig defaults mdx to null", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const config = await resolveNextConfig({});
expect(config.mdx).toBeNull();
});
it("resolveNextConfig returns null mdx for null input", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const config = await resolveNextConfig(null);
expect(config.mdx).toBeNull();
});
it("resolveNextConfig resolves serverActionsAllowedOrigins from experimental.serverActions", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const config = await resolveNextConfig({
experimental: {
serverActions: {
allowedOrigins: ["my-proxy.com", "*.my-domain.com"],
},
},
});
expect(config.serverActionsAllowedOrigins).toEqual(["my-proxy.com", "*.my-domain.com"]);
});
it("resolveNextConfig resolves allowedDevOrigins from top-level config", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const config = await resolveNextConfig({
allowedDevOrigins: ["staging.example.com", "*.preview.dev"],
});
expect(config.allowedDevOrigins).toEqual(["staging.example.com", "*.preview.dev"]);
});
it("resolveNextConfig keeps allowedDevOrigins separate from serverActionsAllowedOrigins", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const config = await resolveNextConfig({
allowedDevOrigins: ["dev.example.com"],
experimental: {
serverActions: {
allowedOrigins: ["actions.example.com"],
},
},
});
expect(config.allowedDevOrigins).toEqual(["dev.example.com"]);
expect(config.serverActionsAllowedOrigins).toEqual(["actions.example.com"]);
});
it("resolveNextConfig defaults allowedDevOrigins to empty array", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const config = await resolveNextConfig({});
expect(config.allowedDevOrigins).toEqual([]);
});
it("resolveNextConfig handles null input with empty allowedDevOrigins", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const config = await resolveNextConfig(null);
expect(config.allowedDevOrigins).toEqual([]);
});
it("resolveNextConfig defaults serverActionsAllowedOrigins to empty array", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const config = await resolveNextConfig({});
expect(config.serverActionsAllowedOrigins).toEqual([]);
});
it("resolveNextConfig handles null input with empty serverActionsAllowedOrigins", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const config = await resolveNextConfig(null);
expect(config.serverActionsAllowedOrigins).toEqual([]);
});
});
describe("loadNextConfig CJS support", () => {
let tmpDir: string;
beforeEach(async () => {
const os = await import("node:os");
const fsp = await import("node:fs/promises");
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-cjs-cfg-"));
});
afterEach(async () => {
const fsp = await import("node:fs/promises");
await fsp.rm(tmpDir, { recursive: true, force: true });
});
it("loads a CJS next.config.js that uses module.exports", async () => {
const fsp = await import("node:fs/promises");
const { loadNextConfig } = await import("../packages/vinext/src/config/next-config.js");
await fsp.writeFile(
path.join(tmpDir, "next.config.js"),
`module.exports = { basePath: "/cjs-app", trailingSlash: true };`,
);
const config = await loadNextConfig(tmpDir);
expect(config).not.toBeNull();
expect(config!.basePath).toBe("/cjs-app");
expect(config!.trailingSlash).toBe(true);
});
it("loads a CJS next.config.js with require() plugin wrapper", async () => {
const fsp = await import("node:fs/promises");
const { loadNextConfig } = await import("../packages/vinext/src/config/next-config.js");
// Simulate a CJS plugin wrapper like nextra/next-intl/etc.
// Create a fake plugin module that wraps the config
await fsp.mkdir(path.join(tmpDir, "node_modules", "fake-plugin"), {
recursive: true,
});
await fsp.writeFile(
path.join(tmpDir, "node_modules", "fake-plugin", "index.js"),
`module.exports = function fakePlugin(pluginOpts) {
return function withPlugin(nextConfig) {
return Object.assign({}, nextConfig, { env: { PLUGIN: "loaded" } });
};
};`,
);
await fsp.writeFile(
path.join(tmpDir, "node_modules", "fake-plugin", "package.json"),
JSON.stringify({ name: "fake-plugin", version: "1.0.0", main: "index.js" }),
);
// Write a next.config.js that uses require() — this is the pattern that fails
await fsp.writeFile(
path.join(tmpDir, "next.config.js"),
`const withPlugin = require('fake-plugin')({ theme: 'docs' });
module.exports = withPlugin({ basePath: "/wrapped" });`,
);
const config = await loadNextConfig(tmpDir);
expect(config).not.toBeNull();
expect(config!.basePath).toBe("/wrapped");
expect(config!.env).toEqual({ PLUGIN: "loaded" });
});
it("loads a CJS function-form next.config.js", async () => {
const fsp = await import("node:fs/promises");
const { loadNextConfig } = await import("../packages/vinext/src/config/next-config.js");
await fsp.writeFile(
path.join(tmpDir, "next.config.js"),
`module.exports = function(phase, { defaultConfig }) {
return { basePath: "/fn-" + phase.split("-")[1] };
};`,
);
const config = await loadNextConfig(tmpDir);
expect(config).not.toBeNull();
// phase is "phase-development-server", split("-")[1] = "development"
expect(config!.basePath).toBe("/fn-development");
});
it("loads a .cjs config file", async () => {
const fsp = await import("node:fs/promises");
const { loadNextConfig } = await import("../packages/vinext/src/config/next-config.js");
await fsp.writeFile(
path.join(tmpDir, "next.config.cjs"),
`module.exports = { basePath: "/cjs-ext" };`,
);
const config = await loadNextConfig(tmpDir);
expect(config).not.toBeNull();
expect(config!.basePath).toBe("/cjs-ext");
});
it("loads an ESM next.config.mjs normally", async () => {
const fsp = await import("node:fs/promises");
const { loadNextConfig } = await import("../packages/vinext/src/config/next-config.js");
await fsp.writeFile(
path.join(tmpDir, "next.config.mjs"),
`export default { basePath: "/esm-app" };`,
);
const config = await loadNextConfig(tmpDir);
expect(config).not.toBeNull();
expect(config!.basePath).toBe("/esm-app");
});
it("loads next.config.ts with extensionless local imports", async () => {
const fsp = await import("node:fs/promises");
const { loadNextConfig } = await import("../packages/vinext/src/config/next-config.js");
await fsp.writeFile(path.join(tmpDir, "env.ts"), `export const BASE_PATH = "/from-env";`);
await fsp.writeFile(
path.join(tmpDir, "next.config.ts"),
`import { BASE_PATH } from "./env";
export default {
basePath: BASE_PATH,
trailingSlash: true,
};`,
);
const config = await loadNextConfig(tmpDir);
expect(config).not.toBeNull();
expect(config!.basePath).toBe("/from-env");
expect(config!.trailingSlash).toBe(true);
});
it("returns null when no config file exists", async () => {
const { loadNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const config = await loadNextConfig(tmpDir);
expect(config).toBeNull();
});
});
describe("extractMdxOptions", () => {
it("returns null when no webpack function", async () => {
const { extractMdxOptions } = await import("../packages/vinext/src/config/next-config.js");
await expect(extractMdxOptions({})).resolves.toBeNull();
await expect(extractMdxOptions({ webpack: "not a function" })).resolves.toBeNull();
});
it("extracts remarkPlugins from webpack rule", async () => {
const { extractMdxOptions } = await import("../packages/vinext/src/config/next-config.js");
const fakeRemarkPlugin = () => {};
const config = {
webpack: (webpackConfig: any) => {
webpackConfig.module.rules.push({
test: /\.mdx$/,
use: [
{
loader: "@next/mdx/mdx-js-loader",
options: {
remarkPlugins: [[fakeRemarkPlugin, { option: true }]],
rehypePlugins: [],
},
},
],
});
return webpackConfig;
},
};
const result = await extractMdxOptions(config);
expect(result).not.toBeNull();
expect(result!.remarkPlugins).toHaveLength(1);
expect(result!.remarkPlugins![0]).toEqual([fakeRemarkPlugin, { option: true }]);
expect(result!.rehypePlugins).toBeUndefined();
});
it("extracts rehypePlugins from webpack rule", async () => {
const { extractMdxOptions } = await import("../packages/vinext/src/config/next-config.js");
const fakeRehypePlugin = () => {};
const config = {
webpack: (webpackConfig: any) => {
webpackConfig.module.rules.push({
test: /\.mdx$/,
use: [
{
loader: "@next/mdx/mdx-js-loader",
options: {
remarkPlugins: [],
rehypePlugins: [fakeRehypePlugin],
},
},
],
});
return webpackConfig;
},
};
const result = await extractMdxOptions(config);
expect(result).not.toBeNull();
expect(result!.rehypePlugins).toHaveLength(1);
expect(result!.remarkPlugins).toBeUndefined();
});
it("extracts recmaPlugins from webpack rule", async () => {
const { extractMdxOptions } = await import("../packages/vinext/src/config/next-config.js");
const fakeRecmaPlugin = () => {};
const config = {
webpack: (webpackConfig: any) => {
webpackConfig.module.rules.push({
test: /\.mdx$/,
use: [
{
loader: "@next/mdx/mdx-js-loader",
options: {
recmaPlugins: [fakeRecmaPlugin],
},
},
],
});
return webpackConfig;
},
};
const result = await extractMdxOptions(config);
expect(result).not.toBeNull();
expect(result!.recmaPlugins).toHaveLength(1);
});
it("handles oneOf nested rules", async () => {
const { extractMdxOptions } = await import("../packages/vinext/src/config/next-config.js");
const fakeRemarkPlugin = () => {};
const config = {
webpack: (webpackConfig: any) => {
webpackConfig.module.rules.push({
oneOf: [
{
test: /\.mdx$/,
use: [
{
loader: "@next/mdx/mdx-js-loader",
options: {
remarkPlugins: [fakeRemarkPlugin],
},
},
],
},
],
});
return webpackConfig;
},
};
const result = await extractMdxOptions(config);
expect(result).not.toBeNull();
expect(result!.remarkPlugins).toHaveLength(1);
});
it("returns null when webpack throws", async () => {
const { extractMdxOptions } = await import("../packages/vinext/src/config/next-config.js");
const config = {
webpack: () => {
throw new Error("some webpack error");
},
};
await expect(extractMdxOptions(config)).resolves.toBeNull();
});
it("returns null when webpack has no MDX loader", async () => {
const { extractMdxOptions } = await import("../packages/vinext/src/config/next-config.js");
const config = {
webpack: (webpackConfig: any) => {
webpackConfig.module.rules.push({
test: /\.css$/,
use: [{ loader: "css-loader" }],
});
return webpackConfig;
},
};
await expect(extractMdxOptions(config)).resolves.toBeNull();
});
it("returns null when MDX loader has empty plugin arrays", async () => {
const { extractMdxOptions } = await import("../packages/vinext/src/config/next-config.js");
const config = {
webpack: (webpackConfig: any) => {
webpackConfig.module.rules.push({
test: /\.mdx$/,
use: [
{
loader: "@next/mdx/mdx-js-loader",
options: {
remarkPlugins: [],
rehypePlugins: [],
},
},
],
});
return webpackConfig;
},
};
await expect(extractMdxOptions(config)).resolves.toBeNull();
});
it("resolveNextConfig extracts mdx from webpack closure", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const fakeRemarkPlugin = () => {};
const config = await resolveNextConfig({
webpack: (webpackConfig: any) => {
webpackConfig.module.rules.push({
test: /\.mdx$/,
use: [
{
loader: "@next/mdx/mdx-js-loader",
options: {
remarkPlugins: [fakeRemarkPlugin],
},
},
],
});
return webpackConfig;
},
});
expect(config.mdx).not.toBeNull();
expect(config.mdx!.remarkPlugins).toHaveLength(1);
});
});
describe("next/web-vitals shim", () => {
it("exports useReportWebVitals as a hook function", async () => {
const { useReportWebVitals } = await import("../packages/vinext/src/shims/web-vitals.js");
expect(typeof useReportWebVitals).toBe("function");
});
});
describe("next/amp shim", () => {
it("exports useAmp and isInAmpMode as no-op functions", async () => {
const { useAmp, isInAmpMode } = await import("../packages/vinext/src/shims/amp.js");
expect(typeof useAmp).toBe("function");
expect(typeof isInAmpMode).toBe("function");
// Both always return false
expect(useAmp()).toBe(false);
expect(isInAmpMode()).toBe(false);
});
});
describe("app router scroll intent state", () => {
it("clears a staged scroll intent when a same-document navigation supersedes it", async () => {
const {
beginAppRouterScrollIntent,
clearAppRouterScrollIntent,
getPendingAppRouterScrollIntent,
} = await import("../packages/vinext/src/shims/app-router-scroll-state.js");
clearAppRouterScrollIntent();
beginAppRouterScrollIntent(null);
expect(getPendingAppRouterScrollIntent()).not.toBeNull();
clearAppRouterScrollIntent();
expect(getPendingAppRouterScrollIntent()).toBeNull();
});
it("marks only the claimed commit's intent as head-hoisted", async () => {
const {
beginAppRouterScrollIntent,
claimAppRouterScrollIntentForCommit,
clearAppRouterScrollIntent,
consumeAppRouterScrollIntent,
getPendingAppRouterScrollIntent,
markAppRouterScrollIntentHeadHoisted,
} = await import("../packages/vinext/src/shims/app-router-scroll-state.js");
clearAppRouterScrollIntent();
const intent = beginAppRouterScrollIntent(null);
expect(intent.targetHoistedInHead).toBe(false);
// The committed scroll target only runs once a commit id is claimed, so a
// not-yet-claimed intent must never be marked.
markAppRouterScrollIntentHeadHoisted(intent, 7);
expect(getPendingAppRouterScrollIntent()?.targetHoistedInHead).toBe(false);
claimAppRouterScrollIntentForCommit(intent, 7);
// A scroll target from a different (e.g. stale) commit must not mark it.
markAppRouterScrollIntentHeadHoisted(intent, 6);
expect(getPendingAppRouterScrollIntent()?.targetHoistedInHead).toBe(false);
markAppRouterScrollIntentHeadHoisted(intent, 7);
const consumed = consumeAppRouterScrollIntent(intent, 7);
expect(consumed?.targetHoistedInHead).toBe(true);
expect(getPendingAppRouterScrollIntent()).toBeNull();
});
it("does not mark a stale intent reference as head-hoisted", async () => {
const {
beginAppRouterScrollIntent,
claimAppRouterScrollIntentForCommit,
clearAppRouterScrollIntent,
getPendingAppRouterScrollIntent,
markAppRouterScrollIntentHeadHoisted,
} = await import("../packages/vinext/src/shims/app-router-scroll-state.js");
clearAppRouterScrollIntent();
const staleIntent = beginAppRouterScrollIntent(null);
const latestIntent = beginAppRouterScrollIntent(null);
claimAppRouterScrollIntentForCommit(latestIntent, 3);
// A scroll target committed for an earlier navigation must not poison the
// navigation that replaced it.
markAppRouterScrollIntentHeadHoisted(staleIntent, 3);
expect(getPendingAppRouterScrollIntent()?.id).toBe(latestIntent.id);
expect(getPendingAppRouterScrollIntent()?.targetHoistedInHead).toBe(false);
});
it("only lets the claimed render commit consume its own scroll intent", async () => {
const {
beginAppRouterScrollIntent,
claimAppRouterScrollIntentForCommit,
clearAppRouterScrollIntent,
consumeAppRouterScrollIntent,
getPendingAppRouterScrollIntent,
} = await import("../packages/vinext/src/shims/app-router-scroll-state.js");
clearAppRouterScrollIntent();
const staleIntent = beginAppRouterScrollIntent(null);
const latestIntent = beginAppRouterScrollIntent(null);
claimAppRouterScrollIntentForCommit(staleIntent, 1);
expect(getPendingAppRouterScrollIntent()?.commitId).toBeNull();
expect(consumeAppRouterScrollIntent(undefined)).toBeNull();
expect(getPendingAppRouterScrollIntent()?.id).toBe(latestIntent.id);
expect(consumeAppRouterScrollIntent(null)).toBeNull();
expect(getPendingAppRouterScrollIntent()?.id).toBe(latestIntent.id);
expect(consumeAppRouterScrollIntent(latestIntent, 1)).toBeNull();
expect(getPendingAppRouterScrollIntent()?.id).toBe(latestIntent.id);
claimAppRouterScrollIntentForCommit(latestIntent, 2);
expect(consumeAppRouterScrollIntent(latestIntent, 1)).toBeNull();
const consumed = consumeAppRouterScrollIntent(latestIntent, 2);
expect(consumed?.id).toBe(latestIntent.id);
expect(consumed?.commitId).toBe(2);
expect(getPendingAppRouterScrollIntent()).toBeNull();
});
it("consuming an unclaimed intent without commitId matches navigateClientSide fallback contract", async () => {
const {
beginAppRouterScrollIntent,
clearAppRouterScrollIntent,
consumeAppRouterScrollIntent,
getPendingAppRouterScrollIntent,
} = await import("../packages/vinext/src/shims/app-router-scroll-state.js");
// no-commit path: intent was created but never claimed by a render commit.
// consumeAppRouterScrollIntent(intent) without a commitId must consume it
// so the navigateClientSide fallback (which calls consume without commitId)
// finds null and does not apply a scroll for an abandoned navigation.
clearAppRouterScrollIntent();
const intent = beginAppRouterScrollIntent(null);
expect(getPendingAppRouterScrollIntent()?.id).toBe(intent.id);
const consumed = consumeAppRouterScrollIntent(intent);
expect(consumed?.id).toBe(intent.id);
expect(getPendingAppRouterScrollIntent()).toBeNull();
});
it("consuming an intent with a stale intent reference is a no-op", async () => {
const {
beginAppRouterScrollIntent,
clearAppRouterScrollIntent,
consumeAppRouterScrollIntent,
getPendingAppRouterScrollIntent,
} = await import("../packages/vinext/src/shims/app-router-scroll-state.js");
// When a newer navigation replaces the pending intent, attempting to consume
// the stale reference must be a no-op — this is the safety check that prevents
// the fallback from consuming a different navigation's intent.
clearAppRouterScrollIntent();
const firstIntent = beginAppRouterScrollIntent(null);
const secondIntent = beginAppRouterScrollIntent(null);
expect(consumeAppRouterScrollIntent(firstIntent)).toBeNull();
expect(getPendingAppRouterScrollIntent()?.id).toBe(secondIntent.id);
expect(consumeAppRouterScrollIntent(secondIntent)).not.toBeNull();
expect(getPendingAppRouterScrollIntent()).toBeNull();
});
it("consuming a claimed intent without commitId is always allowed for cleanup paths", async () => {
const {
beginAppRouterScrollIntent,
claimAppRouterScrollIntentForCommit,
clearAppRouterScrollIntent,
consumeAppRouterScrollIntent,
getPendingAppRouterScrollIntent,
} = await import("../packages/vinext/src/shims/app-router-scroll-state.js");
// The fallback in navigateClientSide calls consume without a commitId.
// An intent claimed by a newer commit must still be consumable without a
// commitId check so that explicit cleanup paths (no-commit, hard-navigate)
// can clear it. The commitId gate only applies when a commitId is passed.
clearAppRouterScrollIntent();
const intent = beginAppRouterScrollIntent(null);
claimAppRouterScrollIntentForCommit(intent, 42);
expect(getPendingAppRouterScrollIntent()?.commitId).toBe(42);
// Consume without commitId — always allowed (no-commit / hard-navigate cleanup).
const consumed = consumeAppRouterScrollIntent(intent);
expect(consumed?.id).toBe(intent.id);
expect(getPendingAppRouterScrollIntent()).toBeNull();
});
});
describe("app router scroll document-top fallback", () => {
// applyAppRouterScrollFallback runs in navigateClientSide after a committed
// navigation declined to consume its scroll intent. It must scroll the
// document to the top UNLESS this navigation's committed target was a
// React-hoisted node in <head> (marked per-intent by AppRouterScrollTarget).
function withScrollFallbackEnv(
head: { querySelectorAll: () => unknown[] },
run: (documentElement: { scrollTop: number }) => void,
): void {
const documentElement = { clientHeight: 800, scrollTop: 500 };
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, "document");
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
Object.defineProperty(globalThis, "document", {
configurable: true,
value: { documentElement, head },
});
Object.defineProperty(globalThis, "window", { configurable: true, value: {} });
try {
run(documentElement);
} finally {
if (originalDocument) {
Object.defineProperty(globalThis, "document", originalDocument);
} else {
Reflect.deleteProperty(globalThis, "document");
}
if (originalWindow) {
Object.defineProperty(globalThis, "window", originalWindow);
} else {
Reflect.deleteProperty(globalThis, "window");
}
}
}
// A document head that already contains a React-hoisted precedence stylesheet
// — exactly the state in which the old global head scan wrongly suppressed the
// fallback for every navigation.
const headWithHoistedStylesheet = {
querySelectorAll: () => [
{
getAttribute: () => null,
localName: "link",
},
],
};
it("scrolls to the document top even when hoisted stylesheets exist in <head>", async () => {
const { beginAppRouterScrollIntent, clearAppRouterScrollIntent } =
await import("../packages/vinext/src/shims/app-router-scroll-state.js");
const { applyAppRouterScrollFallback } =
await import("../packages/vinext/src/shims/navigation.js");
// Regression: an ordinary navigation whose intent was never marked as
// head-hoisted must still scroll to the document top. The presence of a
// hoisted stylesheet in <head> for some unrelated reason must not suppress
// it — the suppression decision is per-intent, not a global head scan.
clearAppRouterScrollIntent();
const intent = beginAppRouterScrollIntent(null);
expect(intent.targetHoistedInHead).toBe(false);
withScrollFallbackEnv(headWithHoistedStylesheet, (documentElement) => {
applyAppRouterScrollFallback(intent);
expect(documentElement.scrollTop).toBe(0);
});
});
it("does not scroll to the document top when this navigation's target was hoisted into <head>", async () => {
const { applyAppRouterScrollFallback } =
await import("../packages/vinext/src/shims/navigation.js");
const intent = {
commitId: 1,
hash: null,
id: 1,
targetHoistedInHead: true,
};
withScrollFallbackEnv({ querySelectorAll: () => [] }, (documentElement) => {
applyAppRouterScrollFallback(intent);
expect(documentElement.scrollTop).toBe(500);
});
});
it("full chain: marked head-hoisted intent survives claim→consume and suppresses the document-top fallback", async () => {
const {
beginAppRouterScrollIntent,
claimAppRouterScrollIntentForCommit,
clearAppRouterScrollIntent,
consumeAppRouterScrollIntent,
getPendingAppRouterScrollIntent,
markAppRouterScrollIntentHeadHoisted,
} = await import("../packages/vinext/src/shims/app-router-scroll-state.js");
const { applyAppRouterScrollFallback } =
await import("../packages/vinext/src/shims/navigation.js");
// Start with a clean slate — no leftover intent from a prior test.
clearAppRouterScrollIntent();
// ── begin ──────────────────────────────────────────────────────────
// navigateClientSide stages an intent before sending the RSC navigation.
const originalIntent = beginAppRouterScrollIntent(null);
expect(originalIntent.targetHoistedInHead).toBe(false);
// ── claim ──────────────────────────────────────────────────────────
// The render commit claims the intent, writing its commit id.
claimAppRouterScrollIntentForCommit(originalIntent, 9);
// ── mark (head-hoisted) ───────────────────────────────────────────
// AppRouterScrollTarget finds the committed node is a React-hoisted
// resource in <head> and marks this navigation's intent.
markAppRouterScrollIntentHeadHoisted(originalIntent, 9);
// ── consume ────────────────────────────────────────────────────────
// navigateClientSide reads the consumable result.
const consumed = consumeAppRouterScrollIntent(originalIntent, 9);
expect(consumed).not.toBeNull();
expect(consumed!.targetHoistedInHead).toBe(true);
expect(consumed!.id).toBe(originalIntent.id);
expect(getPendingAppRouterScrollIntent()).toBeNull();
// ── apply fallback ─────────────────────────────────────────────────
// navigateClientSide calls the fallback with the consumed intent.
withScrollFallbackEnv({ querySelectorAll: () => [] }, (documentElement) => {
applyAppRouterScrollFallback(consumed!);
// targetHoistedInHead is true → fallback declines document-top scroll.
expect(documentElement.scrollTop).toBe(500);
});
// ══════════════════════════════════════════════════════════════════
// Regression guard
// ══════════════════════════════════════════════════════════════════
// markAppRouterScrollIntentHeadHoisted replaces the store.pending
// object with a spread copy to keep the type Readonly. The reference
// handed back by beginAppRouterScrollIntent still points at the old
// object where targetHoistedInHead is false. If a future refactor
// accidentally passes the original (stale) intent to the fallback
// instead of the consumed one, the flag check silently passes and the
// document scrolls to top — incorrectly masking the old-handler
// behaviour whose observable contract the upstream deploy-suite
// depends on.
expect(originalIntent.targetHoistedInHead).toBe(false);
});
});
describe("next/compat/router shim", () => {
it("exports useRouter as a function", async () => {
const mod = await import("../packages/vinext/src/shims/compat-router.js");
// useRouter should be a named export, not a default export (unlike next/router).
// Returns null in App Router context instead of throwing.
expect(typeof mod.useRouter).toBe("function");
expect((mod as Record<string, unknown>).default).toBeUndefined();
});
it("useRouter returns null when no RouterContext.Provider wraps the tree", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const { useRouter } = await import("../packages/vinext/src/shims/compat-router.js");
let captured: unknown = "NOT_SET";
function Probe() {
captured = useRouter();
return React.createElement("div", null, "probe");
}
renderToStaticMarkup(React.createElement(Probe));
expect(captured).toBeNull();
});
it("useRouter returns the router when wrapWithRouterContext wraps the tree", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const { useRouter: useCompatRouter } =
await import("../packages/vinext/src/shims/compat-router.js");
const { wrapWithRouterContext } = await import("../packages/vinext/src/shims/router.js");
let captured: unknown = "NOT_SET";
function Probe() {
captured = useCompatRouter();
return React.createElement("div", null, "probe");
}
const element = wrapWithRouterContext(React.createElement(Probe));
renderToStaticMarkup(element);
expect(captured).not.toBeNull();
expect(typeof (captured as any).pathname).toBe("string");
expect(typeof (captured as any).push).toBe("function");
expect(typeof (captured as any).replace).toBe("function");
expect(typeof (captured as any).back).toBe("function");
expect(typeof (captured as any).reload).toBe("function");
});
it("useRouter returns router reflecting SSR context when set", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const { useRouter: useCompatRouter } =
await import("../packages/vinext/src/shims/compat-router.js");
const { wrapWithRouterContext, setSSRContext } =
await import("../packages/vinext/src/shims/router.js");
setSSRContext({
pathname: "/posts/42",
query: { id: "42" },
asPath: "/posts/42?tab=comments",
});
let captured: unknown = "NOT_SET";
function Probe() {
captured = useCompatRouter();
return React.createElement("div", null, "probe");
}
const element = wrapWithRouterContext(React.createElement(Probe));
renderToStaticMarkup(element);
setSSRContext(null);
expect(captured).not.toBeNull();
expect((captured as any).pathname).toBe("/posts/42");
expect((captured as any).asPath).toBe("/posts/42?tab=comments");
expect((captured as any).query.id).toBe("42");
});
it("preserves array query values from SSR context", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const { useRouter: useCompatRouter } =
await import("../packages/vinext/src/shims/compat-router.js");
const { wrapWithRouterContext, setSSRContext } =
await import("../packages/vinext/src/shims/router.js");
setSSRContext({
pathname: "/docs/a/b",
query: { slug: ["a", "b"] },
asPath: "/docs/a/b",
});
let captured: unknown = "NOT_SET";
function Probe() {
captured = useCompatRouter();
return React.createElement("div", null, "probe");
}
const element = wrapWithRouterContext(React.createElement(Probe));
renderToStaticMarkup(element);
setSSRContext(null);
expect(captured).not.toBeNull();
expect((captured as any).query.slug).toEqual(["a", "b"]);
});
// Regression for issue #1466: React's `useSyncExternalStore` calls
// `getServerSnapshot` multiple times during SSR/hydration and expects a
// stable reference (Object.is) — otherwise it logs:
// "The result of getServerSnapshot should be cached to avoid an infinite loop"
// and may re-render until it stabilizes. `useParams`/`useSearchParams`/
// `usePathname` from `next/navigation` derive their server snapshot from
// `getPagesNavigationContext()`, so its SSR-side return value must be
// reference-stable within a single request. Dynamic params and search params
// intentionally follow the Pages Router pre-ready snapshot here so SSR and
// client hydration agree before the queued ready notification publishes live
// values.
it("getPagesNavigationContext returns stable pre-ready SSR snapshots in one request", async () => {
const { getPagesNavigationContext, setSSRContext } =
await import("../packages/vinext/src/shims/router.js");
setSSRContext({
pathname: "/pages-dir/[dynamic]",
query: { dynamic: "foobar" },
asPath: "/pages-dir/foobar?tab=comments",
navigationIsReady: false,
nextData: {
props: {},
page: "/pages-dir/[dynamic]",
query: { dynamic: "foobar", tab: "comments" },
autoExport: true,
},
});
try {
const first = getPagesNavigationContext();
const second = getPagesNavigationContext();
expect(first).not.toBeNull();
expect(first).toBe(second);
expect(first!.pathname).toBeNull();
expect(first!.params).toBeNull();
expect(first!.searchParams.toString()).toBe("");
// searchParams must also be reference-stable across calls so hook-level
// snapshots (`pagesCtx.searchParams`) are Object.is-equal between renders.
expect(first!.searchParams).toBe(second!.searchParams);
} finally {
setSSRContext(null);
}
});
it("client Pages navigation context prefers the current URL over stale __NEXT_DATA__ params", async () => {
const { getPagesNavigationContext } = await import("../packages/vinext/src/shims/router.js");
const previousWindow = (globalThis as any).window;
(globalThis as any).window = {
location: {
pathname: "/search-params-pages/bar",
search: "",
hash: "",
},
__NEXT_DATA__: {
page: "/search-params-pages/[foo]",
query: { foo: "foo" },
isFallback: false,
},
__VINEXT_PAGE_LOADERS__: {
"/search-params-pages/[foo]": async () => ({ default: () => null }),
},
};
try {
expect(getPagesNavigationContext()?.params).toEqual({ foo: "bar" });
} finally {
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
}
});
it("server Pages navigation context prefers the resolved URL over stale query params", async () => {
const { getPagesNavigationContext, setSSRContext } =
await import("../packages/vinext/src/shims/router.js");
setSSRContext({
pathname: "/search-params-pages/[foo]",
query: { foo: "foo" },
asPath: "/search-params-pages/bar",
navigationIsReady: true,
});
try {
expect(getPagesNavigationContext()?.params).toEqual({ foo: "bar" });
} finally {
setSSRContext(null);
}
});
// Regression: `useRouter().isReady` flows through getRouterSnapshot →
// PagesRouterProvider. On the server it must reflect the SSR navigation
// readiness context, not unconditionally report `true`. Otherwise a pre-ready
// route (auto-export dynamic / query string / rewrite-capable build) would
// render `isReady: true` on the server while the client hydrates with
// `isReady: false`, a hydration mismatch for components reading it in JSX.
// Mirrors Next.js render.tsx's server readiness rule.
it("useRouter().isReady reflects SSR navigation readiness during server render", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const { useRouter, wrapWithRouterContext, setSSRContext } =
await import("../packages/vinext/src/shims/router.js");
function Probe() {
return React.createElement("span", null, String(useRouter().isReady));
}
// Pre-ready route: navigationIsReady false → isReady false.
setSSRContext({
pathname: "/pages-dir/[dynamic]",
query: { dynamic: "foobar" },
asPath: "/pages-dir/foobar?tab=comments",
navigationIsReady: false,
});
try {
const html = renderToStaticMarkup(wrapWithRouterContext(React.createElement(Probe)));
expect(html).toBe("<span>false</span>");
} finally {
setSSRContext(null);
}
// Ready route: navigationIsReady true → isReady true.
setSSRContext({
pathname: "/blog/[slug]",
query: { slug: "hello" },
asPath: "/blog/hello",
navigationIsReady: true,
});
try {
const html = renderToStaticMarkup(wrapWithRouterContext(React.createElement(Probe)));
expect(html).toBe("<span>true</span>");
} finally {
setSSRContext(null);
}
});
it("does not defer Pages Router readiness for dynamic getStaticProps routes without search", async () => {
const { getPagesNavigationIsReadyFromSerializedState } =
await import("../packages/vinext/src/shims/router.js");
expect(
getPagesNavigationIsReadyFromSerializedState("/nav-compat-gsp/[slug]", "", {
props: {},
page: "/nav-compat-gsp/[slug]",
query: { slug: "foobar" },
gsp: true,
}),
).toBe(true);
expect(
getPagesNavigationIsReadyFromSerializedState("/nav-compat/[slug]", "", {
props: {},
page: "/nav-compat/[slug]",
query: { slug: "foobar" },
autoExport: true,
}),
).toBe(false);
expect(
getPagesNavigationIsReadyFromSerializedState("/nav-compat-gsp/[slug]", "?q=pages", {
props: {},
page: "/nav-compat-gsp/[slug]",
query: { slug: "foobar", q: "pages" },
gsp: true,
}),
).toBe(false);
expect(
getPagesNavigationIsReadyFromSerializedState("/search-params-static-rewrites", "", {
props: {},
page: "/search-params-static-rewrites",
query: {},
autoExport: true,
__vinext: { hasRewrites: true },
}),
).toBe(false);
});
it("preserves route param arrays, repeated search params, and hash in client router state", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const { useRouter: useCompatRouter } =
await import("../packages/vinext/src/shims/compat-router.js");
const { wrapWithRouterContext } = await import("../packages/vinext/src/shims/router.js");
const previousWindow = (globalThis as any).window;
(globalThis as any).window = {
location: {
pathname: "/docs/a/b",
search: "?tag=a&tag=b",
hash: "#section",
},
__NEXT_DATA__: {
page: "/docs/[...slug]",
query: { slug: ["a", "b"] },
isFallback: false,
},
__VINEXT_LOCALE__: undefined,
__VINEXT_LOCALES__: undefined,
__VINEXT_DEFAULT_LOCALE__: undefined,
};
try {
let captured: unknown = "NOT_SET";
function Probe() {
captured = useCompatRouter();
return React.createElement("div", null, "probe");
}
const element = wrapWithRouterContext(React.createElement(Probe));
renderToStaticMarkup(element);
expect(captured).not.toBeNull();
expect((captured as any).query.slug).toEqual(["a", "b"]);
expect((captured as any).query.tag).toEqual(["a", "b"]);
expect((captured as any).asPath).toBe("/docs/a/b?tag=a&tag=b#section");
} finally {
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
}
});
it("prefers dynamic route params over same-key search params in client router state", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const { useRouter: useCompatRouter } =
await import("../packages/vinext/src/shims/compat-router.js");
const { wrapWithRouterContext } = await import("../packages/vinext/src/shims/router.js");
const previousWindow = (globalThis as any).window;
(globalThis as any).window = {
location: {
pathname: "/docs/a/b",
search: "?slug=c",
hash: "",
},
__NEXT_DATA__: {
page: "/docs/[...slug]",
query: { slug: ["a", "b"] },
isFallback: false,
},
__VINEXT_LOCALE__: undefined,
__VINEXT_LOCALES__: undefined,
__VINEXT_DEFAULT_LOCALE__: undefined,
};
try {
let captured: unknown = "NOT_SET";
function Probe() {
captured = useCompatRouter();
return React.createElement("div", null, "probe");
}
const element = wrapWithRouterContext(React.createElement(Probe));
renderToStaticMarkup(element);
expect(captured).not.toBeNull();
expect((captured as any).query.slug).toEqual(["a", "b"]);
expect((captured as any).asPath).toBe("/docs/a/b?slug=c");
} finally {
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
}
});
it("treats prototype property names as ordinary query keys in client router state", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const { useRouter: useCompatRouter } =
await import("../packages/vinext/src/shims/compat-router.js");
const { wrapWithRouterContext } = await import("../packages/vinext/src/shims/router.js");
const previousWindow = (globalThis as any).window;
(globalThis as any).window = {
location: {
pathname: "/shallow-test",
search: "?toString=a&constructor=b&__proto__=c",
hash: "",
},
__NEXT_DATA__: {
page: "/shallow-test",
query: {},
isFallback: false,
},
__VINEXT_LOCALE__: undefined,
__VINEXT_LOCALES__: undefined,
__VINEXT_DEFAULT_LOCALE__: undefined,
};
try {
let captured: unknown = "NOT_SET";
function Probe() {
captured = useCompatRouter();
return React.createElement("div", null, "probe");
}
const element = wrapWithRouterContext(React.createElement(Probe));
renderToStaticMarkup(element);
expect(captured).not.toBeNull();
expect((captured as any).query.toString).toBe("a");
expect((captured as any).query.constructor).toBe("b");
expect((captured as any).query.__proto__).toBe("c");
expect(Object.getPrototypeOf((captured as any).query)).toBe(Object.prototype);
} finally {
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
}
});
});
describe("Pages Router router helpers", () => {
it("exports wrapWithRouterContext function", async () => {
const mod = await import("../packages/vinext/src/shims/router.js");
expect(typeof mod.wrapWithRouterContext).toBe("function");
});
it("serializes array query values as repeated params for object-form router URLs", async () => {
const previousWindow = (globalThis as any).window;
const pushState = vi.fn();
const replaceState = vi.fn();
(globalThis as any).window = {
location: {
pathname: "/",
search: "",
hash: "",
assign: vi.fn(),
replace: vi.fn(),
reload: vi.fn(),
},
history: {
state: null,
pushState,
replaceState,
back: vi.fn(),
},
dispatchEvent: vi.fn(),
scrollTo: vi.fn(),
scrollX: 0,
scrollY: 0,
__NEXT_DATA__: {
page: "/",
query: {},
isFallback: false,
},
__VINEXT_LOCALE__: undefined,
__VINEXT_LOCALES__: undefined,
__VINEXT_DEFAULT_LOCALE__: undefined,
};
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
await routerModule.default.push(
{ pathname: "/search", query: { tag: ["a", "b"], q: "x" } },
undefined,
{ shallow: true },
);
expect(pushState).toHaveBeenCalledWith(
expect.objectContaining({ __N: true }),
"",
"/search?tag=a&tag=b&q=x",
);
} finally {
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
}
});
it("stringifies scalar query values like Next.js for object-form router URLs", async () => {
const previousWindow = (globalThis as any).window;
const pushState = vi.fn();
const replaceState = vi.fn();
(globalThis as any).window = {
location: {
pathname: "/",
search: "",
hash: "",
assign: vi.fn(),
replace: vi.fn(),
reload: vi.fn(),
},
history: {
state: null,
pushState,
replaceState,
back: vi.fn(),
},
dispatchEvent: vi.fn(),
scrollTo: vi.fn(),
scrollX: 0,
scrollY: 0,
__NEXT_DATA__: {
page: "/",
query: {},
isFallback: false,
},
__VINEXT_LOCALE__: undefined,
__VINEXT_LOCALES__: undefined,
__VINEXT_DEFAULT_LOCALE__: undefined,
};
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
await routerModule.default.push(
{
pathname: "/search",
query: { page: 2, draft: false, empty: null, missing: undefined, tag: ["a", "b"] },
},
undefined,
{ shallow: true },
);
expect(pushState).toHaveBeenCalledWith(
expect.objectContaining({ __N: true }),
"",
"/search?page=2&draft=false&empty=&missing=&tag=a&tag=b",
);
} finally {
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
}
});
it("updates dynamic route params from the URL after shallow navigation", 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 React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const { useRouter: useCompatRouter } =
await import("../packages/vinext/src/shims/compat-router.js");
const routerModule = await import("../packages/vinext/src/shims/router.js");
const previousWindow = (globalThis as any).window;
const win = {
location: {
pathname: "/posts/42",
search: "",
hash: "",
href: "http://localhost/posts/42",
hostname: "localhost",
assign: vi.fn(),
replace: vi.fn(),
reload: vi.fn(),
},
history: {
state: null,
pushState: vi.fn((_state: unknown, _title: string, url: string) => {
const nextUrl = new URL(url, win.location.href);
win.location.pathname = nextUrl.pathname;
win.location.search = nextUrl.search;
win.location.hash = nextUrl.hash;
win.location.href = nextUrl.href;
}),
replaceState: vi.fn(),
back: vi.fn(),
},
dispatchEvent: vi.fn(),
scrollTo: vi.fn(),
scrollX: 0,
scrollY: 0,
__NEXT_DATA__: {
page: "/posts/[id]",
query: { id: "42" },
isFallback: false,
},
__VINEXT_LOCALE__: undefined,
__VINEXT_LOCALES__: undefined,
__VINEXT_DEFAULT_LOCALE__: undefined,
};
(globalThis as any).window = win;
try {
await routerModule.default.push("/posts/43", undefined, { shallow: true });
let captured: unknown = "NOT_SET";
function Probe() {
captured = useCompatRouter();
return React.createElement("div", null, "probe");
}
renderToStaticMarkup(routerModule.wrapWithRouterContext(React.createElement(Probe)));
expect(captured).not.toBeNull();
expect((captured as any).pathname).toBe("/posts/[id]");
expect((captured as any).asPath).toBe("/posts/43");
expect((captured as any).query).toEqual({ id: "43" });
expect(win.__NEXT_DATA__.query).toEqual({ id: "42" });
} finally {
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
}
});
it("exposes beforePopState on both the Router singleton and wrapped router context", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const mod = await import("../packages/vinext/src/shims/router.js");
const { useRouter: useCompatRouter } =
await import("../packages/vinext/src/shims/compat-router.js");
const routerSingleton = mod.default;
let captured: unknown = "NOT_SET";
function Probe() {
captured = useCompatRouter();
return React.createElement("div", null, "probe");
}
renderToStaticMarkup(mod.wrapWithRouterContext(React.createElement(Probe)));
expect(typeof (routerSingleton as any).beforePopState).toBe("function");
expect(typeof (captured as any).beforePopState).toBe("function");
});
describe("isExternalUrl", () => {
it("detects https:// as external", () => {
expect(isExternalUrl("https://example.com")).toBe(true);
expect(isExternalUrl("https://example.com/path")).toBe(true);
});
it("detects http:// as external", () => {
expect(isExternalUrl("http://example.com")).toBe(true);
});
it("detects protocol-relative // as external", () => {
expect(isExternalUrl("//cdn.example.com/img.png")).toBe(true);
});
it("detects native URI schemes as external", () => {
expect(isExternalUrl("mailto:hello@example.com")).toBe(true);
expect(isExternalUrl("tel:+123456789")).toBe(true);
expect(isExternalUrl("sms:+123456789")).toBe(true);
});
it("returns false for relative paths", () => {
expect(isExternalUrl("/about")).toBe(false);
expect(isExternalUrl("/")).toBe(false);
expect(isExternalUrl("about")).toBe(false);
});
it("returns false for hash-only", () => {
expect(isExternalUrl("#section")).toBe(false);
});
it("returns false for query-only", () => {
expect(isExternalUrl("?foo=1")).toBe(false);
});
});
describe("isHashOnlyChange", () => {
it("returns true for hash-only strings starting with #", () => {
// This works even without window because of the startsWith check
expect(isHashOnlyChange("#foo")).toBe(true);
expect(isHashOnlyChange("#")).toBe(true);
expect(isHashOnlyChange("#section-2")).toBe(true);
});
it("returns false for absolute paths without window context", () => {
// Without a real browser window, URL-based comparison returns false
// because typeof window === "undefined" → returns false
expect(isHashOnlyChange("/about")).toBe(false);
expect(isHashOnlyChange("/about#foo")).toBe(false);
});
it("returns false for full URLs without window context", () => {
expect(isHashOnlyChange("https://example.com#foo")).toBe(false);
});
});
describe("applyNavigationLocale", () => {
it("does not prefix absolute https:// URLs", async () => {
const { applyNavigationLocale } = await import("../packages/vinext/src/shims/router.js");
// Simulate a browser-like window so the locale guard is reached
(globalThis as any).window = { __VINEXT_DEFAULT_LOCALE__: "en" };
try {
expect(applyNavigationLocale("https://example.com/about", "fr")).toBe(
"https://example.com/about",
);
} finally {
delete (globalThis as any).window;
}
});
it("does not prefix absolute http:// URLs", async () => {
const { applyNavigationLocale } = await import("../packages/vinext/src/shims/router.js");
(globalThis as any).window = { __VINEXT_DEFAULT_LOCALE__: "en" };
try {
expect(applyNavigationLocale("http://example.com/path", "de")).toBe(
"http://example.com/path",
);
} finally {
delete (globalThis as any).window;
}
});
it("does not prefix protocol-relative // URLs", async () => {
const { applyNavigationLocale } = await import("../packages/vinext/src/shims/router.js");
(globalThis as any).window = { __VINEXT_DEFAULT_LOCALE__: "en" };
try {
expect(applyNavigationLocale("//cdn.example.com/img.png", "fr")).toBe(
"//cdn.example.com/img.png",
);
} finally {
delete (globalThis as any).window;
}
});
it("does not prefix native URI schemes", async () => {
const { applyNavigationLocale } = await import("../packages/vinext/src/shims/router.js");
(globalThis as any).window = { __VINEXT_DEFAULT_LOCALE__: "en" };
try {
expect(applyNavigationLocale("mailto:hello@example.com", "fr")).toBe(
"mailto:hello@example.com",
);
expect(applyNavigationLocale("tel:+123456789", "fr")).toBe("tel:+123456789");
} finally {
delete (globalThis as any).window;
}
});
it("prefixes local paths with locale", async () => {
const { applyNavigationLocale } = await import("../packages/vinext/src/shims/router.js");
(globalThis as any).window = { __VINEXT_DEFAULT_LOCALE__: "en" };
try {
expect(applyNavigationLocale("/about", "fr")).toBe("/fr/about");
} finally {
delete (globalThis as any).window;
}
});
});
});
describe("Pages Router concurrent navigation", () => {
it("installs the Pages Router popstate runtime before exposing window.next.router", async () => {
const previousWindow = (globalThis as any).window;
const { win } = createNavWindow();
const calls: string[] = [];
win.addEventListener = vi.fn((type: string) => {
const next = Reflect.get(win, "next");
const router =
typeof next === "object" && next !== null ? Reflect.get(next, "router") : undefined;
calls.push(
`${type}:${router === undefined ? "before-window-next-router" : "after-window-next-router"}`,
);
});
(globalThis as any).window = win;
try {
vi.resetModules();
await import("../packages/vinext/src/shims/router.js");
expect(calls).toEqual(["popstate:before-window-next-router"]);
} finally {
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
}
});
it("installPagesRouterRuntime registers the Pages Router popstate handler once", async () => {
const previousWindow = (globalThis as any).window;
const { win } = createNavWindow();
win.addEventListener = vi.fn();
(globalThis as any).window = win;
try {
vi.resetModules();
await import("../packages/vinext/src/shims/router.js");
const { installPagesRouterRuntime } =
await import("../packages/vinext/src/shims/pages-router-runtime.js");
installPagesRouterRuntime();
installPagesRouterRuntime();
expect(win.addEventListener).toHaveBeenCalledTimes(1);
expect(win.addEventListener).toHaveBeenCalledWith("popstate", expect.any(Function));
} finally {
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
}
});
/**
* Helper: create a mock window suitable for non-shallow Router.push().
* Returns an object with the window mock plus helpers for controlling
* the fetch responses (deferred promises).
*/
function createNavWindow() {
const pushState = vi.fn();
const replaceState = vi.fn();
const render = vi.fn((element: unknown) => commitRenderedPagesRouterElement(element));
const win = {
location: {
pathname: "/",
search: "",
hash: "",
href: "http://localhost/",
hostname: "localhost",
assign: vi.fn(),
replace: vi.fn(),
reload: vi.fn(),
},
history: {
state: null,
pushState: pushState as any,
replaceState: replaceState as any,
back: vi.fn(),
},
dispatchEvent: vi.fn(),
scrollTo: vi.fn(),
scrollX: 0,
scrollY: 0,
addEventListener: vi.fn(),
__NEXT_DATA__: {
page: "/",
query: {},
isFallback: false,
props: { pageProps: {} },
__vinext: { pageModuleUrl: "/@fs/pages/index.js" },
},
__VINEXT_ROOT__: { render },
__VINEXT_APP__: undefined,
__VINEXT_LOCALE__: undefined as string | undefined,
__VINEXT_LOCALES__: undefined as string[] | undefined,
__VINEXT_DEFAULT_LOCALE__: undefined as string | undefined,
};
// Make pushState update location to simulate real browser behavior
pushState.mockImplementation((_state: unknown, _title: string, url: string) => {
try {
const parsed = new URL(url, "http://localhost");
win.location.pathname = parsed.pathname;
win.location.search = parsed.search;
win.location.hash = parsed.hash;
win.location.href = parsed.href;
} catch {
// Relative URL — just set pathname
win.location.pathname = url;
win.location.href = "http://localhost" + url;
}
});
replaceState.mockImplementation((_state: unknown, _title: string, url?: string) => {
if (!url) return;
try {
const parsed = new URL(url, "http://localhost");
win.location.pathname = parsed.pathname;
win.location.search = parsed.search;
win.location.hash = parsed.hash;
win.location.href = parsed.href;
} catch {
win.location.pathname = url;
win.location.href = "http://localhost" + url;
}
});
return { win, pushState, replaceState, render };
}
/**
* Build a minimal HTML response that navigateClient can parse.
* Includes __NEXT_DATA__ with a pageModuleUrl pointing to the given path.
*/
function buildNavHtml(
page: string,
pageModuleUrl: string,
query: Record<string, unknown> = {},
i18n?: { locale: string; locales: string[]; defaultLocale: string },
): string {
const nextDataScript = buildPagesNextDataScript({
buildId: null,
i18n: i18n ?? {},
pageProps: { page },
params: query,
routePattern: page,
safeJsonStringify,
vinext: { pageModuleUrl },
});
return `<html><head></head><body>${nextDataScript}</body></html>`;
}
// Resolve an absolute fixture path the router can dynamically import. POSIX
// absolute paths begin with `/` and pass the router's `isValidModulePath`
// guard unchanged, so they are returned as-is. On Windows the path is
// drive-qualified (`E:\...`) with no leading `/`, which the guard rejects and
// blocks before the import; wrap it as the Vite `/@fs/` URL Vite serves for
// on-disk files, which is importable and starts with `/`.
function fixtureModuleUrl(relativePath: string): string {
const absolutePath = path.resolve(import.meta.dirname, relativePath);
if (!isWindows) return absolutePath;
return "/@fs/" + normalizePathSeparators(absolutePath);
}
function buildNavHtmlWithVinext(
page: string,
vinext: { pageModuleUrl?: string; appModuleUrl?: string; hasMiddleware?: boolean },
): string {
const nextDataScript = buildPagesNextDataScript({
buildId: null,
i18n: {},
pageProps: { page },
params: {},
routePattern: page,
safeJsonStringify,
vinext,
});
return `<html><head></head><body>${nextDataScript}</body></html>`;
}
function getFetchHref(url: RequestInfo | URL): string {
if (typeof url === "string") return url;
if (url instanceof URL) return url.href;
return url.url;
}
/**
* Create a deferred promise for controlling fetch timing.
*/
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
it("preserves cross-origin domain-locale targets during query-only same-segment navigation", async () => {
const previousWindow = (globalThis as any).window;
const previousBasePath = process.env.__NEXT_ROUTER_BASEPATH;
const { win } = createNavWindow();
process.env.__NEXT_ROUTER_BASEPATH = "/app";
Object.assign(win.location, {
pathname: "/app/rewrite-navigation-same/0",
href: "https://example.com/app/rewrite-navigation-same/0",
hostname: "example.com",
origin: "https://example.com",
});
Object.assign(win, {
__VINEXT_LOCALE__: "en",
__VINEXT_LOCALES__: ["en", "fr"],
__VINEXT_DEFAULT_LOCALE__: "en",
__NEXT_DATA__: {
...win.__NEXT_DATA__,
page: "/rewrite-navigation-same/[id]",
query: { id: "0" },
domainLocales: [
{ domain: "example.com", defaultLocale: "en" },
{ domain: "example.fr", defaultLocale: "fr", http: true },
],
},
});
(globalThis as any).window = win;
try {
vi.resetModules();
const { default: Router } = await import("../packages/vinext/src/shims/router.js");
await Router.push({ query: { id: "1" } }, undefined, { shallow: true, locale: "fr" });
expect(win.location.assign).toHaveBeenCalledWith(
"http://example.fr/app/rewrite-navigation-same/0?id=1",
);
expect(win.history.pushState).not.toHaveBeenCalled();
} finally {
vi.resetModules();
if (previousBasePath === undefined) delete process.env.__NEXT_ROUTER_BASEPATH;
else process.env.__NEXT_ROUTER_BASEPATH = previousBasePath;
if (previousWindow === undefined) delete (globalThis as any).window;
else (globalThis as any).window = previousWindow;
}
});
it("throws href-interpolation-failed when a required same-segment param is missing", async () => {
const previousWindow = (globalThis as any).window;
const { win } = createNavWindow();
Object.assign(win.location, {
pathname: "/catalog/books/old",
href: "http://localhost/catalog/books/old",
});
Object.assign(win, {
__NEXT_DATA__: {
...win.__NEXT_DATA__,
page: "/catalog/[category]/[item]",
query: { category: "books", item: "old" },
},
});
(globalThis as any).window = win;
try {
vi.resetModules();
const { default: Router } = await import("../packages/vinext/src/shims/router.js");
await expect(
Router.push({ query: { category: "music" } }, undefined, { shallow: true }),
).rejects.toThrow(
"The provided `href` (/catalog/[category]/[item]?category=music) value is missing query values (item) to be interpolated properly. Read more: https://nextjs.org/docs/messages/href-interpolation-failed",
);
expect(win.history.pushState).not.toHaveBeenCalled();
} finally {
vi.resetModules();
if (previousWindow === undefined) delete (globalThis as any).window;
else (globalThis as any).window = previousWindow;
}
});
it("allows an omitted optional catch-all during same-segment interpolation", async () => {
const previousWindow = (globalThis as any).window;
const { win } = createNavWindow();
Object.assign(win.location, {
pathname: "/docs/old",
href: "http://localhost/docs/old",
});
Object.assign(win, {
__NEXT_DATA__: {
...win.__NEXT_DATA__,
page: "/docs/[section]/[[...slug]]",
query: { section: "old" },
},
});
(globalThis as any).window = win;
try {
vi.resetModules();
const { default: Router } = await import("../packages/vinext/src/shims/router.js");
await expect(
Router.push({ query: { section: "guide" } }, undefined, { shallow: true }),
).resolves.toBe(true);
expect(win.history.pushState).toHaveBeenCalledWith(
expect.objectContaining({ __N: true }),
"",
"/docs/guide",
);
} finally {
vi.resetModules();
if (previousWindow === undefined) delete (globalThis as any).window;
else (globalThis as any).window = previousWindow;
}
});
it("interpolates required catch-all params during same-segment navigation", async () => {
const previousWindow = (globalThis as any).window;
const { win } = createNavWindow();
Object.assign(win.location, {
pathname: "/docs/old",
href: "http://localhost/docs/old",
});
Object.assign(win, {
__NEXT_DATA__: {
...win.__NEXT_DATA__,
page: "/docs/[...slug]",
query: { slug: ["old"] },
},
});
(globalThis as any).window = win;
try {
vi.resetModules();
const { default: Router } = await import("../packages/vinext/src/shims/router.js");
await expect(
Router.push({ query: { slug: ["guide", "intro"] } }, undefined, { shallow: true }),
).resolves.toBe(true);
expect(win.history.pushState).toHaveBeenCalledWith(
expect.objectContaining({ __N: true }),
"",
"/docs/guide/intro",
);
} finally {
vi.resetModules();
if (previousWindow === undefined) delete (globalThis as any).window;
else (globalThis as any).window = previousWindow;
}
});
for (const mode of ["push", "replace"] as const) {
it(`keeps hash-only UrlObject ${mode} on the current dynamic route`, async () => {
const previousWindow = (globalThis as any).window;
const previousDocument = (globalThis as any).document;
const originalFetch = globalThis.fetch;
const { win } = createNavWindow();
Object.assign(win.location, {
pathname: "/docs/old",
href: "http://localhost/docs/old",
});
Object.assign(win, {
__NEXT_DATA__: {
...win.__NEXT_DATA__,
page: "/docs/[id]",
query: { id: "old" },
},
});
(globalThis as any).window = win;
(globalThis as any).document = {
getElementById: vi.fn(() => null),
getElementsByName: vi.fn(() => []),
};
globalThis.fetch = vi.fn(async () => {
throw new Error("hash-only dynamic navigation must not fetch page HTML");
});
try {
vi.resetModules();
const { default: Router } = await import("../packages/vinext/src/shims/router.js");
await expect(Router[mode]({ hash: "section" })).resolves.toBe(true);
expect(globalThis.fetch).not.toHaveBeenCalled();
expect(win.history[`${mode}State`]).toHaveBeenCalledWith(
expect.objectContaining({ __N: true }),
"",
"/docs/old#section",
);
} finally {
vi.resetModules();
if (previousWindow === undefined) delete (globalThis as any).window;
else (globalThis as any).window = previousWindow;
if (previousDocument === undefined) delete (globalThis as any).document;
else (globalThis as any).document = previousDocument;
globalThis.fetch = originalFetch;
}
});
}
function trackHrefAssignments(win: {
location: {
href: string;
};
}): string[] {
let currentHref = win.location.href;
const assignments: string[] = [];
Object.defineProperty(win.location, "href", {
configurable: true,
enumerable: true,
get: () => currentHref,
set: (value: string) => {
currentHref = value;
assignments.push(value);
},
});
return assignments;
}
async function expectPagesRouterPushTrailingSlashNormalization({
target,
expectedBrowserUrl,
}: {
target: string;
expectedBrowserUrl: string;
}): Promise<void> {
const previousWindow = (globalThis as any).window;
const previousTrailingSlash = process.env.__VINEXT_TRAILING_SLASH;
const originalFetch = globalThis.fetch;
const { win } = createNavWindow();
(globalThis as any).window = win;
process.env.__VINEXT_TRAILING_SLASH = "true";
vi.resetModules();
globalThis.fetch = vi.fn(async () => {
throw new Error("shallow trailing-slash normalization test must not fetch page HTML");
});
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
const result = await Router.push(target, undefined, { shallow: true });
expect(result).toBe(true);
// History state now follows Next.js shape ({ url, as, options, __N, key });
// assert via partial match so test stays focused on URL normalization.
expect(win.history.pushState).toHaveBeenCalledWith(
expect.objectContaining({ __N: true }),
"",
expectedBrowserUrl,
);
} finally {
if (previousTrailingSlash === undefined) {
delete process.env.__VINEXT_TRAILING_SLASH;
} else {
process.env.__VINEXT_TRAILING_SLASH = previousTrailingSlash;
}
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
}
}
it("Pages Router push normalizes query-bearing paths when trailingSlash is true", async () => {
await expectPagesRouterPushTrailingSlashNormalization({
target: "/about?hello=world",
expectedBrowserUrl: "/about/?hello=world",
});
});
it("Pages Router push preserves canonical query-bearing paths when trailingSlash is true", async () => {
await expectPagesRouterPushTrailingSlashNormalization({
target: "/about/?hello=world",
expectedBrowserUrl: "/about/?hello=world",
});
});
it("Pages Router push strips file-looking path slashes when trailingSlash is true", async () => {
await expectPagesRouterPushTrailingSlashNormalization({
target: "/catch-all/hello.world/",
expectedBrowserUrl: "/catch-all/hello.world",
});
});
// Ported from Next.js:
// test/e2e/basepath/error-pages.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/basepath/error-pages.test.ts
it("Pages Router fetches the error route while preserving the masked URL under basePath", async () => {
const previousWindow = (globalThis as any).window;
const previousBasePath = process.env.__NEXT_ROUTER_BASEPATH;
const originalFetch = globalThis.fetch;
const { win } = createNavWindow();
const pageModuleUrl = fixtureModuleUrl("fixtures/client-navigation-page.tsx");
win.location.pathname = "/docs/slug-1";
win.location.href = "http://localhost/docs/slug-1";
(globalThis as any).window = win;
process.env.__NEXT_ROUTER_BASEPATH = "/docs";
const fetch = vi.fn(
async () =>
new Response(buildNavHtml("/404", pageModuleUrl), {
status: 404,
}),
);
globalThis.fetch = fetch;
try {
vi.resetModules();
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
const result = await Router.push("/404", "/slug-2");
expect(result).toBe(true);
expect(fetch).toHaveBeenCalledWith("/docs/404", expect.any(Object));
expect(win.history.pushState).toHaveBeenCalledWith(
expect.objectContaining({ __N: true }),
"",
"/docs/slug-2",
);
expect(win.location.pathname).toBe("/docs/slug-2");
expect(win.__NEXT_DATA__.page).toBe("/404");
} finally {
if (previousBasePath === undefined) {
delete process.env.__NEXT_ROUTER_BASEPATH;
} else {
process.env.__NEXT_ROUTER_BASEPATH = previousBasePath;
}
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
}
});
it("Pages Router maps masked /_error client navigations to the 404 page under basePath", async () => {
const previousWindow = (globalThis as any).window;
const previousBasePath = process.env.__NEXT_ROUTER_BASEPATH;
const originalFetch = globalThis.fetch;
const { win } = createNavWindow();
const pageModuleUrl = fixtureModuleUrl("fixtures/client-navigation-page.tsx");
win.location.pathname = "/docs/slug-1";
win.location.href = "http://localhost/docs/slug-1";
(globalThis as any).window = win;
process.env.__NEXT_ROUTER_BASEPATH = "/docs";
const fetch = vi.fn(
async () =>
new Response(buildNavHtml("/404", pageModuleUrl), {
status: 404,
}),
);
globalThis.fetch = fetch;
try {
vi.resetModules();
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
const result = await Router.push("/_error", "/slug-2");
expect(result).toBe(true);
expect(fetch).toHaveBeenCalledWith("/docs/404", expect.any(Object));
expect(win.history.pushState).toHaveBeenCalledWith(
expect.objectContaining({ __N: true }),
"",
"/docs/slug-2",
);
expect(win.location.pathname).toBe("/docs/slug-2");
expect(win.__NEXT_DATA__.page).toBe("/404");
} finally {
if (previousBasePath === undefined) {
delete process.env.__NEXT_ROUTER_BASEPATH;
} else {
process.env.__NEXT_ROUTER_BASEPATH = previousBasePath;
}
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
}
});
it("Pages Router fetches /404 through a non-default locale while preserving the masked URL", async () => {
const previousWindow = (globalThis as any).window;
const previousBasePath = process.env.__NEXT_ROUTER_BASEPATH;
const originalFetch = globalThis.fetch;
const { win } = createNavWindow();
const pageModuleUrl = fixtureModuleUrl("fixtures/client-navigation-page.tsx");
win.location.pathname = "/docs/fr/slug-1";
win.location.href = "http://localhost/docs/fr/slug-1";
win.__VINEXT_LOCALE__ = "fr";
win.__VINEXT_LOCALES__ = ["en", "fr"];
win.__VINEXT_DEFAULT_LOCALE__ = "en";
(globalThis as any).window = win;
process.env.__NEXT_ROUTER_BASEPATH = "/docs";
const fetch = vi.fn(
async () =>
new Response(
buildNavHtml(
"/404",
pageModuleUrl,
{},
{
locale: "fr",
locales: ["en", "fr"],
defaultLocale: "en",
},
),
{ status: 404 },
),
);
globalThis.fetch = fetch;
try {
vi.resetModules();
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
const result = await Router.push("/404", "/slug-2");
expect(result).toBe(true);
expect(fetch).toHaveBeenCalledWith("/docs/fr/404", expect.any(Object));
expect(win.history.pushState).toHaveBeenCalledWith(
expect.objectContaining({ __N: true }),
"",
"/docs/fr/slug-2",
);
expect(win.location.pathname).toBe("/docs/fr/slug-2");
expect(win.__NEXT_DATA__.page).toBe("/404");
} finally {
if (previousBasePath === undefined) {
delete process.env.__NEXT_ROUTER_BASEPATH;
} else {
process.env.__NEXT_ROUTER_BASEPATH = previousBasePath;
}
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
}
});
it("Pages Router localizes masked route and display URLs to a non-default locale", async () => {
const previousWindow = (globalThis as any).window;
const previousBasePath = process.env.__NEXT_ROUTER_BASEPATH;
const originalFetch = globalThis.fetch;
const { win } = createNavWindow();
const pageModuleUrl = fixtureModuleUrl("fixtures/client-navigation-page.tsx");
win.location.pathname = "/docs/en/hello";
win.location.href = "http://localhost/docs/en/hello";
win.__VINEXT_LOCALE__ = "en";
win.__VINEXT_LOCALES__ = ["en", "fr"];
win.__VINEXT_DEFAULT_LOCALE__ = "en";
(globalThis as any).window = win;
process.env.__NEXT_ROUTER_BASEPATH = "/docs";
const fetch = vi.fn(
async () =>
new Response(
buildNavHtml(
"/something-else",
pageModuleUrl,
{},
{
locale: "fr",
locales: ["en", "fr"],
defaultLocale: "en",
},
),
),
);
globalThis.fetch = fetch;
try {
vi.resetModules();
const routerModule = await import("../packages/vinext/src/shims/router.js");
const result = await routerModule.default.push("/something-else", "/hello", {
locale: "fr",
});
expect(result).toBe(true);
expect(fetch).toHaveBeenCalledWith("/docs/fr/something-else", expect.any(Object));
expect(win.history.pushState).toHaveBeenCalledWith(
expect.objectContaining({
url: "/fr/something-else",
as: "/fr/hello",
options: expect.objectContaining({ locale: "fr" }),
}),
"",
"/docs/fr/hello",
);
expect(win.location.pathname).toBe("/docs/fr/hello");
expect(win.__NEXT_DATA__.page).toBe("/something-else");
expect(win.__VINEXT_LOCALE__).toBe("fr");
} finally {
if (previousBasePath === undefined) delete process.env.__NEXT_ROUTER_BASEPATH;
else process.env.__NEXT_ROUTER_BASEPATH = previousBasePath;
vi.resetModules();
if (previousWindow === undefined) delete (globalThis as any).window;
else (globalThis as any).window = previousWindow;
globalThis.fetch = originalFetch;
}
});
it("Pages Router maps /_error through a non-default locale while preserving the masked URL", async () => {
const previousWindow = (globalThis as any).window;
const previousBasePath = process.env.__NEXT_ROUTER_BASEPATH;
const originalFetch = globalThis.fetch;
const { win } = createNavWindow();
const pageModuleUrl = fixtureModuleUrl("fixtures/client-navigation-page.tsx");
win.location.pathname = "/docs/fr/slug-1";
win.location.href = "http://localhost/docs/fr/slug-1";
win.__VINEXT_LOCALE__ = "fr";
win.__VINEXT_LOCALES__ = ["en", "fr"];
win.__VINEXT_DEFAULT_LOCALE__ = "en";
(globalThis as any).window = win;
process.env.__NEXT_ROUTER_BASEPATH = "/docs";
const fetch = vi.fn(
async () =>
new Response(
buildNavHtml(
"/404",
pageModuleUrl,
{},
{
locale: "fr",
locales: ["en", "fr"],
defaultLocale: "en",
},
),
{ status: 404 },
),
);
globalThis.fetch = fetch;
try {
vi.resetModules();
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
const result = await Router.push("/_error", "/slug-2");
expect(result).toBe(true);
expect(fetch).toHaveBeenCalledWith("/docs/fr/404", expect.any(Object));
expect(win.history.pushState).toHaveBeenCalledWith(
expect.objectContaining({ __N: true }),
"",
"/docs/fr/slug-2",
);
expect(win.location.pathname).toBe("/docs/fr/slug-2");
expect(win.__NEXT_DATA__.page).toBe("/404");
} finally {
if (previousBasePath === undefined) {
delete process.env.__NEXT_ROUTER_BASEPATH;
} else {
process.env.__NEXT_ROUTER_BASEPATH = previousBasePath;
}
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
}
});
it("Pages Router HTML fallback prefers the registered route loader", async () => {
const previousWindow = (globalThis as any).window;
const previousDocument = (globalThis as any).document;
const originalFetch = globalThis.fetch;
const { win, render } = createNavWindow();
const LoaderPage = () => null;
const loader = vi.fn(async () => ({ default: LoaderPage }));
Object.assign(win, {
__VINEXT_PAGE_LOADERS__: {
"/same": loader,
},
__VINEXT_PAGE_PATTERNS__: ["/same"],
});
(globalThis as any).window = win;
(globalThis as any).document = {
createElement: vi.fn(),
head: { appendChild: vi.fn() },
};
const fetch = vi.fn(
async () => new Response(buildNavHtml("/same", "javascript:duplicate-or-invalid")),
);
globalThis.fetch = fetch;
try {
vi.resetModules();
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
const result = await Router.push("/same");
expect(result).toBe(true);
expect(fetch).toHaveBeenCalledWith("/same", expect.any(Object));
expect(loader).toHaveBeenCalledTimes(1);
expect(render).toHaveBeenCalled();
expect(win.location.href).toBe("http://localhost/same");
} finally {
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
if (previousDocument === undefined) {
delete (globalThis as any).document;
} else {
(globalThis as any).document = previousDocument;
}
globalThis.fetch = originalFetch;
}
});
it("last push() wins when two overlap — superseded navigation does not render", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const { win } = createNavWindow();
(globalThis as any).window = win;
// Two deferred fetches so we control resolution order
const fetchA = createDeferred<Response>();
const fetchB = createDeferred<Response>();
let fetchCount = 0;
globalThis.fetch = async (_url: any, _init: any) => {
fetchCount++;
if (fetchCount === 1) return fetchA.promise;
return fetchB.promise;
};
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
// Start two navigations — don't await yet
const navA = Router.push("/page-a");
// Let microtask queue process so navA's fetch has been called
await Promise.resolve();
const navB = Router.push("/page-b");
// Resolve B first (the winning navigation)
fetchB.resolve(new Response(buildNavHtml("/page-b", "/@fs/pages/page-b.js")));
// Resolve A after B (stale — should be ignored)
fetchA.resolve(new Response(buildNavHtml("/page-a", "/@fs/pages/page-a.js")));
await Promise.allSettled([navA, navB]);
// The superseded navigation (page-a) must NOT have committed its data.
// In a real browser page-b would render; in the test env B's dynamic import
// may fail, so we verify the important invariant: A never writes.
expect(win.__NEXT_DATA__.page).not.toBe("/page-a");
} finally {
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
}
});
it("routeChangeComplete does not fire for the superseded navigation", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const { win } = createNavWindow();
(globalThis as any).window = win;
const fetchA = createDeferred<Response>();
const fetchB = createDeferred<Response>();
let fetchCount = 0;
globalThis.fetch = async (_url: any, _init: any) => {
fetchCount++;
if (fetchCount === 1) return fetchA.promise;
return fetchB.promise;
};
const completedUrls: string[] = [];
const onRouteChangeComplete = (...args: unknown[]) => {
completedUrls.push(String(args[0]));
};
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
Router.events.on("routeChangeComplete", onRouteChangeComplete);
// Start two overlapping navigations
const navA = Router.push("/page-a");
await Promise.resolve();
const navB = Router.push("/page-b");
// Resolve B first, then A
fetchB.resolve(new Response(buildNavHtml("/page-b", "/@fs/pages/page-b.js")));
fetchA.resolve(new Response(buildNavHtml("/page-a", "/@fs/pages/page-a.js")));
await Promise.allSettled([navA, navB]);
// The superseded navigation (page-a) must NOT fire routeChangeComplete.
// page-b may or may not complete fully (dynamic import may fail in test
// env), but that's a separate concern. The critical fix is that the
// cancelled navigation never fires routeChangeComplete.
expect(completedUrls).not.toContain("/page-a");
} finally {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
Router.events.off("routeChangeComplete", onRouteChangeComplete);
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
}
});
it("routeChangeError fires for superseded navigation with cancelled error", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const { win } = createNavWindow();
(globalThis as any).window = win;
const fetchA = createDeferred<Response>();
const fetchB = createDeferred<Response>();
let fetchCount = 0;
globalThis.fetch = async (_url: any, _init: any) => {
fetchCount++;
if (fetchCount === 1) return fetchA.promise;
return fetchB.promise;
};
const errors: Array<{ err: unknown; url: string }> = [];
const onRouteChangeError = (...args: unknown[]) => {
errors.push({ err: args[0], url: String(args[1]) });
};
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
Router.events.on("routeChangeError", onRouteChangeError);
// Start two overlapping navigations
const navA = Router.push("/page-a");
await Promise.resolve();
const navB = Router.push("/page-b");
// Resolve both
fetchB.resolve(new Response(buildNavHtml("/page-b", "/@fs/pages/page-b.js")));
fetchA.resolve(new Response(buildNavHtml("/page-a", "/@fs/pages/page-a.js")));
await Promise.allSettled([navA, navB]);
// The superseded navigation (page-a) should emit routeChangeError
// with a cancelled error, matching Next.js behavior
const cancelledError = errors.find((e) => e.url === "/page-a");
expect(cancelledError).toBeDefined();
const errObj = cancelledError?.err;
expect(errObj).toHaveProperty("cancelled", true);
} finally {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
Router.events.off("routeChangeError", onRouteChangeError);
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
}
});
it("failed navigation (non-OK response) does not emit routeChangeComplete", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const { win } = createNavWindow();
(globalThis as any).window = win;
globalThis.fetch = async (_url: any, _init: any) =>
new Response("Internal Server Error", { status: 500 });
const completedUrls: string[] = [];
const errorUrls: string[] = [];
const onRouteChangeComplete = (...args: unknown[]) => {
completedUrls.push(String(args[0]));
};
const onRouteChangeError = (...args: unknown[]) => {
errorUrls.push(String(args[1]));
};
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
Router.events.on("routeChangeComplete", onRouteChangeComplete);
Router.events.on("routeChangeError", onRouteChangeError);
await Router.push("/failing-page");
// Should NOT have fired routeChangeComplete for a failed navigation
expect(completedUrls).not.toContain("/failing-page");
// Should have fired routeChangeError
expect(errorUrls).toContain("/failing-page");
} finally {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
Router.events.off("routeChangeComplete", onRouteChangeComplete);
Router.events.off("routeChangeError", onRouteChangeError);
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
}
});
it("known navigation failures schedule exactly one hard-navigation fallback", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const { win } = createNavWindow();
const hrefAssignments = trackHrefAssignments(win);
(globalThis as any).window = win;
globalThis.fetch = async (_url: any, _init: any) =>
new Response("Internal Server Error", { status: 500 });
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
const result = await Router.push("/failing-page");
// Distinguish the history update from the hard-navigation fallback:
// pushState writes the absolute browser URL, while the fallback helper
// writes the raw app-relative URL. The guard is correct only if each
// happens exactly once.
const fallbackAssignments = hrefAssignments.filter((value) => value === "/failing-page");
const pushStateAssignments = hrefAssignments.filter(
(value) => value === "http://localhost/failing-page",
);
expect(result).toBe(false);
expect(fallbackAssignments).toHaveLength(1);
expect(pushStateAssignments).toHaveLength(1);
// Catch-all: exactly one history write plus exactly one hard-nav fallback.
expect(hrefAssignments).toHaveLength(2);
} finally {
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
}
});
it("fetches default-locale root through a locale-qualified URL without changing the browser URL", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const { win } = createNavWindow();
const pageModuleUrl = fixtureModuleUrl("fixtures/client-navigation-page.tsx");
win.location.pathname = "/new";
win.location.href = "http://localhost/new";
Object.assign(win, {
__VINEXT_LOCALE__: "en",
__VINEXT_LOCALES__: ["en", "id"],
__VINEXT_DEFAULT_LOCALE__: "en",
});
(globalThis as any).window = win;
const fetch = vi.fn(
async () =>
new Response(
buildNavHtml(
"/",
pageModuleUrl,
{},
{
locale: "en",
locales: ["en", "id"],
defaultLocale: "en",
},
),
{ status: 200 },
),
);
globalThis.fetch = fetch;
try {
vi.resetModules();
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
const result = await Router.push("/");
expect(result).toBe(true);
expect(fetch).toHaveBeenCalledWith("/en", expect.any(Object));
// Next.js-shaped history state (`{ url, as, options, __N, key }`) is now
// written on every router push; assert via partial match so this test
// stays focused on the locale-qualified fetch / browser-URL contract.
expect(win.history.pushState).toHaveBeenCalledWith(
expect.objectContaining({ __N: true, options: expect.objectContaining({ locale: "en" }) }),
"",
"/",
);
expect(win.location.href).toBe("http://localhost/");
expect(win.__VINEXT_LOCALE__).toBe("en");
} finally {
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
}
});
it("keeps the active locale for root Link navigations from a non-locale-prefixed path", async () => {
// i18n sticky-locale (issue #1336): a default-locale path served under a
// non-default locale must keep reporting its active `__VINEXT_LOCALE__` so
// a subsequent client-side root navigation stays in that locale rather
// than snapping back to the configured default. See
// tests/pages-router-i18n-sticky-locale.test.ts for the broader contract.
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const { win } = createNavWindow();
const pageModuleUrl = fixtureModuleUrl("fixtures/client-navigation-page.tsx");
win.location.pathname = "/new";
win.location.href = "http://localhost/new";
Object.assign(win, {
__VINEXT_LOCALE__: "id",
__VINEXT_LOCALES__: ["en", "id"],
__VINEXT_DEFAULT_LOCALE__: "en",
});
(globalThis as any).window = win;
const fetch = vi.fn(
async () =>
new Response(
buildNavHtml(
"/",
pageModuleUrl,
{},
{
locale: "id",
locales: ["en", "id"],
defaultLocale: "en",
},
),
{ status: 200 },
),
);
globalThis.fetch = fetch;
try {
vi.resetModules();
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
const result = await Router.push("/");
expect(result).toBe(true);
expect(fetch).toHaveBeenCalledWith("/id", expect.any(Object));
expect(win.history.pushState).toHaveBeenCalledWith(
expect.objectContaining({ __N: true, options: expect.objectContaining({ locale: "id" }) }),
"",
"/id",
);
expect(win.location.href).toBe("http://localhost/id");
expect(win.__VINEXT_LOCALE__).toBe("id");
} finally {
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
}
});
it("keeps a locale-prefixed current path locale for root Link navigations", async () => {
// Ported from Next.js:
// test/e2e/i18n-preferred-locale-detection/i18n-preferred-locale-detection.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/i18n-preferred-locale-detection/i18n-preferred-locale-detection.test.ts
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const { win } = createNavWindow();
const pageModuleUrl = fixtureModuleUrl("fixtures/client-navigation-page.tsx");
win.location.pathname = "/id/new";
win.location.href = "http://localhost/id/new";
Object.assign(win, {
__VINEXT_LOCALE__: "id",
__VINEXT_LOCALES__: ["en", "id"],
__VINEXT_DEFAULT_LOCALE__: "en",
});
(globalThis as any).window = win;
const fetch = vi.fn(
async () =>
new Response(
buildNavHtml(
"/",
pageModuleUrl,
{},
{
locale: "id",
locales: ["en", "id"],
defaultLocale: "en",
},
),
{ status: 200 },
),
);
globalThis.fetch = fetch;
try {
vi.resetModules();
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
const result = await Router.push("/");
expect(result).toBe(true);
expect(fetch).toHaveBeenCalledWith("/id", expect.any(Object));
expect(win.history.pushState).toHaveBeenCalledWith(
expect.objectContaining({ __N: true }),
"",
"/id",
);
expect(win.location.href).toBe("http://localhost/id");
expect(win.__VINEXT_LOCALE__).toBe("id");
} finally {
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
}
});
it("popstate fetches default-locale root through a locale-qualified URL", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const originalCustomEvent = globalThis.CustomEvent;
const listeners = new Map<string, (event: any) => void>();
const { win } = createNavWindow();
const pageModuleUrl = fixtureModuleUrl("fixtures/client-navigation-page.tsx");
win.location.pathname = "/about";
win.location.href = "http://localhost/about";
Object.assign(win, {
__VINEXT_LOCALE__: "en",
__VINEXT_LOCALES__: ["en", "id"],
__VINEXT_DEFAULT_LOCALE__: "en",
});
win.addEventListener = vi.fn((type: string, handler: (event: any) => void) => {
listeners.set(type, handler);
});
(globalThis as any).window = win;
(globalThis as any).CustomEvent = class CustomEventMock {
constructor(public type: string) {}
} as any;
const fetch = vi.fn(
async () =>
new Response(
buildNavHtml(
"/",
pageModuleUrl,
{},
{
locale: "en",
locales: ["en", "id"],
defaultLocale: "en",
},
),
{ status: 200 },
),
);
globalThis.fetch = fetch;
try {
vi.resetModules();
await import("../packages/vinext/src/shims/router.js");
const { installPagesRouterRuntime } =
await import("../packages/vinext/src/shims/pages-router-runtime.js");
installPagesRouterRuntime();
const popstateHandler = listeners.get("popstate");
expect(popstateHandler).toBeDefined();
win.location.pathname = "/";
win.location.href = "http://localhost/";
popstateHandler!({ state: null });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(fetch).toHaveBeenCalledWith("/en", expect.any(Object));
expect(win.location.href).toBe("http://localhost/");
expect(win.__VINEXT_LOCALE__).toBe("en");
} finally {
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
(globalThis as any).CustomEvent = originalCustomEvent;
}
});
// Without experimental.scrollRestoration, popstate must still restore the
// per-entry scroll saved into history state on push (__vinext_scrollX/Y):
// a soft popstate re-renders content the browser's native "auto" scroll
// restoration can't position correctly.
it("popstate restores history-state scroll when manual scroll restoration is off", async () => {
const previousWindow = (globalThis as any).window;
const previousDocument = (globalThis as any).document;
const originalFetch = globalThis.fetch;
const originalCustomEvent = globalThis.CustomEvent;
const listeners = new Map<string, (event: any) => void>();
const { win, render } = createNavWindow();
const pageModuleUrl = fixtureModuleUrl("fixtures/client-navigation-page.tsx");
win.scrollTo.mockImplementation((x: number, y: number) => {
win.scrollX = x;
win.scrollY = y;
});
win.addEventListener = vi.fn((type: string, handler: (event: any) => void) => {
listeners.set(type, handler);
});
(globalThis as any).window = win;
(globalThis as any).document = {
documentElement: {
dataset: {},
style: {},
getClientRects: vi.fn(),
},
};
(globalThis as any).CustomEvent = class CustomEventMock {
constructor(public type: string) {}
} as any;
globalThis.fetch = vi.fn(
async () => new Response(buildNavHtml("/other", pageModuleUrl), { status: 200 }),
);
try {
vi.resetModules();
await import("../packages/vinext/src/shims/router.js");
const { installPagesRouterRuntime } =
await import("../packages/vinext/src/shims/pages-router-runtime.js");
installPagesRouterRuntime();
const popstateHandler = listeners.get("popstate");
expect(popstateHandler).toBeDefined();
const routeChangeComplete = new Promise<void>((resolve, reject) => {
(win as any).next.router.events.on("routeChangeComplete", () => resolve());
(win as any).next.router.events.on("routeChangeError", (err: unknown) => reject(err));
});
win.location.pathname = "/other";
win.location.href = "http://localhost/other";
// The router-owned state shape saveScrollPosition() writes on push:
// the Next.js state stamp plus the merged __vinext_scrollX/Y fields.
popstateHandler!({
state: {
__N: true,
url: "/other",
as: "/other",
options: {},
__vinext_scrollX: 12,
__vinext_scrollY: 345,
},
});
// The scroll target is applied inside the render-commit callback; the
// mocked root.render never mounts React, so fire the commit manually.
await vi.waitFor(() => expect(render).toHaveBeenCalled());
const committed = render.mock.calls.at(-1)![0] as { props: { onCommit: () => void } };
committed.props.onCommit();
await routeChangeComplete;
expect(win.scrollTo).toHaveBeenCalledWith(12, 345);
} finally {
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
if (previousDocument === undefined) {
delete (globalThis as any).document;
} else {
(globalThis as any).document = previousDocument;
}
globalThis.fetch = originalFetch;
(globalThis as any).CustomEvent = originalCustomEvent;
}
});
// When beforePopState cancels a popstate the app stays on the previous
// history entry, so the internal current-key tracker must keep pointing at
// that entry — otherwise later scroll bookkeeping saves the outgoing
// position under the wrong (never-visited) key. Observable via the next
// popstate's sessionStorage scroll save, which is keyed off the tracker.
it("beforePopState cancellation leaves the current history key unchanged", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const originalScrollRestorationEnv = process.env.__NEXT_SCROLL_RESTORATION;
const listeners = new Map<string, (event: any) => void>();
const { win } = createNavWindow();
win.addEventListener = vi.fn((type: string, handler: (event: any) => void) => {
listeners.set(type, handler);
});
// Enable the manual scroll restoration path: the flag also requires
// history.scrollRestoration support and a working sessionStorage.
(win.history as any).scrollRestoration = "auto";
const sessionStore = new Map<string, string>();
(win as any).sessionStorage = {
getItem: (key: string) => sessionStore.get(key) ?? null,
setItem: (key: string, value: string) => void sessionStore.set(key, value),
removeItem: (key: string) => void sessionStore.delete(key),
};
// Pre-stamped router-owned entry so install picks up its key.
(win.history as any).state = {
__N: true,
url: "/",
as: "/",
options: {},
key: "key-initial",
};
(globalThis as any).window = win;
globalThis.fetch = vi.fn();
process.env.__NEXT_SCROLL_RESTORATION = "true";
try {
vi.resetModules();
await import("../packages/vinext/src/shims/router.js");
const { installPagesRouterRuntime } =
await import("../packages/vinext/src/shims/pages-router-runtime.js");
installPagesRouterRuntime();
const popstateHandler = listeners.get("popstate");
expect(popstateHandler).toBeDefined();
const beforePopState = vi.fn(() => false);
(win as any).next.router.beforePopState(beforePopState);
// Back/forward to another entry, cancelled by beforePopState.
win.location.pathname = "/other";
win.location.href = "http://localhost/other";
popstateHandler!({
state: { __N: true, url: "/other", as: "/other", options: {}, key: "key-target" },
});
expect(beforePopState).toHaveBeenCalledTimes(1);
expect(globalThis.fetch).not.toHaveBeenCalled();
// A later popstate snapshots the outgoing scroll position under the
// tracked current key. The app never left the initial entry, so the
// snapshot must land on key-initial — not the cancelled key-target.
win.scrollX = 120;
win.scrollY = 450;
win.location.pathname = "/third";
win.location.href = "http://localhost/third";
popstateHandler!({
state: { __N: true, url: "/third", as: "/third", options: {}, key: "key-other" },
});
expect(sessionStore.get("__next_scroll_key-initial")).toBe(
JSON.stringify({ x: 120, y: 450 }),
);
expect(sessionStore.has("__next_scroll_key-target")).toBe(false);
} finally {
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
if (originalScrollRestorationEnv === undefined) {
delete process.env.__NEXT_SCROLL_RESTORATION;
} else {
process.env.__NEXT_SCROLL_RESTORATION = originalScrollRestorationEnv;
}
}
});
it("beforePopState receives the stored href and as values for masked history entries", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const listeners = new Map<string, (event: any) => void>();
const { win } = createNavWindow();
win.addEventListener = vi.fn((type: string, handler: (event: any) => void) => {
listeners.set(type, handler);
});
(globalThis as any).window = win;
globalThis.fetch = vi.fn();
try {
vi.resetModules();
await import("../packages/vinext/src/shims/router.js");
const { installPagesRouterRuntime } =
await import("../packages/vinext/src/shims/pages-router-runtime.js");
installPagesRouterRuntime();
const popstateHandler = listeners.get("popstate");
expect(popstateHandler).toBeDefined();
const beforePopState = vi.fn(() => false);
(win as any).next.router.beforePopState(beforePopState);
win.location.pathname = "/hello";
win.location.href = "http://localhost/hello";
popstateHandler!({
state: {
__N: true,
url: "/something-else",
as: "/hello",
options: { shallow: true, locale: "en" },
key: "key-masked",
},
});
expect(beforePopState).toHaveBeenCalledWith({
url: "/something-else",
as: "/hello",
options: { shallow: true, locale: "en" },
});
expect(globalThis.fetch).not.toHaveBeenCalled();
} finally {
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
}
});
// A hash-only back/forward under manual scroll restoration honors only the
// hash anchor: the target entry's `__next_scroll_<key>` snapshot is read but
// intentionally not applied, matching Next.js where change()'s
// onlyAHashChange branch passes `null` to this.set() and only scrollToHash
// runs (forcedScroll feeds the full-navigation path exclusively). The
// departed entry's position is still snapshotted, and the unconsumed target
// snapshot stays in sessionStorage for a later non-hash popstate.
it("hash-only popstate scrolls to the hash anchor and leaves the entry snapshot for later", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const originalScrollRestorationEnv = process.env.__NEXT_SCROLL_RESTORATION;
const listeners = new Map<string, (event: any) => void>();
const { win, pushState } = createNavWindow();
win.addEventListener = vi.fn((type: string, handler: (event: any) => void) => {
listeners.set(type, handler);
});
// Enable the manual scroll restoration path: the flag also requires
// history.scrollRestoration support and a working sessionStorage.
(win.history as any).scrollRestoration = "auto";
const sessionStore = new Map<string, string>();
(win as any).sessionStorage = {
getItem: (key: string) => sessionStore.get(key) ?? null,
setItem: (key: string, value: string) => void sessionStore.set(key, value),
removeItem: (key: string) => void sessionStore.delete(key),
};
// Pre-stamped router-owned entry so install picks up its key.
(win.history as any).state = {
__N: true,
url: "/",
as: "/",
options: {},
key: "key-initial",
};
(globalThis as any).window = win;
globalThis.fetch = vi.fn();
process.env.__NEXT_SCROLL_RESTORATION = "true";
try {
vi.resetModules();
await import("../packages/vinext/src/shims/router.js");
const { installPagesRouterRuntime } =
await import("../packages/vinext/src/shims/pages-router-runtime.js");
installPagesRouterRuntime();
// Positive control: a Pages Router document with the flag enabled
// switches the browser to manual restoration at install time.
expect((win.history as any).scrollRestoration).toBe("manual");
const popstateHandler = listeners.get("popstate");
expect(popstateHandler).toBeDefined();
// The position the user reached on the initial entry; the hash-only
// push below snapshots it under key-initial on departure.
win.scrollX = 5;
win.scrollY = 500;
// Hash-only push mints a new history entry ("#top" avoids the
// document-dependent getElementById branch of scrollToHashTarget).
await (win as any).next.router.push("/#top");
expect(sessionStore.get("__next_scroll_key-initial")).toBe(JSON.stringify({ x: 5, y: 500 }));
const pushedKey = pushState.mock.calls.at(-1)![0].key as string;
expect(typeof pushedKey).toBe("string");
const hashEvents: string[] = [];
(win as any).next.router.events.on("hashChangeStart", () =>
hashEvents.push("hashChangeStart"),
);
(win as any).next.router.events.on("hashChangeComplete", () =>
hashEvents.push("hashChangeComplete"),
);
// The live position on the hash entry, snapshotted on departure.
win.scrollX = 7;
win.scrollY = 70;
win.scrollTo.mockClear();
// Back to the initial entry: same pathname+search, only the hash
// differs, so this is a hash-only popstate.
win.location.hash = "";
win.location.pathname = "/";
win.location.href = "http://localhost/";
popstateHandler!({
state: { __N: true, url: "/", as: "/", options: {}, key: "key-initial" },
});
// Hash-only: no page fetch, hash events fired in order.
expect(globalThis.fetch).not.toHaveBeenCalled();
expect(hashEvents).toEqual(["hashChangeStart", "hashChangeComplete"]);
// Only the hash anchor is honored (empty hash scrolls to top); the
// target entry's saved {x: 5, y: 500} is not applied (upstream parity).
expect(win.scrollTo).toHaveBeenCalledWith(0, 0);
expect(win.scrollTo).not.toHaveBeenCalledWith(5, 500);
// The departed hash entry's live position was snapshotted under its
// key, and the unconsumed target snapshot survives for a later
// non-hash popstate to this entry.
expect(sessionStore.get(`__next_scroll_${pushedKey}`)).toBe(JSON.stringify({ x: 7, y: 70 }));
expect(sessionStore.get("__next_scroll_key-initial")).toBe(JSON.stringify({ x: 5, y: 500 }));
} finally {
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
if (originalScrollRestorationEnv === undefined) {
delete process.env.__NEXT_SCROLL_RESTORATION;
} else {
process.env.__NEXT_SCROLL_RESTORATION = originalScrollRestorationEnv;
}
}
});
// A hash-only push must snapshot the outgoing entry's scroll under its key
// before updateHistory mints the new entry's key — otherwise a later
// back-popstate to the departed entry restores {x: 0, y: 0} instead of the
// position the user had reached there. Upstream snapshots in Router.push()
// itself, ahead of change()'s onlyAHashChange short-circuit, so hash-only
// pushes still write `__next_scroll_<key>` for the departed entry.
it("hash-only push snapshots the outgoing entry's scroll under the old key", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const originalScrollRestorationEnv = process.env.__NEXT_SCROLL_RESTORATION;
const { win, pushState } = createNavWindow();
// Enable the manual scroll restoration path: the flag also requires
// history.scrollRestoration support and a working sessionStorage.
(win.history as any).scrollRestoration = "auto";
const sessionStore = new Map<string, string>();
(win as any).sessionStorage = {
getItem: (key: string) => sessionStore.get(key) ?? null,
setItem: (key: string, value: string) => void sessionStore.set(key, value),
removeItem: (key: string) => void sessionStore.delete(key),
};
// Pre-stamped router-owned entry so install picks up its key.
(win.history as any).state = {
__N: true,
url: "/",
as: "/",
options: {},
key: "key-initial",
};
(globalThis as any).window = win;
globalThis.fetch = vi.fn();
process.env.__NEXT_SCROLL_RESTORATION = "true";
try {
vi.resetModules();
await import("../packages/vinext/src/shims/router.js");
const { installPagesRouterRuntime } =
await import("../packages/vinext/src/shims/pages-router-runtime.js");
installPagesRouterRuntime();
// The live position on the outgoing entry at push time ("#top" avoids
// the document-dependent getElementById branch of scrollToHashTarget).
win.scrollX = 30;
win.scrollY = 300;
await (win as any).next.router.push("/#top");
// Hash-only: no page fetch; a new entry was minted with a fresh key.
expect(globalThis.fetch).not.toHaveBeenCalled();
const pushedKey = pushState.mock.calls.at(-1)![0].key as string;
expect(typeof pushedKey).toBe("string");
expect(pushedKey).not.toBe("key-initial");
// The departed entry's position was snapshotted under its own key —
// not the freshly minted one — so a later back-popstate restores it.
expect(sessionStore.get("__next_scroll_key-initial")).toBe(JSON.stringify({ x: 30, y: 300 }));
expect(sessionStore.has(`__next_scroll_${pushedKey}`)).toBe(false);
} finally {
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
if (originalScrollRestorationEnv === undefined) {
delete process.env.__NEXT_SCROLL_RESTORATION;
} else {
process.env.__NEXT_SCROLL_RESTORATION = originalScrollRestorationEnv;
}
}
});
// The experimental.scrollRestoration manual opt-in is Pages-Router-only:
// an App Router document (window.next.appDir === true, stamped by the App
// Router bootstrap before any client-component module evaluates) that
// value-imports next/router for compat must not have the browser's native
// restoration disabled. Upstream only flips history.scrollRestoration in
// the Pages Router constructor, which never runs on App Router documents.
//
// This test guards the bootstrap eval-order invariant: the
// manualScrollRestoration constant in router.ts reads window.next?.appDir
// at module-eval time, which is only correct because installWindowNext in
// app-browser-entry.ts stamps appDir: true before the first next/router
// value-import evaluates. The failure mode is silent — if that ordering
// regressed, manual scroll restoration would flip on under App Router
// documents with no error, only subtly broken back/forward scroll.
it("does not flip history.scrollRestoration to manual on App Router documents", async () => {
const previousWindow = (globalThis as any).window;
const originalScrollRestorationEnv = process.env.__NEXT_SCROLL_RESTORATION;
const { win } = createNavWindow();
(win.history as any).scrollRestoration = "auto";
const sessionStore = new Map<string, string>();
(win as any).sessionStorage = {
getItem: (key: string) => sessionStore.get(key) ?? null,
setItem: (key: string, value: string) => void sessionStore.set(key, value),
removeItem: (key: string) => void sessionStore.delete(key),
};
// The App Router bootstrap stamp, in place before next/router evaluates.
(win as any).next = { version: "test", appDir: true };
(globalThis as any).window = win;
process.env.__NEXT_SCROLL_RESTORATION = "true";
try {
vi.resetModules();
await import("../packages/vinext/src/shims/router.js");
const { installPagesRouterRuntime } =
await import("../packages/vinext/src/shims/pages-router-runtime.js");
installPagesRouterRuntime();
expect((win.history as any).scrollRestoration).toBe("auto");
} finally {
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
if (originalScrollRestorationEnv === undefined) {
delete process.env.__NEXT_SCROLL_RESTORATION;
} else {
process.env.__NEXT_SCROLL_RESTORATION = originalScrollRestorationEnv;
}
}
});
it("does not prefix a locale-qualified target with the current locale", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const { win } = createNavWindow();
const pageModuleUrl = fixtureModuleUrl("fixtures/client-navigation-page.tsx");
Object.assign(win, {
__VINEXT_LOCALE__: "id",
__VINEXT_LOCALES__: ["en", "id", "fr"],
__VINEXT_DEFAULT_LOCALE__: "en",
});
(globalThis as any).window = win;
const fetch = vi.fn(
async () =>
new Response(
buildNavHtml(
"/about",
pageModuleUrl,
{},
{
locale: "fr",
locales: ["en", "id", "fr"],
defaultLocale: "en",
},
),
{ status: 200 },
),
);
globalThis.fetch = fetch;
try {
vi.resetModules();
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
const result = await Router.push("/fr/about");
expect(result).toBe(true);
expect(fetch).toHaveBeenCalledWith("/fr/about", expect.any(Object));
// Next.js-shaped history state — assert partially.
expect(win.history.pushState).toHaveBeenCalledWith(
expect.objectContaining({ __N: true }),
"",
"/fr/about",
);
expect(win.location.href).toBe("http://localhost/fr/about");
expect(win.__VINEXT_LOCALE__).toBe("fr");
} finally {
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
}
});
it("handles Pages Router middleware internal redirects as client-side redirects", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const { win, replaceState, render } = createNavWindow();
const pageModuleUrl = fixtureModuleUrl("fixtures/client-navigation-page.tsx");
Object.assign(win.location, { origin: "http://localhost" });
Object.assign(win, {
__VINEXT_LOCALE__: "en",
__VINEXT_LOCALES__: ["en", "fr", "nl", "es"],
__VINEXT_DEFAULT_LOCALE__: "en",
});
Object.assign(win.__NEXT_DATA__, {
buildId: "build-1",
__vinext: { ...win.__NEXT_DATA__.__vinext, hasMiddleware: true },
});
Object.assign(win, {
__VINEXT_PAGE_LOADERS__: {
"/new-home": async () => import(pageModuleUrl),
},
});
(globalThis as any).window = win;
const fetch = vi.fn(async (url: RequestInfo | URL) => {
const href = getFetchHref(url);
if (href === "/_next/data/build-1/old-home.json") {
return new Response("{}", {
headers: { "x-nextjs-redirect": "/new-home" },
status: 200,
});
}
if (href === "/new-home") {
return new Response(buildNavHtmlWithVinext("/new-home", { hasMiddleware: true }));
}
throw new Error(`Unexpected fetch: ${href}`);
});
globalThis.fetch = fetch;
try {
vi.resetModules();
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
const result = await Router.push("/old-home");
expect(result).toBe(true);
expect(fetch).toHaveBeenNthCalledWith(
1,
"/_next/data/build-1/old-home.json",
expect.objectContaining({
headers: expect.objectContaining({ "x-nextjs-data": "1" }),
}),
);
expect(fetch).toHaveBeenNthCalledWith(2, "/new-home", expect.any(Object));
expect(replaceState).toHaveBeenLastCalledWith({}, "", "/new-home");
expect(win.location.pathname).toBe("/new-home");
expect(render).toHaveBeenCalled();
} finally {
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
}
});
it("hydrates middleware rewrites to fallback pages from the data response", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const { win, render } = createNavWindow();
const pageModuleUrl = fixtureModuleUrl("fixtures/client-navigation-page.tsx");
Object.assign(win.location, { origin: "http://localhost" });
Object.assign(win.__NEXT_DATA__, {
buildId: "build-1",
__vinext: { ...win.__NEXT_DATA__.__vinext, hasMiddleware: true },
});
Object.assign(win, {
__VINEXT_PAGE_PATTERNS__: ["/fallback-true-blog/[slug]"],
__VINEXT_PAGE_LOADERS__: {
"/fallback-true-blog/[slug]": async () => import(pageModuleUrl),
},
});
(globalThis as any).window = win;
const fetch = vi.fn(async (url: RequestInfo | URL) => {
const href = getFetchHref(url);
if (href === "/_next/data/build-1/to-blog/another-post.json") {
return new Response(
JSON.stringify({ pageProps: { slug: "another-post", generated: true } }),
{
headers: {
"content-type": "application/json",
"x-nextjs-rewrite": "/fallback-true-blog/another-post",
},
},
);
}
throw new Error(`Unexpected fetch: ${href}`);
});
globalThis.fetch = fetch;
try {
vi.resetModules();
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
const result = await Router.push("/to-blog/another-post");
expect(result).toBe(true);
expect(fetch).toHaveBeenCalledOnce();
expect(win.history.pushState).toHaveBeenLastCalledWith(
expect.objectContaining({ __N: true }),
"",
"/to-blog/another-post",
);
expect(win.location.pathname).toBe("/to-blog/another-post");
expect(win.__NEXT_DATA__).toMatchObject({
page: "/fallback-true-blog/[slug]",
query: { slug: "another-post" },
props: { pageProps: { slug: "another-post", generated: true } },
isFallback: false,
});
expect(render).toHaveBeenCalled();
} finally {
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
}
});
it("uses the rewritten page loader when the visible URL also matches a page", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const { win, render } = createNavWindow();
const sourceLoader = vi.fn(async () => ({ default: () => null }));
const destinationLoader = vi.fn(async () => ({ default: () => null }));
Object.assign(win.location, { origin: "http://localhost" });
Object.assign(win.__NEXT_DATA__, {
buildId: "build-1",
__vinext: { ...win.__NEXT_DATA__.__vinext, hasMiddleware: true },
});
Object.assign(win, {
__VINEXT_PAGE_PATTERNS__: ["/source/[slug]", "/destination"],
__VINEXT_PAGE_LOADERS__: {
"/source/[slug]": sourceLoader,
"/destination": destinationLoader,
},
});
(globalThis as any).window = win;
const fetch = vi.fn(
async () =>
new Response(JSON.stringify({ pageProps: { slug: "post", rewritten: true } }), {
headers: {
"content-type": "application/json",
"x-nextjs-rewrite": "/destination?rewriteSlug=post",
},
}),
);
globalThis.fetch = fetch;
try {
vi.resetModules();
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
const result = await Router.push("/source/post");
expect(result).toBe(true);
expect(fetch).toHaveBeenCalledOnce();
expect(sourceLoader).not.toHaveBeenCalled();
expect(destinationLoader).toHaveBeenCalledOnce();
expect(win.location.pathname).toBe("/source/post");
expect(win.__NEXT_DATA__).toMatchObject({
page: "/destination",
query: { rewriteSlug: "post" },
props: { pageProps: { slug: "post", rewritten: true } },
});
expect(Router.query).toEqual({ rewriteSlug: "post" });
expect(render).toHaveBeenCalled();
} finally {
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
}
});
it("does not double-prefix basePath for middleware data redirects", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const previousBasePath = process.env.__NEXT_ROUTER_BASEPATH;
const { win, replaceState, render } = createNavWindow();
const pageModuleUrl = fixtureModuleUrl("fixtures/client-navigation-page.tsx");
Object.assign(win.location, {
origin: "http://localhost",
pathname: "/docs",
href: "http://localhost/docs",
});
Object.assign(win.__NEXT_DATA__, {
buildId: "build-1",
__vinext: { ...win.__NEXT_DATA__.__vinext, hasMiddleware: true },
});
Object.assign(win, {
__VINEXT_PAGE_LOADERS__: {
"/new-home": async () => import(pageModuleUrl),
},
});
process.env.__NEXT_ROUTER_BASEPATH = "/docs";
(globalThis as any).window = win;
const fetch = vi.fn(async (url: RequestInfo | URL) => {
const href = getFetchHref(url);
if (href === "/docs/_next/data/build-1/old-home.json") {
return new Response("{}", {
headers: { "x-nextjs-redirect": "/docs/new-home" },
status: 200,
});
}
if (href === "/docs/new-home") {
return new Response(buildNavHtmlWithVinext("/new-home", { hasMiddleware: true }));
}
throw new Error(`Unexpected fetch: ${href}`);
});
globalThis.fetch = fetch;
try {
vi.resetModules();
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
const result = await Router.push("/old-home");
expect(result).toBe(true);
expect(fetch).toHaveBeenNthCalledWith(
1,
"/docs/_next/data/build-1/old-home.json",
expect.objectContaining({
headers: expect.objectContaining({ "x-nextjs-data": "1" }),
}),
);
expect(fetch).toHaveBeenNthCalledWith(2, "/docs/new-home", expect.any(Object));
expect(fetch).not.toHaveBeenCalledWith("/docs/docs/new-home", expect.any(Object));
expect(replaceState).toHaveBeenLastCalledWith({}, "", "/docs/new-home");
expect(win.location.href).toBe("http://localhost/docs/new-home");
expect(render).toHaveBeenCalled();
} finally {
vi.resetModules();
if (previousBasePath === undefined) {
delete process.env.__NEXT_ROUTER_BASEPATH;
} else {
process.env.__NEXT_ROUTER_BASEPATH = previousBasePath;
}
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
}
});
it("falls through to normal page navigation when the middleware data probe fails", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const { win, render } = createNavWindow();
const pageModuleUrl = fixtureModuleUrl("fixtures/client-navigation-page.tsx");
Object.assign(win.location, { origin: "http://localhost" });
Object.assign(win.__NEXT_DATA__, {
buildId: "build-1",
__vinext: { ...win.__NEXT_DATA__.__vinext, hasMiddleware: true },
});
(globalThis as any).window = win;
const fetch = vi.fn(async (url: RequestInfo | URL) => {
const href = getFetchHref(url);
if (href === "/_next/data/build-1/old-home.json") {
throw new TypeError("probe failed");
}
if (href === "/old-home") {
return new Response(buildNavHtml("/old-home", pageModuleUrl));
}
throw new Error(`Unexpected fetch: ${href}`);
});
globalThis.fetch = fetch;
try {
vi.resetModules();
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
const result = await Router.push("/old-home");
expect(result).toBe(true);
expect(fetch).toHaveBeenNthCalledWith(
1,
"/_next/data/build-1/old-home.json",
expect.objectContaining({
headers: expect.objectContaining({ "x-nextjs-data": "1" }),
}),
);
expect(fetch).toHaveBeenNthCalledWith(2, "/old-home", expect.any(Object));
expect(win.location.href).toBe("http://localhost/old-home");
expect(render).toHaveBeenCalled();
} finally {
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
}
});
it("hard-navigates to the final middleware redirect URL when it is not a page", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const { win } = createNavWindow();
const hrefAssignments = trackHrefAssignments(win);
Object.assign(win.location, { origin: "http://localhost" });
Object.assign(win, {
__VINEXT_LOCALE__: "en",
__VINEXT_LOCALES__: ["en", "fr", "nl", "es"],
__VINEXT_DEFAULT_LOCALE__: "en",
});
Object.assign(win.__NEXT_DATA__, {
buildId: "build-1",
__vinext: { ...win.__NEXT_DATA__.__vinext, hasMiddleware: true },
});
(globalThis as any).window = win;
const fetch = vi.fn(async (url: RequestInfo | URL) => {
const href = getFetchHref(url);
if (href === "/_next/data/build-1/nl/to.json?pathname=/api/ok") {
return new Response("{}", {
headers: { "x-nextjs-redirect": "/nl/api/ok" },
status: 200,
});
}
if (href === "/api/ok") return new Response("ok", { status: 200 });
throw new Error(`Unexpected fetch: ${href}`);
});
globalThis.fetch = fetch;
try {
vi.resetModules();
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
const result = await Router.push("/to?pathname=/api/ok", undefined, { locale: "nl" });
expect(result).toBe(false);
expect(fetch).toHaveBeenNthCalledWith(
1,
"/_next/data/build-1/nl/to.json?pathname=/api/ok",
expect.objectContaining({
headers: expect.objectContaining({ "x-nextjs-data": "1" }),
}),
);
expect(hrefAssignments).toContain("http://localhost/nl/to?pathname=/api/ok");
expect(hrefAssignments).toContain("/api/ok");
expect(hrefAssignments).not.toContain("/nl/api/ok");
} finally {
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
}
});
async function expectBasePathHashOnlyPush({
browserPath,
target,
expectedBrowserUrl,
expectedEventUrl,
}: {
browserPath: string;
target: string;
expectedBrowserUrl: string;
expectedEventUrl: string;
}) {
const previousWindow = (globalThis as any).window;
const previousDocument = (globalThis as any).document;
const originalFetch = globalThis.fetch;
const previousBasePath = process.env.__NEXT_ROUTER_BASEPATH;
const { win } = createNavWindow();
win.location.pathname = browserPath;
win.location.href = `http://localhost${browserPath}`;
(globalThis as any).window = win;
(globalThis as any).document = {
getElementById: vi.fn(() => ({ scrollIntoView: vi.fn() })),
};
process.env.__NEXT_ROUTER_BASEPATH = "/app";
vi.resetModules();
const fetch = vi.fn(async () => {
throw new Error("hash-only navigations must not fetch page HTML");
});
globalThis.fetch = fetch;
const hashEvents: string[] = [];
const routeEvents: string[] = [];
const onHashChangeStart = (...args: unknown[]) => {
hashEvents.push(`start:${String(args[0])}`);
};
const onHashChangeComplete = (...args: unknown[]) => {
hashEvents.push(`complete:${String(args[0])}`);
};
const onRouteChangeStart = (...args: unknown[]) => {
routeEvents.push(`start:${String(args[0])}`);
};
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
Router.events.on("hashChangeStart", onHashChangeStart);
Router.events.on("hashChangeComplete", onHashChangeComplete);
Router.events.on("routeChangeStart", onRouteChangeStart);
const result = await Router.push(target);
expect(result).toBe(true);
expect(fetch).not.toHaveBeenCalled();
// Hash-only push now records Next.js-shaped history state too.
expect(win.history.pushState).toHaveBeenCalledWith(
expect.objectContaining({ __N: true }),
"",
expectedBrowserUrl,
);
expect(hashEvents).toEqual([`start:${expectedEventUrl}`, `complete:${expectedEventUrl}`]);
expect(routeEvents).toEqual([]);
} finally {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
Router.events.off("hashChangeStart", onHashChangeStart);
Router.events.off("hashChangeComplete", onHashChangeComplete);
Router.events.off("routeChangeStart", onRouteChangeStart);
if (previousBasePath === undefined) {
delete process.env.__NEXT_ROUTER_BASEPATH;
} else {
process.env.__NEXT_ROUTER_BASEPATH = previousBasePath;
}
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
if (previousDocument === undefined) {
delete (globalThis as any).document;
} else {
(globalThis as any).document = previousDocument;
}
globalThis.fetch = originalFetch;
}
}
it("treats app-relative hash navigations as hash-only when basePath is configured", async () => {
await expectBasePathHashOnlyPush({
browserPath: "/app/router-events-test",
target: "/router-events-test#section-1",
expectedBrowserUrl: "/app/router-events-test#section-1",
expectedEventUrl: "/router-events-test#section-1",
});
});
it("scrolls hash-only pushes to URI-decoded id targets", async () => {
const previousWindow = (globalThis as any).window;
const previousDocument = (globalThis as any).document;
const originalFetch = globalThis.fetch;
const { win } = createNavWindow();
(globalThis as any).window = win;
const target = { scrollIntoView: vi.fn() };
const getElementById = vi.fn((id: string) => (id === "hello world" ? target : null));
const getElementsByName = vi.fn(() => []);
(globalThis as any).document = { getElementById, getElementsByName };
globalThis.fetch = vi.fn(async () => {
throw new Error("hash-only navigations must not fetch page HTML");
});
vi.resetModules();
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
const result = await Router.push("#hello%20world");
expect(result).toBe(true);
expect(getElementById).toHaveBeenCalledWith("hello world");
expect(getElementsByName).not.toHaveBeenCalled();
expect(target.scrollIntoView).toHaveBeenCalledWith({ behavior: "auto" });
} finally {
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
if (previousDocument === undefined) {
delete (globalThis as any).document;
} else {
(globalThis as any).document = previousDocument;
}
globalThis.fetch = originalFetch;
}
});
it("scrolls hash-only pushes to named anchors when no id matches", async () => {
const previousWindow = (globalThis as any).window;
const previousDocument = (globalThis as any).document;
const originalFetch = globalThis.fetch;
const { win } = createNavWindow();
(globalThis as any).window = win;
const target = { scrollIntoView: vi.fn() };
const getElementById = vi.fn(() => null);
const getElementsByName = vi.fn((name: string) => (name === "legacy-anchor" ? [target] : []));
(globalThis as any).document = { getElementById, getElementsByName };
globalThis.fetch = vi.fn(async () => {
throw new Error("hash-only navigations must not fetch page HTML");
});
vi.resetModules();
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
const result = await Router.push("#legacy-anchor");
expect(result).toBe(true);
expect(getElementById).toHaveBeenCalledWith("legacy-anchor");
expect(getElementsByName).toHaveBeenCalledWith("legacy-anchor");
expect(target.scrollIntoView).toHaveBeenCalledWith({ behavior: "auto" });
} finally {
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
if (previousDocument === undefined) {
delete (globalThis as any).document;
} else {
(globalThis as any).document = previousDocument;
}
globalThis.fetch = originalFetch;
}
});
it("does not scroll hash-only pushes when scroll is false", async () => {
const previousWindow = (globalThis as any).window;
const previousDocument = (globalThis as any).document;
const originalFetch = globalThis.fetch;
const { win } = createNavWindow();
(globalThis as any).window = win;
const target = { scrollIntoView: vi.fn() };
const getElementById = vi.fn(() => target);
const getElementsByName = vi.fn(() => []);
(globalThis as any).document = { getElementById, getElementsByName };
globalThis.fetch = vi.fn(async () => {
throw new Error("hash-only navigations must not fetch page HTML");
});
vi.resetModules();
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
const result = await Router.push("#legacy-anchor", undefined, { scroll: false });
expect(result).toBe(true);
expect(getElementById).not.toHaveBeenCalled();
expect(getElementsByName).not.toHaveBeenCalled();
expect(target.scrollIntoView).not.toHaveBeenCalled();
} finally {
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
if (previousDocument === undefined) {
delete (globalThis as any).document;
} else {
(globalThis as any).document = previousDocument;
}
globalThis.fetch = originalFetch;
}
});
it("does not strip app-relative targets that start with the basePath segment", async () => {
await expectBasePathHashOnlyPush({
browserPath: "/app/app/foo",
target: "/app/foo#section-1",
expectedBrowserUrl: "/app/app/foo#section-1",
expectedEventUrl: "/app/foo#section-1",
});
});
it("popstate known failures schedule a single hard navigation", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const originalCustomEvent = globalThis.CustomEvent;
const listeners = new Map<string, (event: any) => void>();
const { win } = createNavWindow();
win.addEventListener = vi.fn((type: string, handler: (event: any) => void) => {
listeners.set(type, handler);
});
(globalThis as any).window = win;
(globalThis as any).CustomEvent = class CustomEventMock {
constructor(public type: string) {}
} as any;
globalThis.fetch = async (_url: any, _init: any) =>
new Response("Internal Server Error", { status: 500 });
try {
vi.resetModules();
await import("../packages/vinext/src/shims/router.js");
const { installPagesRouterRuntime } =
await import("../packages/vinext/src/shims/pages-router-runtime.js");
installPagesRouterRuntime();
const popstateHandler = listeners.get("popstate");
expect(popstateHandler).toBeDefined();
win.location.pathname = "/failing-page";
win.location.href = "http://localhost/failing-page";
// Install tracking after test setup so we only capture popstate-driven
// writes, not the setup assignment above.
const hrefAssignments = trackHrefAssignments(win);
popstateHandler!({ state: null });
// Cross a task boundary so the async popstate chain has fully settled.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(hrefAssignments.filter((value) => value === "/failing-page")).toHaveLength(1);
expect(hrefAssignments).toHaveLength(1);
} finally {
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
(globalThis as any).CustomEvent = originalCustomEvent;
}
});
it("replace() also cancels superseded navigation", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const { win } = createNavWindow();
(globalThis as any).window = win;
const fetchA = createDeferred<Response>();
const fetchB = createDeferred<Response>();
let fetchCount = 0;
globalThis.fetch = async (_url: any, _init: any) => {
fetchCount++;
if (fetchCount === 1) return fetchA.promise;
return fetchB.promise;
};
const completedUrls: string[] = [];
const errors: Array<{ err: unknown; url: string }> = [];
const onRouteChangeComplete = (...args: unknown[]) => {
completedUrls.push(String(args[0]));
};
const onRouteChangeError = (...args: unknown[]) => {
errors.push({ err: args[0], url: String(args[1]) });
};
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
Router.events.on("routeChangeComplete", onRouteChangeComplete);
Router.events.on("routeChangeError", onRouteChangeError);
// First push, then replace overlapping
const navA = Router.push("/page-a");
await Promise.resolve();
const navB = Router.replace("/page-b");
fetchB.resolve(new Response(buildNavHtml("/page-b", "/@fs/pages/page-b.js")));
fetchA.resolve(new Response(buildNavHtml("/page-a", "/@fs/pages/page-a.js")));
await Promise.allSettled([navA, navB]);
// The superseded push (page-a) should be cancelled, not completed
expect(completedUrls).not.toContain("/page-a");
// page-a should have a cancelled error
const cancelledA = errors.find((e) => e.url === "/page-a");
expect(cancelledA).toBeDefined();
expect(cancelledA?.err).toHaveProperty("cancelled", true);
} finally {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
Router.events.off("routeChangeComplete", onRouteChangeComplete);
Router.events.off("routeChangeError", onRouteChangeError);
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
}
});
it("abort signal fires when navigation is superseded — AbortError becomes NavigationCancelledError", async () => {
// Verify that the AbortController signal passed to fetch actually fires when a
// newer navigation starts, and that the resulting AbortError is converted into
// a NavigationCancelledError (the routeChangeError path, not a plain rejection).
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const { win } = createNavWindow();
(globalThis as any).window = win;
const fetchA = createDeferred<Response>();
const fetchB = createDeferred<Response>();
let fetchCount = 0;
// Signal-aware mock: the first fetch rejects with AbortError when its signal fires.
globalThis.fetch = async (_url: any, _init: any) => {
fetchCount++;
if (fetchCount === 1) {
return new Promise<Response>((resolve, reject) => {
fetchA.promise.then(resolve, reject);
_init?.signal?.addEventListener("abort", () => {
reject(new DOMException("The operation was aborted.", "AbortError"));
});
});
}
return fetchB.promise;
};
const errors: Array<{ err: unknown; url: string }> = [];
const onRouteChangeError = (...args: unknown[]) => {
errors.push({ err: args[0], url: String(args[1]) });
};
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
Router.events.on("routeChangeError", onRouteChangeError);
// Start navigation A then immediately supersede it with B
const navA = Router.push("/page-a");
await Promise.resolve();
const navB = Router.push("/page-b");
// Resolve B; A is aborted via its signal — no manual resolution needed
fetchB.resolve(new Response(buildNavHtml("/page-b", "/@fs/pages/page-b.js")));
await Promise.allSettled([navA, navB]);
// navA's fetch was aborted via signal → AbortError → NavigationCancelledError
const cancelledError = errors.find((e) => e.url === "/page-a");
expect(cancelledError).toBeDefined();
expect(cancelledError?.err).toHaveProperty("cancelled", true);
} finally {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
Router.events.off("routeChangeError", onRouteChangeError);
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
}
});
it("stale response arriving first does not render before the winning navigation", async () => {
// fetchA (stale, page-a) resolves before fetchB (winning, page-b).
// assertStillCurrent() in navigateClient must catch the stale navigation
// after it processes the response, so page-a's data never reaches the DOM.
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const { win } = createNavWindow();
(globalThis as any).window = win;
const fetchA = createDeferred<Response>();
const fetchB = createDeferred<Response>();
let fetchCount = 0;
globalThis.fetch = async (_url: any, _init: any) => {
fetchCount++;
if (fetchCount === 1) return fetchA.promise;
return fetchB.promise;
};
const completedUrls: string[] = [];
const onRouteChangeComplete = (...args: unknown[]) => {
completedUrls.push(String(args[0]));
};
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
Router.events.on("routeChangeComplete", onRouteChangeComplete);
// Start two navigations
const navA = Router.push("/page-a");
await Promise.resolve();
const navB = Router.push("/page-b");
// Stale fetch (A) resolves first this time
fetchA.resolve(new Response(buildNavHtml("/page-a", "/@fs/pages/page-a.js")));
// Winning fetch (B) resolves after
fetchB.resolve(new Response(buildNavHtml("/page-b", "/@fs/pages/page-b.js")));
await Promise.allSettled([navA, navB]);
// Stale navigation must not have committed its data to the DOM.
// B may also fail at dynamic import in test env, so we only verify A never wrote.
expect(win.__NEXT_DATA__.page).not.toBe("/page-a");
// Stale navigation must not fire routeChangeComplete
expect(completedUrls).not.toContain("/page-a");
} finally {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
Router.events.off("routeChangeComplete", onRouteChangeComplete);
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
}
});
it("__NEXT_DATA__ is not stale when routeChangeError fires for a cancelled navigation", async () => {
// Regression test: __NEXT_DATA__ must not reflect the cancelled route's data
// at the moment routeChangeError fires. The fix defers the global write until
// just before root.render(), after all assertStillCurrent() checks pass.
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const { win } = createNavWindow();
(globalThis as any).window = win;
const fetchA = createDeferred<Response>();
const fetchB = createDeferred<Response>();
let fetchCount = 0;
globalThis.fetch = async (_url: any, _init: any) => {
fetchCount++;
if (fetchCount === 1) return fetchA.promise;
return fetchB.promise;
};
// Track __NEXT_DATA__.page at the moment of each routeChangeError
const nextDataPageAtError: string[] = [];
const onRouteChangeError = () => {
nextDataPageAtError.push(win.__NEXT_DATA__.page);
};
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
Router.events.on("routeChangeError", onRouteChangeError);
// Start two navigations — A will be superseded by B
const navA = Router.push("/page-a");
await Promise.resolve();
const navB = Router.push("/page-b");
// Resolve stale (A) first, then winning (B)
fetchA.resolve(new Response(buildNavHtml("/page-a", "/@fs/pages/page-a.js")));
fetchB.resolve(new Response(buildNavHtml("/page-b", "/@fs/pages/page-b.js")));
await Promise.allSettled([navA, navB]);
// At the moment routeChangeError fired for nav A, __NEXT_DATA__ must NOT
// have been overwritten with page-a's data
for (const page of nextDataPageAtError) {
expect(page).not.toBe("/page-a");
}
} finally {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
Router.events.off("routeChangeError", onRouteChangeError);
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
globalThis.fetch = originalFetch;
}
});
// Ported from Next.js: test/e2e/middleware-dynamic-basepath-matcher-rewrites
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-dynamic-basepath-matcher-rewrites
// Issue #1196 — catch-all router.query must not be corrupted by basePath + rewrites + middleware.
it("initializes catch-all router.query from __NEXT_DATA__ with basePath", async () => {
const previousWindow = (globalThis as any).window;
const previousBasePath = process.env.__NEXT_ROUTER_BASEPATH;
const { win } = createNavWindow();
// Simulate being on a catch-all page under basePath
process.env.__NEXT_ROUTER_BASEPATH = "/docs";
win.location.pathname = "/docs/first";
win.location.href = "http://localhost/docs/first";
// Server-rendered __NEXT_DATA__ for the catch-all page
win.__NEXT_DATA__ = {
page: "/[...path]",
query: { path: ["first"] },
isFallback: false,
props: { pageProps: {} },
__vinext: { pageModuleUrl: "/@fs/pages/[...path].js" },
};
(globalThis as any).window = win;
vi.resetModules();
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const routerModule = await import("../packages/vinext/src/shims/router.js");
let capturedRouter: any;
function Probe() {
capturedRouter = routerModule.useRouter();
return React.createElement("span", null, "ok");
}
try {
renderToStaticMarkup(routerModule.wrapWithRouterContext(React.createElement(Probe)));
expect(capturedRouter.query.path).toEqual(["first"]);
expect(capturedRouter.pathname).toBe("/[...path]");
expect(capturedRouter.asPath).toBe("/first");
} finally {
if (previousBasePath === undefined) {
delete process.env.__NEXT_ROUTER_BASEPATH;
} else {
process.env.__NEXT_ROUTER_BASEPATH = previousBasePath;
}
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
vi.resetModules();
}
});
// Ported from Next.js: test/e2e/middleware-dynamic-basepath-matcher-rewrites
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-dynamic-basepath-matcher-rewrites
// Issue #1196 — during a client-side <Link>/Router.push() navigation on a
// catch-all route under basePath, router.query.<catchAll> must reflect the
// destination URL segments and not be corrupted by intermediate data-URL
// handling. In Next.js the corruption originated in getMiddlewareData where
// basePath wasn't stripped from the _next/data/... URL before the catch-all
// route regex matched against it. vinext's navigateClient reads __NEXT_DATA__
// directly from the response HTML rather than constructing _next/data URLs,
// so it doesn't have that architectural pattern, but the route-param
// extraction path that ultimately produces router.query must still strip
// basePath from the resolved pathname before matching the catch-all pattern.
// This test exercises Router.push() through performNavigation → pushState
// and verifies that router.query.path reflects the destination segments.
it("preserves catch-all router.query after client navigation with basePath", async () => {
const previousWindow = (globalThis as any).window;
const previousBasePath = process.env.__NEXT_ROUTER_BASEPATH;
const originalFetch = globalThis.fetch;
const { win } = createNavWindow();
// Simulate being on a catch-all page under basePath when navigation starts.
process.env.__NEXT_ROUTER_BASEPATH = "/docs";
win.location.pathname = "/docs/first";
win.location.href = "http://localhost/docs/first";
win.__NEXT_DATA__ = {
page: "/[...path]",
query: { path: ["first"] },
isFallback: false,
props: { pageProps: {} },
// The catch-all module file is referenced through a Vite-style path that
// passes isValidModulePath() (no ".." directory-traversal segments).
__vinext: { pageModuleUrl: "/@fs/pages/catchall.js" },
};
(globalThis as any).window = win;
vi.resetModules();
// Capture router state mid-navigation, after pushState has updated
// window.location but before navigateClient's dynamic import runs (which
// would fail in this test env). useRouter() reads from window.location
// and window.__NEXT_DATA__ at provider-mount time, so rendering a Probe
// inside the mocked fetch handler observes the post-pushState state —
// exactly the code path that #1196 corrupts in Next.js.
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const routerModule = await import("../packages/vinext/src/shims/router.js");
let capturedRouter: any;
function Probe() {
capturedRouter = routerModule.useRouter();
return React.createElement("span", null, "ok");
}
globalThis.fetch = async (_url: any, _init: any) => {
renderToStaticMarkup(routerModule.wrapWithRouterContext(React.createElement(Probe)));
// Return HTML containing the destination's __NEXT_DATA__. The dynamic
// import of the page module fails in this test env, which is fine — the
// assertion above has already captured router.query.
return new Response(
buildNavHtml("/[...path]", "/@fs/pages/catchall.js", { path: ["second"] }),
);
};
// Silence the expected routeChangeError from the page-module import failure.
const onRouteChangeError = vi.fn();
try {
const Router = routerModule.default;
Router.events.on("routeChangeError", onRouteChangeError);
// Router.push() takes an app-relative path; basePath is added internally
// by performNavigation → toBrowserNavigationHref before pushState.
await Router.push("/second");
// pushState has fired, so location.pathname is "/docs/second".
// stripBasePath() removes "/docs", and the catch-all pattern
// "/[...path]" from __NEXT_DATA__.page extracts { path: ["second"] }.
expect(capturedRouter.pathname).toBe("/[...path]");
expect(capturedRouter.asPath).toBe("/second");
expect(capturedRouter.query.path).toEqual(["second"]);
// Negative assertion mirroring #1196: query.path must NOT contain
// _next/data segments.
expect(capturedRouter.query.path).not.toContain("_next");
expect(capturedRouter.query.path).not.toContain("data");
} finally {
routerModule.default.events.off("routeChangeError", onRouteChangeError);
globalThis.fetch = originalFetch;
if (previousBasePath === undefined) {
delete process.env.__NEXT_ROUTER_BASEPATH;
} else {
process.env.__NEXT_ROUTER_BASEPATH = previousBasePath;
}
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
vi.resetModules();
}
});
});
describe("deprecated Router.on<Event> property bridge", () => {
// These tests verify that assigning a function to a deprecated property such
// as `Router.onRouteChangeComplete` causes that function to be invoked when
// the corresponding event fires via Router.events.emit(). The bridge is the
// code added in fix/with-router-deprecated-nav-events.
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
vi.resetModules();
});
it("Router.onRouteChangeComplete is called when routeChangeComplete fires", async () => {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
const calls: unknown[][] = [];
(Router as any).onRouteChangeComplete = (...args: unknown[]) => {
calls.push(args);
};
try {
Router.events.emit("routeChangeComplete", "/test-path", { shallow: false });
expect(calls).toHaveLength(1);
expect(calls[0][0]).toBe("/test-path");
} finally {
delete (Router as any).onRouteChangeComplete;
}
});
it("Router.onRouteChangeStart is called when routeChangeStart fires", async () => {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
const calls: unknown[][] = [];
(Router as any).onRouteChangeStart = (...args: unknown[]) => {
calls.push(args);
};
try {
Router.events.emit("routeChangeStart", "/start-path", { shallow: false });
expect(calls).toHaveLength(1);
expect(calls[0][0]).toBe("/start-path");
} finally {
delete (Router as any).onRouteChangeStart;
}
});
it("emitting an event with no on<Event> handler assigned does not throw", async () => {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
// Ensure no handler is set
delete (Router as any).onRouteChangeComplete;
expect(() => {
Router.events.emit("routeChangeComplete", "/no-handler", { shallow: false });
}).not.toThrow();
});
it("previously-cleared on<Event> handler is not called after removal", async () => {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
const calls: unknown[][] = [];
(Router as any).onRouteChangeComplete = (...args: unknown[]) => {
calls.push(args);
};
// Fire once — should be called
Router.events.emit("routeChangeComplete", "/first", { shallow: false });
expect(calls).toHaveLength(1);
// Clear the handler
delete (Router as any).onRouteChangeComplete;
// Fire again — should NOT be called
Router.events.emit("routeChangeComplete", "/second", { shallow: false });
expect(calls).toHaveLength(1);
});
});
describe("Pages Router _next/data client navigation", () => {
// These tests exercise the JSON path in navigateClient: when
// `__VINEXT_PAGE_LOADERS__` is populated (prod-style hydration), navigation
// fetches `/_next/data/<buildId>/<page>.json` instead of the full HTML.
// The HTML path tests above still cover the dev fallback.
// Local mirror of trackHrefAssignments in the concurrent-navigation suite —
// duplicated rather than hoisted to avoid disturbing the existing scopes.
function trackHrefAssignmentsLocal(win: { location: { href: string } }): string[] {
let currentHref = win.location.href;
const assignments: string[] = [];
Object.defineProperty(win.location, "href", {
configurable: true,
enumerable: true,
get: () => currentHref,
set: (value: string) => {
currentHref = value;
assignments.push(value);
},
});
return assignments;
}
// Local mirror of buildNavHtml — the HTML-fallback assertion needs a
// minimal HTML response that the navigateClientHtml path can parse.
function buildNavHtmlLocal(page: string, pageModuleUrl: string): string {
const nextData = {
props: { pageProps: {} },
page,
query: {},
buildId: null,
isFallback: false,
__vinext: { pageModuleUrl },
};
return `<html><head></head><body><script>window.__NEXT_DATA__ = ${JSON.stringify(nextData)}</script></body></html>`;
}
function createDataNavWindow(
opts: {
buildId?: string;
page?: string;
locale?: string;
loaders?: Record<string, () => Promise<unknown>>;
appLoader?: () => Promise<unknown>;
/** Initial pathname for window.location. */
pathname?: string;
} = {},
) {
const pushState = vi.fn();
const replaceState = vi.fn();
const render = vi.fn((element: unknown) => commitRenderedPagesRouterElement(element));
const buildId = opts.buildId ?? "test-build";
const win = {
location: {
pathname: opts.pathname ?? "/",
search: "",
hash: "",
href: `http://localhost${opts.pathname ?? "/"}`,
origin: "http://localhost",
hostname: "localhost",
assign: vi.fn(),
replace: vi.fn(),
reload: vi.fn(),
},
history: {
state: null,
pushState: pushState as any,
replaceState: replaceState as any,
back: vi.fn(),
},
dispatchEvent: vi.fn(),
scrollTo: vi.fn(),
scrollX: 0,
scrollY: 0,
addEventListener: vi.fn(),
__NEXT_DATA__: {
page: opts.page ?? "/",
query: {},
isFallback: false,
props: { pageProps: {} },
buildId,
} as any,
__VINEXT_ROOT__: { render },
__VINEXT_APP__: undefined,
__VINEXT_LOCALE__: opts.locale,
__VINEXT_LOCALES__: undefined,
__VINEXT_DEFAULT_LOCALE__: undefined,
__VINEXT_PAGE_LOADERS__: opts.loaders,
__VINEXT_PAGE_PATTERNS__: opts.loaders ? Object.keys(opts.loaders) : undefined,
__VINEXT_APP_LOADER__: opts.appLoader,
};
pushState.mockImplementation((_state: unknown, _title: string, url: string) => {
try {
const parsed = new URL(url, "http://localhost");
win.location.pathname = parsed.pathname;
win.location.search = parsed.search;
win.location.hash = parsed.hash;
win.location.href = parsed.href;
} catch {
win.location.pathname = url;
win.location.href = "http://localhost" + url;
}
});
replaceState.mockImplementation((_state: unknown, _title: string, url?: string) => {
if (!url) return;
try {
const parsed = new URL(url, "http://localhost");
win.location.pathname = parsed.pathname;
win.location.search = parsed.search;
win.location.hash = parsed.hash;
win.location.href = parsed.href;
} catch {
win.location.pathname = url;
win.location.href = "http://localhost" + url;
}
});
return { win, pushState, replaceState, render, buildId };
}
// Fake React-component placeholder. The mocked __VINEXT_ROOT__.render
// captures whatever element navigateClient produces; we only assert on the
// surrounding state (page, query, fetch URL, etc.), so the page modules
// returned by loaders just need a default export of any function value.
const FakePage = (() => "fake-page") as unknown as { default: unknown };
(FakePage as { default: unknown }).default = () => "fake-page";
function makePageModule(name: string): { default: unknown } {
return { default: ((): string => `page:${name}`) as unknown };
}
it("fetches /_next/data/<buildId>/<page>.json instead of the HTML page", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const loaderAbout = vi.fn(async () => makePageModule("about"));
const { win, buildId } = createDataNavWindow({
loaders: { "/": vi.fn(async () => makePageModule("home")), "/about": loaderAbout },
});
(globalThis as any).window = win;
vi.resetModules();
const fetchMock = vi.fn(
async (_url: any, _init: any) =>
new Response(JSON.stringify({ pageProps: { hello: "world" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
globalThis.fetch = fetchMock as any;
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
const result = await Router.push("/about");
expect(result).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [calledUrl, calledInit] = fetchMock.mock.calls[0];
expect(String(calledUrl)).toBe(`/_next/data/${buildId}/about.json`);
expect((calledInit as RequestInit).headers).toMatchObject({
Accept: "application/json",
"x-nextjs-data": "1",
});
expect(loaderAbout).toHaveBeenCalledTimes(1);
expect(win.__NEXT_DATA__.page).toBe("/about");
expect(win.__NEXT_DATA__.props.pageProps).toEqual({ hello: "world" });
} finally {
if (previousWindow === undefined) delete (globalThis as any).window;
else (globalThis as any).window = previousWindow;
globalThis.fetch = originalFetch;
vi.resetModules();
}
});
it("dedupes deferred identical pushes while cancelling only the stale navigation", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const slowLoader = vi.fn(async () => makePageModule("slow"));
const { win, render, pushState } = createDataNavWindow({
loaders: { "/": vi.fn(async () => makePageModule("home")), "/slow": slowLoader },
});
(globalThis as any).window = win;
vi.resetModules();
let resolveFetch: (response: Response) => void = () => {};
const deferredResponse = new Promise<Response>((resolve) => {
resolveFetch = resolve;
});
const fetchMock = vi.fn(() => deferredResponse);
globalThis.fetch = fetchMock as any;
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
const routeChangeErrors: unknown[] = [];
const routeChangeComplete = vi.fn();
Router.events.on("routeChangeError", (error) => routeChangeErrors.push(error));
Router.events.on("routeChangeComplete", routeChangeComplete);
const firstPush = Router.push("/slow?value=one");
const secondPush = Router.push("/slow?value=one");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(pushState).toHaveBeenCalledTimes(2);
resolveFetch(
new Response(JSON.stringify({ pageProps: { hit: 1 } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
await expect(Promise.all([firstPush, secondPush])).resolves.toEqual([true, true]);
expect(routeChangeErrors).toHaveLength(1);
expect(routeChangeErrors[0]).toMatchObject({ cancelled: true });
expect(routeChangeComplete).toHaveBeenCalledTimes(1);
expect(slowLoader).toHaveBeenCalledTimes(1);
expect(render).toHaveBeenCalledTimes(1);
expect(win.__NEXT_DATA__).toMatchObject({
page: "/slow",
query: { value: "one" },
props: { pageProps: { hit: 1 } },
});
} finally {
if (previousWindow === undefined) delete (globalThis as any).window;
else (globalThis as any).window = previousWindow;
globalThis.fetch = originalFetch;
vi.resetModules();
}
});
it("threads full Pages props through _app during data navigation", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const PageComponent = () => "page:about";
const AppComponent = () => "app";
const loaderAbout = vi.fn(async () => ({ default: PageComponent }));
const appLoader = vi.fn(async () => ({ default: AppComponent }));
const { win, render } = createDataNavWindow({
loaders: { "/": vi.fn(async () => makePageModule("home")), "/about": loaderAbout },
appLoader,
});
(globalThis as any).window = win;
vi.resetModules();
const props = {
appProps: { source: "_app.getInitialProps" },
pageProps: { hello: "world" },
};
globalThis.fetch = vi.fn(
async () =>
new Response(JSON.stringify(props), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
) as any;
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
const result = await Router.push("/about");
expect(result).toBe(true);
expect(loaderAbout).toHaveBeenCalledTimes(1);
expect(appLoader).toHaveBeenCalledTimes(1);
const committed = render.mock.calls.at(-1)![0] as {
props: {
children: { props: { children: { type: unknown; props: Record<string, unknown> } } };
};
};
const appElement = committed.props.children.props.children;
expect(appElement.type).toBe(AppComponent);
expect(appElement.props).toMatchObject({
appProps: { source: "_app.getInitialProps" },
pageProps: { hello: "world" },
});
expect(appElement.props.Component).toBe(PageComponent);
expect(win.__NEXT_DATA__.props).toEqual(props);
} finally {
if (previousWindow === undefined) delete (globalThis as any).window;
else (globalThis as any).window = previousWindow;
globalThis.fetch = originalFetch;
vi.resetModules();
}
});
it("hard-reloads when the data endpoint returns 404 (build-skew safety net)", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const { win } = createDataNavWindow({
loaders: {
"/": vi.fn(async () => makePageModule("home")),
"/about": vi.fn(async () => makePageModule("about")),
},
});
(globalThis as any).window = win;
const hrefAssignments = trackHrefAssignmentsLocal(win);
vi.resetModules();
globalThis.fetch = vi.fn(
async () =>
new Response("{}", { status: 404, headers: { "Content-Type": "application/json" } }),
) as any;
const onRouteChangeError = vi.fn();
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
Router.events.on("routeChangeError", onRouteChangeError);
const result = await Router.push("/about");
expect(result).toBe(false);
// Hard reload was queued via window.location.href = url.
expect(hrefAssignments).toContain("/about");
Router.events.off("routeChangeError", onRouteChangeError);
} finally {
if (previousWindow === undefined) delete (globalThis as any).window;
else (globalThis as any).window = previousWindow;
globalThis.fetch = originalFetch;
vi.resetModules();
}
});
it("hard-reloads to the redirect target on x-nextjs-redirect", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const { win } = createDataNavWindow({
loaders: {
"/": vi.fn(async () => makePageModule("home")),
"/about": vi.fn(async () => makePageModule("about")),
},
});
(globalThis as any).window = win;
const hrefAssignments = trackHrefAssignmentsLocal(win);
vi.resetModules();
globalThis.fetch = vi.fn(
async () =>
new Response("{}", {
status: 200,
headers: { "Content-Type": "application/json", "x-nextjs-redirect": "/login" },
}),
) as any;
const onRouteChangeError = vi.fn();
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
Router.events.on("routeChangeError", onRouteChangeError);
await Router.push("/about");
// The soft redirect destination is what gets hard-loaded.
expect(hrefAssignments).toContain("/login");
Router.events.off("routeChangeError", onRouteChangeError);
} finally {
if (previousWindow === undefined) delete (globalThis as any).window;
else (globalThis as any).window = previousWindow;
globalThis.fetch = originalFetch;
vi.resetModules();
}
});
it("falls back to the HTML path when no loader is registered for the target route", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
// Loader map has only `/` — no loader for `/about`, so the data path
// resolves to null and navigateClient falls through to HTML extraction.
const { win } = createDataNavWindow({
loaders: { "/": vi.fn(async () => makePageModule("home")) },
});
(globalThis as any).window = win;
vi.resetModules();
const fetchMock = vi.fn(
async (_url: any, _init?: any) =>
new Response(buildNavHtmlLocal("/about", "/@fs/pages/about.js"), {
status: 200,
headers: { "Content-Type": "text/html" },
}),
);
globalThis.fetch = fetchMock as any;
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
await Router.push("/about");
// The fetch URL must be the HTML page, not the data endpoint.
const [calledUrl] = fetchMock.mock.calls[0];
expect(String(calledUrl)).not.toMatch(/^\/_next\/data\//);
expect(String(calledUrl)).toContain("/about");
} finally {
if (previousWindow === undefined) delete (globalThis as any).window;
else (globalThis as any).window = previousWindow;
globalThis.fetch = originalFetch;
vi.resetModules();
}
});
it("extracts dynamic route params from the URL and stores them on __NEXT_DATA__.query", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const blogLoader = vi.fn(async () => makePageModule("blog"));
const { win, buildId } = createDataNavWindow({
loaders: { "/": vi.fn(async () => makePageModule("home")), "/blog/[slug]": blogLoader },
});
(globalThis as any).window = win;
vi.resetModules();
const fetchMock = vi.fn(
async (_url: any, _init?: any) =>
new Response(JSON.stringify({ pageProps: { post: "hi" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
globalThis.fetch = fetchMock as any;
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
await Router.push("/blog/my-post");
const [calledUrl] = fetchMock.mock.calls[0];
expect(String(calledUrl)).toBe(`/_next/data/${buildId}/blog/my-post.json`);
expect(blogLoader).toHaveBeenCalledTimes(1);
expect(win.__NEXT_DATA__.page).toBe("/blog/[slug]");
expect(win.__NEXT_DATA__.query).toEqual({ slug: "my-post" });
} finally {
if (previousWindow === undefined) delete (globalThis as any).window;
else (globalThis as any).window = previousWindow;
globalThis.fetch = originalFetch;
vi.resetModules();
}
});
it("merges URL search params and route params into __NEXT_DATA__.query (route params win)", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const { win } = createDataNavWindow({
loaders: {
"/": vi.fn(async () => makePageModule("home")),
"/blog/[slug]": vi.fn(async () => makePageModule("blog")),
},
});
(globalThis as any).window = win;
vi.resetModules();
globalThis.fetch = vi.fn(
async () =>
new Response(JSON.stringify({ pageProps: {} }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
) as any;
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
// Search param `slug` should NOT shadow the dynamic route param `slug`.
// Search params `q` and `tag` should appear in query alongside `slug`.
await Router.push("/blog/my-post?slug=spoofed&q=hello&tag=a&tag=b");
expect(win.__NEXT_DATA__.page).toBe("/blog/[slug]");
expect(win.__NEXT_DATA__.query).toEqual({
slug: "my-post",
q: "hello",
tag: ["a", "b"],
});
} finally {
if (previousWindow === undefined) delete (globalThis as any).window;
else (globalThis as any).window = previousWindow;
globalThis.fetch = originalFetch;
vi.resetModules();
}
});
it("hard-reloads on a malformed JSON response", async () => {
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const { win } = createDataNavWindow({
loaders: {
"/": vi.fn(async () => makePageModule("home")),
"/about": vi.fn(async () => makePageModule("about")),
},
});
(globalThis as any).window = win;
const hrefAssignments = trackHrefAssignmentsLocal(win);
vi.resetModules();
globalThis.fetch = vi.fn(
async () =>
new Response("not json {{{", {
status: 200,
headers: { "Content-Type": "application/json" },
}),
) as any;
const onRouteChangeError = vi.fn();
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
Router.events.on("routeChangeError", onRouteChangeError);
await Router.push("/about");
expect(hrefAssignments).toContain("/about");
Router.events.off("routeChangeError", onRouteChangeError);
} finally {
if (previousWindow === undefined) delete (globalThis as any).window;
else (globalThis as any).window = previousWindow;
globalThis.fetch = originalFetch;
vi.resetModules();
}
});
it("uses the JSON path for prefetch when a loader is registered", async () => {
const previousWindow = (globalThis as any).window;
const originalDocument = (globalThis as any).document;
const originalFetch = globalThis.fetch;
const aboutLoader = vi.fn(async () => makePageModule("about"));
const { win, buildId } = createDataNavWindow({
loaders: { "/": vi.fn(async () => makePageModule("home")), "/about": aboutLoader },
});
(globalThis as any).window = win;
const appendedLinks: Array<{ rel: string; as: string; href: string; crossOrigin?: string }> =
[];
(globalThis as any).document = {
createElement: (tag: string) => {
if (tag !== "link") return {};
const link: { rel: string; as: string; href: string; crossOrigin?: string } = {
rel: "",
as: "",
href: "",
};
return link;
},
head: {
appendChild: (node: any) => {
appendedLinks.push({
rel: node.rel,
as: node.as,
href: node.href,
crossOrigin: node.crossOrigin,
});
},
},
};
const fetchMock = vi.fn(async () => new Response("{}"));
globalThis.fetch = fetchMock;
vi.resetModules();
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
await Router.prefetch("/about");
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce());
expect(fetchMock).toHaveBeenCalledWith(`/_next/data/${buildId}/about.json`, {
headers: {
Accept: "application/json",
purpose: "prefetch",
"x-nextjs-data": "1",
},
signal: expect.any(AbortSignal),
});
expect(appendedLinks).toEqual([]);
// The loader was warmed (chunk fetch kicked off).
expect(aboutLoader).toHaveBeenCalledTimes(1);
} finally {
if (previousWindow === undefined) delete (globalThis as any).window;
else (globalThis as any).window = previousWindow;
(globalThis as any).document = originalDocument;
globalThis.fetch = originalFetch;
vi.resetModules();
}
});
it("falls back to as=document prefetch when no loader matches", async () => {
const previousWindow = (globalThis as any).window;
const originalDocument = (globalThis as any).document;
const { win } = createDataNavWindow({
loaders: { "/": vi.fn(async () => makePageModule("home")) },
});
(globalThis as any).window = win;
const appendedLinks: Array<{ rel: string; as: string; href: string }> = [];
(globalThis as any).document = {
createElement: () => ({ rel: "", as: "", href: "" }),
head: {
appendChild: (node: any) => {
appendedLinks.push({ rel: node.rel, as: node.as, href: node.href });
},
},
};
vi.resetModules();
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
await Router.prefetch("/unmapped");
const prefetchLink = appendedLinks.find((l) => l.rel === "prefetch");
expect(prefetchLink?.as).toBe("document");
} finally {
if (previousWindow === undefined) delete (globalThis as any).window;
else (globalThis as any).window = previousWindow;
(globalThis as any).document = originalDocument;
vi.resetModules();
}
});
it("strips the locale prefix before matching loaders and updates __NEXT_DATA__.locale", async () => {
// Regression: route patterns in __VINEXT_PAGE_PATTERNS__ are
// locale-unaware (e.g. /about, not /fr/about). Without stripping the
// locale prefix from the URL before pattern matching, locale transitions
// would always miss the loader map and fall through to the slower HTML
// path. The data URL itself must keep the locale prefix so the server
// returns locale-specific gSSP data.
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const loaderAbout = vi.fn(async () => makePageModule("about"));
const { win, buildId } = createDataNavWindow({
loaders: {
"/": vi.fn(async () => makePageModule("home")),
"/about": loaderAbout,
},
locale: "en",
});
(win as any).__VINEXT_LOCALES__ = ["en", "fr"];
(win as any).__VINEXT_DEFAULT_LOCALE__ = "en";
(win.__NEXT_DATA__ as any).locale = "en";
(win.__NEXT_DATA__ as any).locales = ["en", "fr"];
(win.__NEXT_DATA__ as any).defaultLocale = "en";
(globalThis as any).window = win;
vi.resetModules();
const fetchMock = vi.fn(
async () =>
new Response(JSON.stringify({ pageProps: { hello: "bonjour" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
globalThis.fetch = fetchMock as any;
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
const result = await Router.push("/fr/about");
expect(result).toBe(true);
// URL kept the locale prefix — the server uses it to pick the locale.
const [calledUrl] = (fetchMock.mock.calls[0] ?? []) as unknown as [unknown, RequestInit?];
expect(String(calledUrl)).toBe(`/_next/data/${buildId}/fr/about.json`);
// Loader matched on the locale-stripped pattern.
expect(loaderAbout).toHaveBeenCalledTimes(1);
// Page resolved to the unprefixed route pattern.
expect(win.__NEXT_DATA__.page).toBe("/about");
// Locale globals updated to the new locale so useRouter().locale reflects it.
expect((win.__NEXT_DATA__ as any).locale).toBe("fr");
expect(win.__VINEXT_LOCALE__).toBe("fr");
} finally {
if (previousWindow === undefined) delete (globalThis as any).window;
else (globalThis as any).window = previousWindow;
globalThis.fetch = originalFetch;
vi.resetModules();
}
});
it("restores the default locale on transitions back to an unprefixed URL", async () => {
// `Router.push("/about", undefined, { locale: "en" })` from a French page
// switches back to the default locale. The data URL drops the locale
// prefix (default locale is unprefixed) and __NEXT_DATA__.locale flips
// back to "en" so useRouter().locale tracks the new locale.
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const loaderAbout = vi.fn(async () => makePageModule("about"));
const { win, buildId } = createDataNavWindow({
loaders: {
"/": vi.fn(async () => makePageModule("home")),
"/about": loaderAbout,
},
locale: "fr",
pathname: "/fr",
});
(win as any).__VINEXT_LOCALES__ = ["en", "fr"];
(win as any).__VINEXT_DEFAULT_LOCALE__ = "en";
(win.__NEXT_DATA__ as any).locale = "fr";
(win.__NEXT_DATA__ as any).locales = ["en", "fr"];
(win.__NEXT_DATA__ as any).defaultLocale = "en";
(globalThis as any).window = win;
vi.resetModules();
globalThis.fetch = vi.fn(
async () =>
new Response(JSON.stringify({ pageProps: {} }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
) as any;
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
await Router.push("/about", undefined, { locale: "en" });
const [calledUrl] = (globalThis.fetch as any).mock.calls[0];
expect(String(calledUrl)).toBe(`/_next/data/${buildId}/about.json`);
expect((win.__NEXT_DATA__ as any).locale).toBe("en");
expect(win.__VINEXT_LOCALE__).toBe("en");
} finally {
if (previousWindow === undefined) delete (globalThis as any).window;
else (globalThis as any).window = previousWindow;
globalThis.fetch = originalFetch;
vi.resetModules();
}
});
});
describe("next/server enhancements", () => {
it("NextRequest.ip extracts from x-forwarded-for header", async () => {
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
const req = new NextRequest("https://example.com", {
headers: { "x-forwarded-for": "1.2.3.4, 5.6.7.8" },
});
expect(req.ip).toBe("1.2.3.4");
});
it("NextRequest.ip returns undefined without forwarded header", async () => {
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
const req = new NextRequest("https://example.com");
expect(req.ip).toBeUndefined();
});
it("NextRequest.geo extracts from Cloudflare/Vercel headers", async () => {
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
const req = new NextRequest("https://example.com", {
headers: {
"cf-ipcountry": "US",
"cf-ipcity": "San Francisco",
},
});
const geo = req.geo;
expect(geo).toBeDefined();
expect(geo!.country).toBe("US");
expect(geo!.city).toBe("San Francisco");
});
it("NextRequest.geo returns undefined without geo headers", async () => {
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
const req = new NextRequest("https://example.com");
expect(req.geo).toBeUndefined();
});
it("ResponseCookies.getAll returns all set cookies", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("a", "1");
cookies.set("b", "2");
const all = cookies.getAll();
expect(all).toHaveLength(2);
expect(all.find((c: any) => c.name === "a")?.value).toBe("1");
expect(all.find((c: any) => c.name === "b")?.value).toBe("2");
});
});
describe("next/image enhancements", () => {
it("exports StaticImageData type", async () => {
const imageModule = await import("../packages/vinext/src/shims/image.js");
// StaticImageData is an interface, so we can't check at runtime
// but getImageProps uses it — verify that function exists
expect(typeof imageModule.getImageProps).toBe("function");
});
it("getImageProps returns img props from Image props", async () => {
const { getImageProps } = await import("../packages/vinext/src/shims/image.js");
const result = getImageProps({
src: "/photo.jpg",
alt: "Test",
width: 800,
height: 600,
priority: true,
});
// Local images now route through the optimization endpoint
expect(result.props.src).toContain("/_next/image");
expect(result.props.src).toContain("url=%2Fphoto.jpg");
expect(result.props.src).toContain("w=800");
expect(result.props.alt).toBe("Test");
expect(result.props.width).toBe(800);
expect(result.props.height).toBe(600);
expect(result.props.loading).toBe("eager");
});
it("getImageProps handles fill mode", async () => {
const { getImageProps } = await import("../packages/vinext/src/shims/image.js");
const result = getImageProps({
src: "/bg.jpg",
alt: "Background",
fill: true,
});
expect(result.props.width).toBeUndefined();
expect(result.props.height).toBeUndefined();
expect(result.props.style?.position).toBe("absolute");
});
it("getImageProps handles StaticImageData", async () => {
const { getImageProps } = await import("../packages/vinext/src/shims/image.js");
const result = getImageProps({
src: { src: "/imported.jpg", width: 1200, height: 800, blurDataURL: "data:..." },
alt: "Imported",
});
expect(result.props.src).toContain("/_next/image");
expect(result.props.src).toContain("url=%2Fimported.jpg");
expect(result.props.src).toContain("w=1200");
expect(result.props.width).toBe(1200);
expect(result.props.height).toBe(800);
});
it("getImageProps generates srcSet for local images with width", async () => {
const { getImageProps } = await import("../packages/vinext/src/shims/image.js");
const result = getImageProps({
src: "/photo.jpg",
alt: "Test",
width: 1200,
height: 800,
});
expect(result.props.srcSet).toBeDefined();
// srcSet entries point to /_next/image optimization endpoint
expect(result.props.srcSet).toContain("/_next/image");
expect(result.props.srcSet).toContain("url=%2Fphoto.jpg");
expect(result.props.srcSet).toContain("w");
});
it("getImageProps does not generate srcSet for fill images", async () => {
const { getImageProps } = await import("../packages/vinext/src/shims/image.js");
const result = getImageProps({
src: "/bg.jpg",
alt: "Background",
fill: true,
});
expect(result.props.srcSet).toBeUndefined();
expect(result.props.sizes).toBe("100vw"); // fill implies 100vw
});
it("getImageProps includes fetchPriority for priority images", async () => {
const { getImageProps } = await import("../packages/vinext/src/shims/image.js");
const result = getImageProps({
src: "/hero.jpg",
alt: "Hero",
width: 1200,
height: 800,
priority: true,
});
expect(result.props.fetchPriority).toBe("high");
expect(result.props.loading).toBe("eager");
});
it("getImageProps includes data-nimg attribute", async () => {
const { getImageProps } = await import("../packages/vinext/src/shims/image.js");
const result = getImageProps({
src: "/photo.jpg",
alt: "Photo",
width: 800,
height: 600,
});
expect((result.props as any)["data-nimg"]).toBe("1");
const fillResult = getImageProps({
src: "/bg.jpg",
alt: "BG",
fill: true,
});
expect((fillResult.props as any)["data-nimg"]).toBe("fill");
});
it("getImageProps includes blur placeholder background styles", async () => {
const { getImageProps } = await import("../packages/vinext/src/shims/image.js");
const blurUrl = "data:image/jpeg;base64,/9j/4AAQ";
const result = getImageProps({
src: "/photo.jpg",
alt: "Blurry",
width: 800,
height: 600,
placeholder: "blur",
blurDataURL: blurUrl,
});
expect(result.props.style?.backgroundImage).toContain(blurUrl);
expect(result.props.style?.backgroundSize).toBe("cover");
});
it("getImageProps uses custom loader function", async () => {
const { getImageProps } = await import("../packages/vinext/src/shims/image.js");
const result = getImageProps({
src: "/photo.jpg",
alt: "Custom",
width: 800,
height: 600,
loader: ({ src, width, quality }) => `https://cdn.example.com${src}?w=${width}&q=${quality}`,
});
// Custom loader bypasses the /_next/image endpoint
expect(result.props.src).toBe("https://cdn.example.com/photo.jpg?w=800&q=75");
expect(result.props.src).not.toContain("/_next/image");
});
it("unoptimized prop bypasses /_next/image endpoint", async () => {
const { getImageProps } = await import("../packages/vinext/src/shims/image.js");
const result = getImageProps({
src: "/photo.jpg",
alt: "Unoptimized",
width: 800,
height: 600,
unoptimized: true,
});
// unoptimized=true should serve the raw src, not the optimization endpoint
expect(result.props.src).toBe("/photo.jpg");
expect(result.props.src).not.toContain("/_next/image");
});
it("SVG src auto-skips optimization endpoint (default behavior)", async () => {
const { getImageProps } = await import("../packages/vinext/src/shims/image.js");
const result = getImageProps({
src: "/logo.svg",
alt: "SVG logo",
width: 200,
height: 200,
});
// By default (dangerouslyAllowSVG not set), .svg sources bypass the optimizer
expect(result.props.src).toBe("/logo.svg");
expect(result.props.src).not.toContain("/_next/image");
});
it("non-SVG src still uses optimization endpoint", async () => {
const { getImageProps } = await import("../packages/vinext/src/shims/image.js");
const result = getImageProps({
src: "/photo.png",
alt: "PNG photo",
width: 256,
height: 256,
});
expect(result.props.src).toContain("/_next/image");
});
});
describe("next/image component rendering", () => {
it("renders basic image with src, alt, width, height", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, { src: "/photo.jpg", alt: "Test photo", width: 800, height: 600 }),
);
// Local images route through the optimization endpoint
expect(html).toContain("/_next/image");
expect(html).toContain("url=%2Fphoto.jpg");
expect(html).toContain('alt="Test photo"');
expect(html).toContain('width="800"');
expect(html).toContain('height="600"');
});
it("renders fill image with absolute positioning", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, { src: "/bg.jpg", alt: "Background", fill: true }),
);
expect(html).toContain("position:absolute");
expect(html).toContain('data-nimg="fill"');
// fill images should not have width/height attributes
expect(html).not.toContain("width=");
expect(html).not.toContain("height=");
});
it("renders priority image with fetchpriority=high and loading=eager", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, {
src: "/hero.jpg",
alt: "Hero",
width: 1200,
height: 800,
priority: true,
}),
);
expect(html).toContain('fetchPriority="high"');
expect(html).toContain('loading="eager"');
});
it("renders lazy loading by default (no priority)", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, { src: "/photo.jpg", alt: "Photo", width: 800, height: 600 }),
);
expect(html).toContain('loading="lazy"');
expect(html).not.toContain("fetchPriority");
});
it("renders srcSet for local images with width", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, { src: "/photo.jpg", alt: "Photo", width: 1200, height: 800 }),
);
expect(html).toContain("srcSet");
// srcSet entries point to /_next/image optimization endpoint
expect(html).toContain("/_next/image");
expect(html).toContain("url=%2Fphoto.jpg");
});
it("renders blur placeholder with background-image", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const blurUrl = "data:image/jpeg;base64,/9j/4AAQ";
const html = renderToStaticMarkup(
React.createElement(Image, {
src: "/photo.jpg",
alt: "Blurry",
width: 800,
height: 600,
placeholder: "blur",
blurDataURL: blurUrl,
}),
);
expect(html).toContain(blurUrl);
expect(html).toContain("background-image");
expect(html).toContain("background-size:cover");
});
it("renders with custom loader function", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, {
src: "/photo.jpg",
alt: "Custom",
width: 800,
height: 600,
loader: ({ src, width, quality }: { src: string; width: number; quality?: number }) =>
`https://cdn.example.com${src}?w=${width}&q=${quality}`,
}),
);
expect(html).toContain('src="https://cdn.example.com/photo.jpg?w=800&amp;q=75"');
});
it("renders with custom sizes attribute", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, {
src: "/photo.jpg",
alt: "Responsive",
width: 1200,
height: 800,
sizes: "(max-width: 768px) 100vw, 50vw",
}),
);
expect(html).toContain('sizes="(max-width: 768px) 100vw, 50vw"');
});
it("renders fill image with sizes=100vw by default", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, { src: "/bg.jpg", alt: "BG", fill: true }),
);
expect(html).toContain('sizes="100vw"');
});
it("handles StaticImageData import objects", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const staticImport = {
src: "/imported.jpg",
width: 1200,
height: 800,
blurDataURL: "data:...",
};
const html = renderToStaticMarkup(
React.createElement(Image, { src: staticImport, alt: "Imported" }),
);
expect(html).toContain("/_next/image");
expect(html).toContain("url=%2Fimported.jpg");
expect(html).toContain('width="1200"');
expect(html).toContain('height="800"');
});
it("renders with className", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, {
src: "/photo.jpg",
alt: "Styled",
width: 800,
height: 600,
className: "rounded-lg shadow-md",
}),
);
expect(html).toContain('class="rounded-lg shadow-md"');
});
it("includes data-nimg=1 for non-fill images", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, { src: "/photo.jpg", alt: "Test", width: 800, height: 600 }),
);
expect(html).toContain('data-nimg="1"');
});
it("always sets decoding=async", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, { src: "/photo.jpg", alt: "Test", width: 800, height: 600 }),
);
expect(html).toContain('decoding="async"');
});
// ── SSR-only smoke tests: verify SSR output does not crash when onLoad / onError /
// ref are provided. The hydration replay logic (useLayoutEffect, img.src = img.src,
// mergedRef DOM node capture) requires a client-side mount; the handler wiring is
// tested in the Playwright E2E test suite.
it("renders with onError callback attached (SSR smoke test)", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, {
src: "/broken.jpg",
alt: "Broken",
width: 400,
height: 300,
onError: () => {},
}),
);
// SSR should render without errors — the onError replay is client-side only
expect(html).toContain("<img");
expect(html).toContain("/_next/image");
});
it("renders with both onLoad and onError callbacks (SSR smoke test)", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, {
src: "/photo.jpg",
alt: "Photo",
width: 800,
height: 600,
onLoad: () => {},
onError: () => {},
}),
);
expect(html).toContain("<img");
expect(html).toContain("/_next/image");
});
it("forwards ref to img element via mergedRef (SSR smoke test)", async () => {
const React = await import("react");
const { renderToString } = await import("react-dom/server");
const ref = React.createRef<HTMLImageElement>();
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToString(
React.createElement(Image, {
src: "/photo.jpg",
alt: "Ref test",
width: 800,
height: 600,
ref,
}),
);
expect(html).toContain("<img");
expect(html).toContain("/_next/image");
// ref.current is null after SSR — that's expected since refs don't hydrate in SSR
expect(ref.current).toBeNull();
});
it("renders with onError in loader path (SSR smoke test)", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, {
src: "/photo.jpg",
alt: "Loader",
width: 800,
height: 600,
onError: () => {},
loader: ({ src, width, quality }) =>
`https://cdn.example.com${src}?w=${width}&q=${quality}`,
}),
);
expect(html).toContain("<img");
expect(html).not.toContain("/_next/image");
expect(html).toContain("cdn.example.com");
});
});
describe("image remote pattern matching", () => {
it("matchRemotePattern matches exact hostname", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { hostname: "cdn.example.com" };
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/img.jpg"))).toBe(true);
expect(matchRemotePattern(pattern, new URL("https://other.example.com/img.jpg"))).toBe(false);
});
it("matchRemotePattern matches wildcard hostname with *", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { hostname: "*.example.com" };
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/img.jpg"))).toBe(true);
expect(matchRemotePattern(pattern, new URL("https://images.example.com/img.jpg"))).toBe(true);
// Single * should NOT match nested subdomains
expect(matchRemotePattern(pattern, new URL("https://a.b.example.com/img.jpg"))).toBe(false);
// Should not match bare domain
expect(matchRemotePattern(pattern, new URL("https://example.com/img.jpg"))).toBe(false);
});
it("matchRemotePattern matches ** hostname for any depth", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { hostname: "**.example.com" };
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/img.jpg"))).toBe(true);
expect(matchRemotePattern(pattern, new URL("https://a.b.c.example.com/img.jpg"))).toBe(true);
// ** requires at least one segment
expect(matchRemotePattern(pattern, new URL("https://example.com/img.jpg"))).toBe(false);
});
it("matchRemotePattern checks protocol when specified", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { protocol: "https", hostname: "cdn.example.com" };
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/img.jpg"))).toBe(true);
expect(matchRemotePattern(pattern, new URL("http://cdn.example.com/img.jpg"))).toBe(false);
});
it("matchRemotePattern checks port when specified", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { hostname: "localhost", port: "3000" };
expect(matchRemotePattern(pattern, new URL("http://localhost:3000/img.jpg"))).toBe(true);
expect(matchRemotePattern(pattern, new URL("http://localhost:8080/img.jpg"))).toBe(false);
});
it("matchRemotePattern checks pathname when specified", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { hostname: "cdn.example.com", pathname: "/images/**" };
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/images/photo.jpg"))).toBe(
true,
);
expect(
matchRemotePattern(pattern, new URL("https://cdn.example.com/images/nested/photo.jpg")),
).toBe(true);
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/other/photo.jpg"))).toBe(
false,
);
});
it("matchRemotePattern checks search when specified", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { hostname: "cdn.example.com", search: "?v=123" };
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/img.jpg?v=123"))).toBe(
true,
);
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/img.jpg?v=456"))).toBe(
false,
);
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/img.jpg"))).toBe(false);
});
it("matchRemotePattern defaults pathname to ** when not specified", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { hostname: "cdn.example.com" };
expect(
matchRemotePattern(pattern, new URL("https://cdn.example.com/any/deep/path/img.jpg")),
).toBe(true);
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/"))).toBe(true);
});
it("matchRemotePattern handles protocol with trailing colon", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { protocol: "https:", hostname: "cdn.example.com" };
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/img.jpg"))).toBe(true);
});
it("hasRemoteMatch checks domains list", async () => {
const { hasRemoteMatch } = await import("../packages/vinext/src/shims/image-config.js");
const domains = ["cdn.example.com", "images.example.com"];
expect(hasRemoteMatch(domains, [], new URL("https://cdn.example.com/img.jpg"))).toBe(true);
expect(hasRemoteMatch(domains, [], new URL("https://images.example.com/img.jpg"))).toBe(true);
expect(hasRemoteMatch(domains, [], new URL("https://other.example.com/img.jpg"))).toBe(false);
});
it("hasRemoteMatch checks remotePatterns when domains don't match", async () => {
const { hasRemoteMatch } = await import("../packages/vinext/src/shims/image-config.js");
const patterns = [{ protocol: "https", hostname: "**.cdn.example.com" }];
expect(hasRemoteMatch([], patterns, new URL("https://us.cdn.example.com/img.jpg"))).toBe(true);
expect(hasRemoteMatch([], patterns, new URL("http://us.cdn.example.com/img.jpg"))).toBe(false);
});
it("matchRemotePattern handles single * in pathname", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { hostname: "cdn.example.com", pathname: "/images/*" };
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/images/photo.jpg"))).toBe(
true,
);
// Single * should not match nested paths
expect(
matchRemotePattern(pattern, new URL("https://cdn.example.com/images/nested/photo.jpg")),
).toBe(false);
});
it("matchRemotePattern handles regex special chars in hostname", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
// Dots in hostname are literal (escaped for regex)
const pattern = { hostname: "cdn.example.com" };
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/img.jpg"))).toBe(true);
// "cdnXexampleXcom" should not match "cdn.example.com"
expect(matchRemotePattern(pattern, new URL("https://cdnXexample.com/img.jpg"))).toBe(false);
});
});
describe("image optimization URL generation", () => {
it("imageOptimizationUrl generates correct URL", async () => {
const { imageOptimizationUrl } = await import("../packages/vinext/src/shims/image.js");
const url = imageOptimizationUrl("/images/hero.webp", 1200, 75);
expect(url).toBe("/_next/image?url=%2Fimages%2Fhero.webp&w=1200&q=75");
});
it("imageOptimizationUrl encodes special characters", async () => {
const { imageOptimizationUrl } = await import("../packages/vinext/src/shims/image.js");
const url = imageOptimizationUrl("/images/my photo.jpg", 800, 80);
expect(url).toContain("url=%2Fimages%2Fmy%20photo.jpg");
expect(url).toContain("w=800");
expect(url).toContain("q=80");
});
it("imageOptimizationUrl uses default quality of 75", async () => {
const { imageOptimizationUrl } = await import("../packages/vinext/src/shims/image.js");
const url = imageOptimizationUrl("/img.png", 640);
expect(url).toContain("q=75");
});
});
describe("image optimization request parsing", () => {
it("parseImageParams extracts url, width, quality", async () => {
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
const url = new URL("http://localhost/_next/image?url=%2Fimages%2Fhero.webp&w=1200&q=75");
const params = parseImageParams(url);
expect(params).not.toBeNull();
expect(params!.imageUrl).toBe("/images/hero.webp");
expect(params!.width).toBe(1200);
expect(params!.quality).toBe(75);
});
it("parseImageParams returns null when url is missing", async () => {
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
const url = new URL("http://localhost/_next/image?w=640&q=75");
expect(parseImageParams(url)).toBeNull();
});
it("parseImageParams blocks absolute http URLs", async () => {
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
const url = new URL(
"http://localhost/_next/image?url=http%3A%2F%2Fevil.com%2Fimg.jpg&w=640&q=75",
);
expect(parseImageParams(url)).toBeNull();
});
it("parseImageParams blocks absolute https URLs", async () => {
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
const url = new URL(
"http://localhost/_next/image?url=https%3A%2F%2Fevil.com%2Fimg.jpg&w=640&q=75",
);
expect(parseImageParams(url)).toBeNull();
});
it("parseImageParams blocks protocol-relative URLs", async () => {
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
const url = new URL("http://localhost/_next/image?url=%2F%2Fevil.com%2Fimg.jpg&w=640&q=75");
expect(parseImageParams(url)).toBeNull();
});
// Ported from Next.js: test/integration/image-optimizer/test/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/integration/image-optimizer/test/index.test.ts
it("parseImageParams requires width and quality", async () => {
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
expect(parseImageParams(new URL("http://localhost/_next/image?url=%2Fimg.jpg"))).toBeNull();
expect(
parseImageParams(new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=640")),
).toBeNull();
});
it("parseImageParams rejects cache-key aliases with integer suffixes", async () => {
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
expect(
parseImageParams(new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=640junk&q=75")),
).toBeNull();
expect(
parseImageParams(new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=75junk")),
).toBeNull();
});
it("parseImageParams rejects zero, signed, and decimal widths", async () => {
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
for (const width of ["0", "-640", "+640", "640.0"]) {
expect(
parseImageParams(new URL(`http://localhost/_next/image?url=%2Fimg.jpg&w=${width}&q=75`)),
).toBeNull();
}
});
it("parseImageParams rejects leading-zero cache-key aliases", async () => {
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
expect(
parseImageParams(new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=0640&q=75")),
).toBeNull();
expect(
parseImageParams(new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=075")),
).toBeNull();
});
it("parseImageParams rejects duplicate and unknown query parameters", async () => {
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
expect(
parseImageParams(new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=640&w=640&q=75")),
).toBeNull();
expect(
parseImageParams(
new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=75&cacheBust=1"),
),
).toBeNull();
});
it("parseImageParams rejects source URLs longer than 3072 characters", async () => {
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
const imageUrl = `/${"a".repeat(3072)}`;
const url = new URL("http://localhost/_next/image");
url.searchParams.set("url", imageUrl);
url.searchParams.set("w", "640");
url.searchParams.set("q", "75");
expect(parseImageParams(url)).toBeNull();
});
it("parseImageParams blocks data: URIs (exotic scheme bypass)", async () => {
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
expect(
parseImageParams(
new URL(
"http://localhost/_next/image?url=data%3Atext%2Fhtml%2C%3Cscript%3Ealert(1)%3C%2Fscript%3E&w=640&q=75",
),
),
).toBeNull();
});
it("parseImageParams blocks javascript: URIs", async () => {
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
expect(
parseImageParams(
new URL("http://localhost/_next/image?url=javascript%3Aalert(1)&w=640&q=75"),
),
).toBeNull();
});
it("parseImageParams blocks bare filenames (no leading slash)", async () => {
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
expect(
parseImageParams(new URL("http://localhost/_next/image?url=img.jpg&w=640&q=75")),
).toBeNull();
});
it("parseImageParams rejects quality outside 1-100", async () => {
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
expect(
parseImageParams(new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=0")),
).toBeNull();
expect(
parseImageParams(new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=101")),
).toBeNull();
});
it("parseImageParams permits any quality 1-100 when qualities is unset", async () => {
// Matches Next.js: an unset `images.qualities` is not restricted to a single
// value. Regression test for non-75 qualities (e.g. `<Image quality={90}>`)
// returning 400 from the image optimization endpoint.
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
for (const q of [1, 50, 75, 90, 100]) {
const params = parseImageParams(
new URL(`http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=${q}`),
);
expect(params).not.toBeNull();
expect(params!.quality).toBe(q);
}
});
it("parseImageParams enforces the allowlist only when qualities is configured", async () => {
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
const allowed = [640, 750, 828, 1080, 1200, 1920, 2048, 3840];
// Quality in the configured allowlist passes.
expect(
parseImageParams(
new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=90"),
allowed,
[75, 90],
),
).not.toBeNull();
// Quality outside the configured allowlist is rejected.
expect(
parseImageParams(
new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=50"),
allowed,
[75, 90],
),
).toBeNull();
});
it("parseImageParams blocks backslash-based open redirect (/\\evil.com)", async () => {
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
// /\evil.com — browsers and the URL constructor treat this as //evil.com
expect(
parseImageParams(new URL("http://localhost/_next/image?url=%2F%5Cevil.com&w=640&q=75")),
).toBeNull();
});
it("parseImageParams blocks encoded backslash variants", async () => {
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
// /\evil.com/img.jpg
expect(
parseImageParams(
new URL("http://localhost/_next/image?url=%2F%5Cevil.com%2Fimg.jpg&w=640&q=75"),
),
).toBeNull();
// /\\evil.com (double backslash)
expect(
parseImageParams(new URL("http://localhost/_next/image?url=%2F%5C%5Cevil.com&w=640&q=75")),
).toBeNull();
});
it("parseImageParams validates origin hasn't changed after URL construction", async () => {
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
// This tests defense-in-depth: even if a future parser differential is found,
// the origin check catches it.
// A valid relative URL should pass
const good = parseImageParams(
new URL("http://localhost/_next/image?url=%2Fimages%2Fhero.webp&w=640&q=75"),
);
expect(good).not.toBeNull();
expect(good!.imageUrl).toBe("/images/hero.webp");
});
it("parseImageParams normalizes backslashes in returned imageUrl", async () => {
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
// /images\hero.webp should be normalized to /images/hero.webp
// (backslash in the middle of a valid path)
const result = parseImageParams(
new URL("http://localhost/_next/image?url=%2Fimages%5Chero.webp&w=640&q=75"),
);
expect(result).not.toBeNull();
expect(result!.imageUrl).toBe("/images/hero.webp");
});
it("parseImageParams rejects width exceeding absolute maximum (3840)", async () => {
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
expect(
parseImageParams(new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=3841&q=75")),
).toBeNull();
expect(
parseImageParams(new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=999999999&q=75")),
).toBeNull();
expect(
parseImageParams(new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=2147483647&q=75")),
).toBeNull();
});
it("parseImageParams accepts width at the absolute maximum (3840)", async () => {
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
const params = parseImageParams(
new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=3840&q=75"),
);
expect(params).not.toBeNull();
expect(params!.width).toBe(3840);
});
it("parseImageParams validates against allowedWidths when provided", async () => {
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
const allowedWidths = [640, 750, 828, 1080, 1200, 1920, 2048, 3840];
// Allowed width passes
const params = parseImageParams(
new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=1080&q=75"),
allowedWidths,
);
expect(params).not.toBeNull();
expect(params!.width).toBe(1080);
// Non-allowed width is rejected
expect(
parseImageParams(
new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=999&q=75"),
allowedWidths,
),
).toBeNull();
expect(
parseImageParams(
new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=0&q=75"),
allowedWidths,
),
).toBeNull();
});
it("parseImageParams allows any quality 1-100 when qualities is unset", async () => {
// Matches Next.js: an unset `images.qualities` does not restrict the quality
// to a single value — any integer from 1-100 is accepted.
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
expect(
parseImageParams(new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=75")),
).not.toBeNull();
expect(
parseImageParams(new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=80")),
).not.toBeNull();
});
it("parseImageParams accepts configured qualities", async () => {
const { parseImageParams, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } =
await import("../packages/vinext/src/server/image-optimization.js");
const params = parseImageParams(
new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=80"),
[...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES],
[75, 80],
);
expect(params?.quality).toBe(80);
});
it("resolveDevImageRedirect applies validation before passthrough", async () => {
const { resolveDevImageRedirect } =
await import("../packages/vinext/src/server/image-optimization.js");
expect(
resolveDevImageRedirect(
new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=640junk&q=75"),
),
).toBeNull();
expect(
resolveDevImageRedirect(
new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=75&extra=1"),
),
).toBeNull();
expect(
resolveDevImageRedirect(new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=75")),
).toBe("/img.jpg");
});
it("resolveDevImageRedirect preserves Next.js blur requests", async () => {
const { resolveDevImageRedirect } =
await import("../packages/vinext/src/server/image-optimization.js");
expect(
resolveDevImageRedirect(new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=8&q=70")),
).toBe("/img.jpg");
expect(
resolveDevImageRedirect(new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=8&q=75")),
).toBe("/img.jpg");
expect(
resolveDevImageRedirect(new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=70")),
).toBe("/img.jpg");
expect(
resolveDevImageRedirect(new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=08&q=70")),
).toBeNull();
});
it("parseImageParams allows imageSizes (small widths) in allowedWidths", async () => {
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
const allowedWidths = [
16, 32, 48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920, 2048, 3840,
];
const params = parseImageParams(
new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=64&q=75"),
allowedWidths,
);
expect(params).not.toBeNull();
expect(params!.width).toBe(64);
});
it("negotiateImageFormat prefers AVIF over WebP", async () => {
const { negotiateImageFormat } =
await import("../packages/vinext/src/server/image-optimization.js");
expect(negotiateImageFormat("image/avif,image/webp,image/jpeg")).toBe("image/avif");
});
it("negotiateImageFormat selects WebP when no AVIF", async () => {
const { negotiateImageFormat } =
await import("../packages/vinext/src/server/image-optimization.js");
expect(negotiateImageFormat("image/webp,image/jpeg")).toBe("image/webp");
});
it("negotiateImageFormat falls back to JPEG", async () => {
const { negotiateImageFormat } =
await import("../packages/vinext/src/server/image-optimization.js");
expect(negotiateImageFormat("image/png,image/jpeg")).toBe("image/jpeg");
expect(negotiateImageFormat(null)).toBe("image/jpeg");
});
it("IMAGE_OPTIMIZATION_PATH is /_next/image", async () => {
const { IMAGE_OPTIMIZATION_PATH } =
await import("../packages/vinext/src/server/image-optimization.js");
expect(IMAGE_OPTIMIZATION_PATH).toBe("/_next/image");
});
it("VINEXT_IMAGE_OPTIMIZATION_PATH is /_vinext/image", async () => {
const { VINEXT_IMAGE_OPTIMIZATION_PATH } =
await import("../packages/vinext/src/server/image-optimization.js");
expect(VINEXT_IMAGE_OPTIMIZATION_PATH).toBe("/_vinext/image");
});
it("isImageOptimizationPath accepts both supported endpoints", async () => {
const { isImageOptimizationPath } =
await import("../packages/vinext/src/server/image-optimization.js");
expect(isImageOptimizationPath("/_next/image")).toBe(true);
expect(isImageOptimizationPath("/_vinext/image")).toBe(true);
expect(isImageOptimizationPath("/_next/image/")).toBe(false);
expect(isImageOptimizationPath("/_next/image.png")).toBe(false);
expect(isImageOptimizationPath("/_next/data")).toBe(false);
expect(isImageOptimizationPath("/")).toBe(false);
});
it("exports DEFAULT_DEVICE_SIZES and DEFAULT_IMAGE_SIZES matching Next.js defaults", async () => {
const { DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } =
await import("../packages/vinext/src/server/image-optimization.js");
expect(DEFAULT_DEVICE_SIZES).toEqual([640, 750, 828, 1080, 1200, 1920, 2048, 3840]);
expect(DEFAULT_IMAGE_SIZES).toEqual([16, 32, 48, 64, 96, 128, 256, 384]);
});
});
describe("isSafeImageContentType", () => {
it("accepts safe image content types", async () => {
const { isSafeImageContentType } =
await import("../packages/vinext/src/server/image-optimization.js");
expect(isSafeImageContentType("image/jpeg")).toBe(true);
expect(isSafeImageContentType("image/png")).toBe(true);
expect(isSafeImageContentType("image/gif")).toBe(true);
expect(isSafeImageContentType("image/webp")).toBe(true);
expect(isSafeImageContentType("image/avif")).toBe(true);
expect(isSafeImageContentType("image/x-icon")).toBe(true);
expect(isSafeImageContentType("image/bmp")).toBe(true);
expect(isSafeImageContentType("image/tiff")).toBe(true);
});
it("rejects SVG content type", async () => {
const { isSafeImageContentType } =
await import("../packages/vinext/src/server/image-optimization.js");
expect(isSafeImageContentType("image/svg+xml")).toBe(false);
});
it("rejects non-image content types", async () => {
const { isSafeImageContentType } =
await import("../packages/vinext/src/server/image-optimization.js");
expect(isSafeImageContentType("text/html")).toBe(false);
expect(isSafeImageContentType("application/javascript")).toBe(false);
expect(isSafeImageContentType("text/xml")).toBe(false);
expect(isSafeImageContentType("application/octet-stream")).toBe(false);
});
it("rejects null content type", async () => {
const { isSafeImageContentType } =
await import("../packages/vinext/src/server/image-optimization.js");
expect(isSafeImageContentType(null)).toBe(false);
});
it("handles content type with parameters (charset, etc.)", async () => {
const { isSafeImageContentType } =
await import("../packages/vinext/src/server/image-optimization.js");
expect(isSafeImageContentType("image/jpeg; charset=utf-8")).toBe(true);
expect(isSafeImageContentType("image/svg+xml; charset=utf-8")).toBe(false);
});
it("is case-insensitive", async () => {
const { isSafeImageContentType } =
await import("../packages/vinext/src/server/image-optimization.js");
expect(isSafeImageContentType("Image/JPEG")).toBe(true);
expect(isSafeImageContentType("IMAGE/SVG+XML")).toBe(false);
});
it("allows SVG when dangerouslyAllowSVG is true", async () => {
const { isSafeImageContentType } =
await import("../packages/vinext/src/server/image-optimization.js");
expect(isSafeImageContentType("image/svg+xml", true)).toBe(true);
});
it("allows SVG with parameters when dangerouslyAllowSVG is true", async () => {
const { isSafeImageContentType } =
await import("../packages/vinext/src/server/image-optimization.js");
expect(isSafeImageContentType("image/svg+xml; charset=utf-8", true)).toBe(true);
});
it("still rejects non-image types when dangerouslyAllowSVG is true", async () => {
const { isSafeImageContentType } =
await import("../packages/vinext/src/server/image-optimization.js");
expect(isSafeImageContentType("text/html", true)).toBe(false);
expect(isSafeImageContentType("application/javascript", true)).toBe(false);
expect(isSafeImageContentType(null, true)).toBe(false);
});
});
describe("handleImageOptimization", () => {
it("returns 400 for invalid params", async () => {
const { handleImageOptimization } =
await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_next/image");
const handlers = {
fetchAsset: async () => new Response("", { status: 200 }),
};
const response = await handleImageOptimization(request, handlers);
expect(response.status).toBe(400);
});
it("returns 404 when fetchAsset fails", async () => {
const { handleImageOptimization } =
await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=75");
const handlers = {
fetchAsset: async () => new Response("", { status: 404 }),
};
const response = await handleImageOptimization(request, handlers);
expect(response.status).toBe(404);
});
it("returns original image when no transformImage handler", async () => {
const { handleImageOptimization } =
await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=75");
const handlers = {
fetchAsset: async () =>
new Response("original-image-data", {
status: 200,
headers: { "Content-Type": "image/jpeg" },
}),
};
const response = await handleImageOptimization(request, handlers);
expect(response.status).toBe(200);
expect(await response.text()).toBe("original-image-data");
expect(response.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
expect(response.headers.get("Vary")).toBe("Accept");
});
it("calls transformImage when provided", async () => {
const { handleImageOptimization } =
await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=90", {
headers: { Accept: "image/webp" },
});
let capturedOptions: { width: number; format: string; quality: number } | null = null;
const handlers = {
fetchAsset: async () =>
new Response("original", {
status: 200,
headers: { "Content-Type": "image/jpeg" },
}),
transformImage: async (
_body: ReadableStream,
options: { width: number; format: string; quality: number },
) => {
capturedOptions = options;
return new Response("transformed", { headers: { "Content-Type": options.format } });
},
};
const response = await handleImageOptimization(request, handlers, undefined, {
qualities: [90],
});
expect(response.status).toBe(200);
expect(await response.text()).toBe("transformed");
expect(capturedOptions).toEqual({ width: 640, format: "image/webp", quality: 90 });
});
it("falls back to original on transform error", async () => {
const { handleImageOptimization } =
await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=75");
let fetchCount = 0;
const handlers = {
fetchAsset: async () => {
fetchCount += 1;
return new Response("original", {
status: 200,
headers: { "Content-Type": "image/png" },
});
},
transformImage: async () => {
throw new Error("transform failed");
},
};
const response = await handleImageOptimization(request, handlers);
expect(response.status).toBe(200);
expect(await response.text()).toBe("original");
expect(fetchCount).toBe(1);
});
it("refetches the source when transform consumes the stream before failing", async () => {
const { handleImageOptimization } =
await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=75");
let fetchCount = 0;
const handlers = {
fetchAsset: async () => {
fetchCount += 1;
return new Response(fetchCount === 1 ? "original" : "refetched", {
status: 200,
headers: { "Content-Type": "image/png" },
});
},
transformImage: async (body: ReadableStream) => {
await new Response(body).arrayBuffer();
throw new Error("transform failed after consuming stream");
},
};
const response = await handleImageOptimization(request, handlers);
expect(response.status).toBe(200);
expect(await response.text()).toBe("refetched");
expect(fetchCount).toBe(2);
});
it("uses refetched source headers when consumed transform falls back", async () => {
const { handleImageOptimization } =
await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=75");
let fetchCount = 0;
const handlers = {
fetchAsset: async () => {
fetchCount += 1;
return new Response(fetchCount === 1 ? "original" : "refetched", {
status: 200,
headers: {
"Content-Type": fetchCount === 1 ? "image/png" : "image/jpeg",
ETag: fetchCount === 1 ? '"source-etag"' : '"refetched-etag"',
},
});
},
transformImage: async (body: ReadableStream) => {
await new Response(body).arrayBuffer();
throw new Error("transform failed after consuming stream");
},
};
const response = await handleImageOptimization(request, handlers);
expect(response.status).toBe(200);
expect(fetchCount).toBe(2);
expect(response.headers.get("Content-Type")).toBe("image/jpeg");
expect(response.headers.get("ETag")).toBe('"refetched-etag"');
expect(response.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
expect(response.headers.get("Vary")).toBe("Accept");
});
it("returns 404 when refetch fallback cannot reload the source image", async () => {
const { handleImageOptimization } =
await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=75");
let fetchCount = 0;
const handlers = {
fetchAsset: async () => {
fetchCount += 1;
if (fetchCount === 1) {
return new Response("original", {
status: 200,
headers: { "Content-Type": "image/png" },
});
}
return new Response("", { status: 404 });
},
transformImage: async (body: ReadableStream) => {
await new Response(body).arrayBuffer();
throw new Error("transform failed after consuming stream");
},
};
const response = await handleImageOptimization(request, handlers);
expect(fetchCount).toBe(2);
expect(response.status).toBe(404);
});
it("returns 400 when refetch fallback reloads an unsafe content type", async () => {
const { handleImageOptimization } =
await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=75");
let fetchCount = 0;
const handlers = {
fetchAsset: async () => {
fetchCount += 1;
return new Response(fetchCount === 1 ? "original" : "<html>bad</html>", {
status: 200,
headers: {
"Content-Type": fetchCount === 1 ? "image/png" : "text/html",
},
});
},
transformImage: async (body: ReadableStream) => {
await new Response(body).arrayBuffer();
throw new Error("transform failed after consuming stream");
},
};
const response = await handleImageOptimization(request, handlers);
expect(fetchCount).toBe(2);
expect(response.status).toBe(400);
expect(await response.text()).toBe("The requested resource is not an allowed image type");
});
it("returns 400 for backslash open redirect (/\\evil.com)", async () => {
const { handleImageOptimization } =
await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_next/image?url=%2F%5Cevil.com&w=640&q=75");
const handlers = {
fetchAsset: async () => new Response("should not be called", { status: 200 }),
};
const response = await handleImageOptimization(request, handlers);
expect(response.status).toBe(400);
});
it("does not call fetchAsset for backslash URLs", async () => {
const { handleImageOptimization } =
await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request(
"http://localhost/_next/image?url=%2F%5Cgoogle.com%2Fimg.jpg&w=640&q=75",
);
let fetchCalled = false;
const handlers = {
fetchAsset: async () => {
fetchCalled = true;
return new Response("", { status: 200 });
},
};
const response = await handleImageOptimization(request, handlers);
expect(response.status).toBe(400);
expect(fetchCalled).toBe(false);
});
it("blocks SVG content type", async () => {
const { handleImageOptimization } =
await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_next/image?url=%2Fmalicious.svg&w=640&q=75");
const handlers = {
fetchAsset: async () =>
new Response("<svg><script>alert(1)</script></svg>", {
status: 200,
headers: { "Content-Type": "image/svg+xml" },
}),
};
const response = await handleImageOptimization(request, handlers);
expect(response.status).toBe(400);
expect(await response.text()).toBe("The requested resource is not an allowed image type");
});
it("blocks text/html content type", async () => {
const { handleImageOptimization } =
await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_next/image?url=%2Ffake.jpg&w=640&q=75");
const handlers = {
fetchAsset: async () =>
new Response("<html><script>alert(1)</script></html>", {
status: 200,
headers: { "Content-Type": "text/html" },
}),
};
const response = await handleImageOptimization(request, handlers);
expect(response.status).toBe(400);
});
it("blocks responses with no Content-Type", async () => {
const { handleImageOptimization } =
await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=75");
const handlers = {
fetchAsset: async () => new Response("data", { status: 200 }),
};
const response = await handleImageOptimization(request, handlers);
expect(response.status).toBe(400);
});
it("sets Content-Security-Policy header on fallback responses", async () => {
const { handleImageOptimization } =
await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=75");
const handlers = {
fetchAsset: async () =>
new Response("image-data", {
status: 200,
headers: { "Content-Type": "image/jpeg" },
}),
};
const response = await handleImageOptimization(request, handlers);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Security-Policy")).toBe(
"script-src 'none'; frame-src 'none'; sandbox;",
);
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(response.headers.get("Content-Disposition")).toBe("inline");
});
it("sets Content-Security-Policy header on transformed responses", async () => {
const { handleImageOptimization } =
await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=90", {
headers: { Accept: "image/webp" },
});
const handlers = {
fetchAsset: async () =>
new Response("original", {
status: 200,
headers: { "Content-Type": "image/jpeg" },
}),
transformImage: async (
_body: ReadableStream,
options: { width: number; format: string; quality: number },
) => new Response("transformed", { headers: { "Content-Type": options.format } }),
};
const response = await handleImageOptimization(request, handlers, undefined, {
qualities: [90],
});
expect(response.status).toBe(200);
expect(response.headers.get("Content-Security-Policy")).toBe(
"script-src 'none'; frame-src 'none'; sandbox;",
);
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(response.headers.get("Content-Disposition")).toBe("inline");
});
it("overrides unsafe Content-Type from transform handler", async () => {
const { handleImageOptimization } =
await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=90", {
headers: { Accept: "image/webp" },
});
const handlers = {
fetchAsset: async () =>
new Response("original", {
status: 200,
headers: { "Content-Type": "image/jpeg" },
}),
transformImage: async () =>
new Response("transformed", { headers: { "Content-Type": "text/html" } }),
};
const response = await handleImageOptimization(request, handlers, undefined, {
qualities: [90],
});
expect(response.status).toBe(200);
// Should override to the negotiated format, not pass through text/html
expect(response.headers.get("Content-Type")).toBe("image/webp");
});
it("allows SVG passthrough with dangerouslyAllowSVG: true", async () => {
const { handleImageOptimization } =
await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_next/image?url=%2Flogo.svg&w=640&q=75");
const handlers = {
fetchAsset: async () =>
new Response('<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>', {
status: 200,
headers: { "Content-Type": "image/svg+xml" },
}),
};
const response = await handleImageOptimization(request, handlers, undefined, {
dangerouslyAllowSVG: true,
});
expect(response.status).toBe(200);
expect(await response.text()).toBe('<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>');
expect(response.headers.get("Content-Type")).toBe("image/svg+xml");
});
it("still blocks SVG when dangerouslyAllowSVG is false", async () => {
const { handleImageOptimization } =
await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_next/image?url=%2Flogo.svg&w=640&q=75");
const handlers = {
fetchAsset: async () =>
new Response("<svg></svg>", {
status: 200,
headers: { "Content-Type": "image/svg+xml" },
}),
};
const response = await handleImageOptimization(request, handlers, undefined, {
dangerouslyAllowSVG: false,
});
expect(response.status).toBe(400);
});
it("SVG passthrough skips transformImage", async () => {
const { handleImageOptimization } =
await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_next/image?url=%2Flogo.svg&w=640&q=75");
let transformCalled = false;
const handlers = {
fetchAsset: async () =>
new Response("<svg></svg>", {
status: 200,
headers: { "Content-Type": "image/svg+xml" },
}),
transformImage: async () => {
transformCalled = true;
return new Response("transformed");
},
};
const response = await handleImageOptimization(request, handlers, undefined, {
dangerouslyAllowSVG: true,
});
expect(response.status).toBe(200);
expect(transformCalled).toBe(false);
expect(await response.text()).toBe("<svg></svg>");
});
it("applies security headers on SVG passthrough", async () => {
const { handleImageOptimization } =
await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_next/image?url=%2Flogo.svg&w=640&q=75");
const handlers = {
fetchAsset: async () =>
new Response("<svg></svg>", {
status: 200,
headers: { "Content-Type": "image/svg+xml" },
}),
};
const response = await handleImageOptimization(request, handlers, undefined, {
dangerouslyAllowSVG: true,
});
expect(response.status).toBe(200);
expect(response.headers.get("Content-Security-Policy")).toBe(
"script-src 'none'; frame-src 'none'; sandbox;",
);
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(response.headers.get("Content-Disposition")).toBe("inline");
});
it("applies custom contentDispositionType", async () => {
const { handleImageOptimization } =
await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=75");
const handlers = {
fetchAsset: async () =>
new Response("image-data", {
status: 200,
headers: { "Content-Type": "image/jpeg" },
}),
};
const response = await handleImageOptimization(request, handlers, undefined, {
contentDispositionType: "attachment",
});
expect(response.status).toBe(200);
expect(response.headers.get("Content-Disposition")).toBe("attachment");
});
it("defaults Content-Disposition to inline when contentDispositionType is invalid", async () => {
const { handleImageOptimization } =
await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=75");
const handlers = {
fetchAsset: async () =>
new Response("image-data", {
status: 200,
headers: { "Content-Type": "image/jpeg" },
}),
};
const response = await handleImageOptimization(request, handlers, undefined, {
contentDispositionType: "bogus" as "inline",
});
expect(response.status).toBe(200);
expect(response.headers.get("Content-Disposition")).toBe("inline");
});
it("applies custom contentSecurityPolicy", async () => {
const { handleImageOptimization } =
await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=75");
const handlers = {
fetchAsset: async () =>
new Response("image-data", {
status: 200,
headers: { "Content-Type": "image/jpeg" },
}),
};
const customCSP = "default-src 'self'; script-src 'none';";
const response = await handleImageOptimization(request, handlers, undefined, {
contentSecurityPolicy: customCSP,
});
expect(response.status).toBe(200);
expect(response.headers.get("Content-Security-Policy")).toBe(customCSP);
});
it("default behavior unchanged when no imageConfig provided", async () => {
const { handleImageOptimization } =
await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_next/image?url=%2Fimg.jpg&w=640&q=75");
const handlers = {
fetchAsset: async () =>
new Response("image-data", {
status: 200,
headers: { "Content-Type": "image/jpeg" },
}),
};
const response = await handleImageOptimization(request, handlers);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Security-Policy")).toBe(
"script-src 'none'; frame-src 'none'; sandbox;",
);
expect(response.headers.get("Content-Disposition")).toBe("inline");
});
});
describe("next/navigation enhancements", () => {
it("exports ReadonlyURLSearchParams runtime class", async () => {
const nav = await import("../packages/vinext/src/shims/navigation.js");
expect(typeof nav.ReadonlyURLSearchParams).toBe("function");
expect(typeof nav.useServerInsertedHTML).toBe("function");
});
it("ReadonlyURLSearchParams preserves reads and blocks mutation methods", async () => {
const { ReadonlyURLSearchParams } = await import("../packages/vinext/src/shims/navigation.js");
const searchParams = new ReadonlyURLSearchParams("foo=bar&foo=baz&zap=zazzle");
expect(searchParams).toBeInstanceOf(URLSearchParams);
expect(searchParams).toBeInstanceOf(ReadonlyURLSearchParams);
expect(searchParams.get("foo")).toBe("bar");
expect(searchParams.getAll("foo")).toEqual(["bar", "baz"]);
expect(searchParams.toString()).toBe("foo=bar&foo=baz&zap=zazzle");
expect(() => searchParams.append("x", "1")).toThrow(
"Method unavailable on `ReadonlyURLSearchParams`.",
);
expect(() => searchParams.delete("foo")).toThrow(
"Method unavailable on `ReadonlyURLSearchParams`.",
);
expect(() => searchParams.set("foo", "qux")).toThrow(
"Method unavailable on `ReadonlyURLSearchParams`.",
);
expect(() => searchParams.sort()).toThrow("Method unavailable on `ReadonlyURLSearchParams`.");
expect(searchParams.toString()).toBe("foo=bar&foo=baz&zap=zazzle");
});
it("useSearchParams returns a readonly wrapper on the server path", async () => {
const { ReadonlyURLSearchParams, setNavigationContext, useSearchParams } =
await import("../packages/vinext/src/shims/navigation.js");
try {
setNavigationContext({
pathname: "/readonly-test",
searchParams: new URLSearchParams("foo=bar&foo=baz"),
params: {},
});
const searchParams = useSearchParams();
expect(searchParams).toBeInstanceOf(ReadonlyURLSearchParams);
expect(searchParams.getAll("foo")).toEqual(["bar", "baz"]);
expect(() => searchParams.set("foo", "qux")).toThrow(
"Method unavailable on `ReadonlyURLSearchParams`.",
);
} finally {
setNavigationContext(null);
}
});
it("useSearchParams reuses the same readonly wrapper for the same server context", async () => {
const { setNavigationContext, useSearchParams } =
await import("../packages/vinext/src/shims/navigation.js");
try {
const ctx = {
pathname: "/readonly-test",
searchParams: new URLSearchParams("foo=bar"),
params: {},
};
setNavigationContext(ctx);
const first = useSearchParams();
const second = useSearchParams();
expect(first).toBe(second);
} finally {
setNavigationContext(null);
}
});
it("useSearchParams keeps wrapper identity stable across concurrent ALS-scoped requests", async () => {
const { runWithNavigationContext } =
await import("../packages/vinext/src/shims/navigation-state.js");
const { setNavigationContext, useSearchParams } =
await import("../packages/vinext/src/shims/navigation.js");
let releaseInterleave!: () => void;
const waitForInterleave = new Promise<void>((resolve) => {
releaseInterleave = resolve;
});
async function runRequest(query: string, pathname: string) {
return runWithNavigationContext(async () => {
setNavigationContext({
pathname,
searchParams: new URLSearchParams(query),
params: {},
});
const first = useSearchParams();
await waitForInterleave;
const second = useSearchParams();
return {
first,
second,
value: first.toString(),
};
});
}
const requestA = runRequest("a=1", "/request-a");
const requestB = runRequest("b=2", "/request-b");
await Promise.resolve();
releaseInterleave();
const [a, b] = await Promise.all([requestA, requestB]);
expect(a.first).toBe(a.second);
expect(b.first).toBe(b.second);
expect(a.value).toBe("a=1");
expect(b.value).toBe("b=2");
expect(a.first).not.toBe(b.first);
});
it("useServerInsertedHTML is a no-op function", async () => {
const { useServerInsertedHTML } = await import("../packages/vinext/src/shims/navigation.js");
// Should not throw
expect(() => useServerInsertedHTML(() => null)).not.toThrow();
});
});
describe("next/legacy/image shim", () => {
it("renders LegacyImage with layout=fill as modern Image with fill prop", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const LegacyImage = (await import("../packages/vinext/src/shims/legacy-image.js")).default;
const html = renderToStaticMarkup(
React.createElement(LegacyImage, {
src: "/photo.jpg",
alt: "Test",
layout: "fill",
objectFit: "cover",
objectPosition: "center",
}),
);
expect(html).toContain("photo.jpg");
expect(html).toContain("alt");
// fill mode should produce absolute positioning styles
expect(html).toContain("position:absolute");
});
it("renders LegacyImage with layout=intrinsic using width/height", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const LegacyImage = (await import("../packages/vinext/src/shims/legacy-image.js")).default;
const html = renderToStaticMarkup(
React.createElement(LegacyImage, {
src: "/photo.jpg",
alt: "Test",
layout: "intrinsic",
width: 640,
height: 480,
}),
);
expect(html).toContain('width="640"');
expect(html).toContain('height="480"');
});
it("renders LegacyImage with string width/height (converts to number)", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const LegacyImage = (await import("../packages/vinext/src/shims/legacy-image.js")).default;
const html = renderToStaticMarkup(
React.createElement(LegacyImage, {
src: "/photo.jpg",
alt: "Test",
width: "200",
height: "150",
}),
);
expect(html).toContain('width="200"');
expect(html).toContain('height="150"');
});
});
describe("next/error shim", () => {
it("renders 404 error page", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const ErrorComponent = (await import("../packages/vinext/src/shims/error.js")).default;
const html = renderToStaticMarkup(React.createElement(ErrorComponent, { statusCode: 404 }));
expect(html).toContain("404");
expect(html).toContain("could not be found");
});
it("renders 500 error page", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const ErrorComponent = (await import("../packages/vinext/src/shims/error.js")).default;
const html = renderToStaticMarkup(React.createElement(ErrorComponent, { statusCode: 500 }));
expect(html).toContain("500");
expect(html).toContain("Internal Server Error");
});
it("renders custom title", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const ErrorComponent = (await import("../packages/vinext/src/shims/error.js")).default;
const html = renderToStaticMarkup(
React.createElement(ErrorComponent, { statusCode: 403, title: "Forbidden" }),
);
expect(html).toContain("403");
expect(html).toContain("Forbidden");
});
});
// next/app default export
//
// Ported from Next.js: packages/next/src/pages/_app.tsx
// https://github.com/vercel/next.js/blob/canary/packages/next/src/pages/_app.tsx
//
// Pages Router fixtures very commonly do:
// import App from "next/app";
// export default class MyApp extends App { ... }
// or call App.getInitialProps(appContext) from a custom getInitialProps.
// Without a runtime default export, every such fixture fails to build with
// "[MISSING_EXPORT] 'default' is not exported by ...shims/app.js".
describe("next/app shim", () => {
it("exports a React class component as default", async () => {
const React = await import("react");
const AppDefault = (await import("../packages/vinext/src/shims/app.js")).default;
expect(typeof AppDefault).toBe("function");
// Class component: prototype must have a render method, instances must
// be instances of React.Component.
expect(typeof AppDefault.prototype.render).toBe("function");
const instance = new AppDefault({ Component: () => null, pageProps: {} });
expect(instance).toBeInstanceOf(React.Component);
});
it("default App.render() returns <Component {...pageProps} />", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const AppDefault = (await import("../packages/vinext/src/shims/app.js")).default;
function Page(props: { greeting: string }) {
return React.createElement("p", null, props.greeting);
}
// Use explicit generics so React.createElement's prop type inference
// lines up with `AppProps<{ greeting: string }>`.
const html = renderToStaticMarkup(
React.createElement(AppDefault<unknown, { greeting: string }>, {
Component: Page,
pageProps: { greeting: "hello world" },
}),
);
expect(html).toBe("<p>hello world</p>");
});
it("App.getInitialProps is a function and forwards Component.getInitialProps result as pageProps", async () => {
const AppDefault = (await import("../packages/vinext/src/shims/app.js")).default;
expect(typeof AppDefault.getInitialProps).toBe("function");
// origGetInitialProps is preserved for userland code that introspects it.
expect(typeof AppDefault.origGetInitialProps).toBe("function");
const pageCtx = { req: { url: "/test" } };
const Component = Object.assign(() => null, {
getInitialProps: async (ctx: unknown) => {
expect(ctx).toBe(pageCtx);
return { foo: "bar" };
},
});
const result = await AppDefault.getInitialProps({
Component,
AppTree: () => null,
ctx: pageCtx,
router: {},
});
expect(result).toEqual({ pageProps: { foo: "bar" } });
});
it("App.getInitialProps returns { pageProps: {} } when Component has no getInitialProps", async () => {
const AppDefault = (await import("../packages/vinext/src/shims/app.js")).default;
const result = await AppDefault.getInitialProps({
Component: () => null,
AppTree: () => null,
ctx: {},
router: {},
});
expect(result).toEqual({ pageProps: {} });
});
});
describe("next/constants shim", () => {
it("exports all phase constants", async () => {
const constants = await import("../packages/vinext/src/shims/constants.js");
const constantsFromNext = await import("next/constants");
const normalizeConstants = (mod: Record<string, unknown>) => {
const {
__esModule: _esModule,
default: _default,
CLIENT_STATIC_FILES_RUNTIME_POLYFILLS_SYMBOL,
...rest
} = mod;
return {
...rest,
CLIENT_STATIC_FILES_RUNTIME_POLYFILLS_SYMBOL:
typeof CLIENT_STATIC_FILES_RUNTIME_POLYFILLS_SYMBOL === "symbol"
? CLIENT_STATIC_FILES_RUNTIME_POLYFILLS_SYMBOL.description
: CLIENT_STATIC_FILES_RUNTIME_POLYFILLS_SYMBOL,
};
};
expect(normalizeConstants(constants)).toEqual(normalizeConstants(constantsFromNext));
});
});
describe("next/script SSR rendering", () => {
it("beforeInteractive renders <script> tag in SSR", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Script = (await import("../packages/vinext/src/shims/script.js")).default;
const html = renderToStaticMarkup(
React.createElement(Script, {
src: "https://example.com/analytics.js",
strategy: "beforeInteractive",
id: "analytics",
}),
);
expect(html).toContain("<script");
expect(html).toContain('src="https://example.com/analytics.js"');
expect(html).toContain('id="analytics"');
});
it("afterInteractive emits a preload <link> (no <script> tag) in SSR", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Script = (await import("../packages/vinext/src/shims/script.js")).default;
const html = renderToStaticMarkup(
React.createElement(Script, {
src: "https://example.com/chat.js",
strategy: "afterInteractive",
}),
);
// afterInteractive does not render a <script> tag during SSR,
// but it does emit <link rel="preload" as="script"> via React Float
// so the script is fetched while HTML streams.
expect(html).toContain('<link rel="preload"');
expect(html).toContain('href="https://example.com/chat.js"');
expect(html).toContain('as="script"');
expect(html).not.toContain("<script");
});
it("lazyOnload returns null (no preload) in SSR", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Script = (await import("../packages/vinext/src/shims/script.js")).default;
const html = renderToStaticMarkup(
React.createElement(Script, {
src: "https://example.com/tracking.js",
strategy: "lazyOnload",
}),
);
expect(html).toBe("");
});
it("default strategy (no strategy prop) emits a preload <link> in SSR", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Script = (await import("../packages/vinext/src/shims/script.js")).default;
const html = renderToStaticMarkup(
React.createElement(Script, {
src: "https://example.com/default.js",
}),
);
// Default is afterInteractive → preload link only, no <script> tag.
expect(html).toContain('<link rel="preload"');
expect(html).toContain('href="https://example.com/default.js"');
expect(html).toContain('as="script"');
expect(html).not.toContain("<script");
});
it("beforeInteractive with dangerouslySetInnerHTML renders inline script", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Script = (await import("../packages/vinext/src/shims/script.js")).default;
const html = renderToStaticMarkup(
React.createElement(Script, {
strategy: "beforeInteractive",
id: "inline-script",
dangerouslySetInnerHTML: { __html: "console.log('hello')" },
}),
);
expect(html).toContain("<script");
expect(html).toContain('id="inline-script"');
expect(html).toContain("console.log('hello')");
});
it("beforeInteractive escapes </script> in dangerouslySetInnerHTML", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Script = (await import("../packages/vinext/src/shims/script.js")).default;
const html = renderToStaticMarkup(
React.createElement(Script, {
strategy: "beforeInteractive",
id: "escape-test",
dangerouslySetInnerHTML: {
__html: 'var x = "</script><img src=x onerror=alert(1)>";',
},
}),
);
// The raw </script> must NOT appear — it would break the tag boundary
expect(html).not.toContain("</script><img");
// The escaped form should be present instead
expect(html).toContain("<\\/script>");
});
it("beforeInteractive escapes </script> case-insensitively", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Script = (await import("../packages/vinext/src/shims/script.js")).default;
const html = renderToStaticMarkup(
React.createElement(Script, {
strategy: "beforeInteractive",
id: "case-test",
dangerouslySetInnerHTML: {
__html: 'var x = "</SCRIPT><img src=x onerror=alert(1)>";',
},
}),
);
// Mixed-case </SCRIPT> must also be escaped
expect(html).not.toContain("</SCRIPT>");
expect(html).toContain("<\\/SCRIPT>");
});
it("exports handleClientScriptLoad and initScriptLoader", async () => {
const scriptModule = await import("../packages/vinext/src/shims/script.js");
expect(typeof scriptModule.handleClientScriptLoad).toBe("function");
expect(typeof scriptModule.initScriptLoader).toBe("function");
});
});
describe("next/dist/* internal import shims", () => {
it("app-router-context exports AppRouterContext and types", async () => {
const mod = await import("../packages/vinext/src/shims/internal/app-router-context.js");
expect(mod.AppRouterContext).toBeDefined();
expect(mod.GlobalLayoutRouterContext).toBeDefined();
expect(mod.LayoutRouterContext).toBeDefined();
expect(mod.MissingSlotContext).toBeDefined();
expect(mod.TemplateContext).toBeDefined();
});
it("app-router-context imports when React.createContext is unavailable", async () => {
vi.resetModules();
vi.doMock("react", () => ({ createContext: undefined }));
try {
const mod = await import("../packages/vinext/src/shims/internal/app-router-context.js");
expect(mod.AppRouterContext).toBeNull();
expect(mod.GlobalLayoutRouterContext).toBeNull();
expect(mod.LayoutRouterContext).toBeNull();
expect(mod.MissingSlotContext).toBeNull();
expect(mod.TemplateContext).toBeNull();
} finally {
vi.doUnmock("react");
vi.resetModules();
}
});
it("utils exports NEXT_DATA type helpers", async () => {
const mod = await import("../packages/vinext/src/shims/internal/utils.js");
expect(typeof mod.execOnce).toBe("function");
expect(typeof mod.getLocationOrigin).toBe("function");
expect(typeof mod.getURL).toBe("function");
// execOnce should only call the function once
let count = 0;
const fn = mod.execOnce(() => ++count);
fn();
fn();
fn();
expect(count).toBe(1);
});
it("api-utils exports NextApiRequestCookies type", async () => {
// This module is primarily type-only, but should resolve without errors
const mod = await import("../packages/vinext/src/shims/internal/api-utils.js");
expect(mod).toBeDefined();
});
it("cookies shim re-exports RequestCookies and ResponseCookies", async () => {
const mod = await import("../packages/vinext/src/shims/internal/cookies.js");
expect(mod.RequestCookies).toBeDefined();
expect(mod.ResponseCookies).toBeDefined();
});
it("work-unit-async-storage exports AsyncLocalStorage instances", async () => {
const mod = await import("../packages/vinext/src/shims/internal/work-unit-async-storage.js");
expect(mod.workUnitAsyncStorage).toBeDefined();
expect(mod.requestAsyncStorage).toBeDefined();
// Both should be the same AsyncLocalStorage instance
expect(mod.workUnitAsyncStorage).toBe(mod.requestAsyncStorage);
});
it("router-context exports RouterContext as a React context", async () => {
const mod = await import("../packages/vinext/src/shims/internal/router-context.js");
expect(mod.RouterContext).toBeDefined();
// Must be a real React context object (has Provider and Consumer)
expect(mod.RouterContext.Provider).toBeDefined();
expect(mod.RouterContext.Consumer).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Cloudflare KV CacheHandler
// ---------------------------------------------------------------------------
describe("KVCacheHandler", () => {
// In-memory mock of Cloudflare KV namespace
function createMockKV() {
const store = new Map<string, { value: string; expirationTtl?: number }>();
return {
store,
async get(key: string): Promise<string | null> {
return store.get(key)?.value ?? null;
},
async put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void> {
store.set(key, { value, expirationTtl: options?.expirationTtl });
},
async delete(key: string): Promise<void> {
store.delete(key);
},
async list(options?: { prefix?: string; limit?: number; cursor?: string }) {
const prefix = options?.prefix ?? "";
const keys: Array<{ name: string }> = [];
for (const k of store.keys()) {
if (k.startsWith(prefix)) keys.push({ name: k });
}
return { keys, list_complete: true };
},
};
}
it("stores and retrieves a cache entry", async () => {
const { KVCacheHandler } =
await import("../packages/cloudflare/src/cache/kv-data-adapter.runtime.js");
const kv = createMockKV();
const handler = new KVCacheHandler(kv as any);
await handler.set("test-key", {
kind: "PAGES",
html: "<h1>Hello</h1>",
pageData: { props: {} },
headers: undefined,
status: 200,
});
const result = await handler.get("test-key");
expect(result).not.toBeNull();
expect(result!.value).not.toBeNull();
expect(result!.value!.kind).toBe("PAGES");
if (result!.value!.kind === "PAGES") {
expect(result!.value!.html).toBe("<h1>Hello</h1>");
expect(result!.value!.status).toBe(200);
}
});
it("returns null for missing keys", async () => {
const { KVCacheHandler } =
await import("../packages/cloudflare/src/cache/kv-data-adapter.runtime.js");
const kv = createMockKV();
const handler = new KVCacheHandler(kv as any);
const result = await handler.get("nonexistent");
expect(result).toBeNull();
});
it("handles tag-based invalidation", async () => {
const { KVCacheHandler } =
await import("../packages/cloudflare/src/cache/kv-data-adapter.runtime.js");
const kv = createMockKV();
const handler = new KVCacheHandler(kv as any);
await handler.set(
"tagged-entry",
{
kind: "FETCH",
data: { headers: {}, body: '{"result":1}', url: "test" },
tags: ["my-tag"],
revalidate: 60,
},
{ revalidate: 60, tags: ["my-tag"] },
);
// Before invalidation — entry exists
const before = await handler.get("tagged-entry");
expect(before).not.toBeNull();
// Invalidate the tag
await handler.revalidateTag("my-tag");
// After invalidation — entry should be treated as miss
const after = await handler.get("tagged-entry");
expect(after).toBeNull();
});
it("returns stale entry when past revalidation time", async () => {
const { KVCacheHandler } =
await import("../packages/cloudflare/src/cache/kv-data-adapter.runtime.js");
const kv = createMockKV();
const handler = new KVCacheHandler(kv as any);
// Set with very short revalidation (already expired)
await handler.set(
"stale-key",
{
kind: "PAGES",
html: "<h1>Stale</h1>",
pageData: {},
headers: undefined,
status: 200,
},
{ revalidate: -1 }, // already past
);
// Manually fix the revalidateAt to be in the past
const raw = await kv.get("cache:stale-key");
const entry = JSON.parse(raw!);
entry.revalidateAt = Date.now() - 1000;
await kv.put("cache:stale-key", JSON.stringify(entry));
const result = await handler.get("stale-key");
expect(result).not.toBeNull();
expect(result!.cacheState).toBe("stale");
expect(result!.value!.kind).toBe("PAGES");
});
it("serializes and restores APP_PAGE with rscData ArrayBuffer", async () => {
const { KVCacheHandler } =
await import("../packages/cloudflare/src/cache/kv-data-adapter.runtime.js");
const kv = createMockKV();
const handler = new KVCacheHandler(kv as any);
const originalData = new TextEncoder().encode("RSC payload data");
await handler.set("app-page-key", {
kind: "APP_PAGE",
html: "<div>App page</div>",
rscData: originalData.buffer as ArrayBuffer,
headers: { "x-custom": "value" },
postponed: undefined,
status: 200,
});
const result = await handler.get("app-page-key");
expect(result).not.toBeNull();
expect(result!.value!.kind).toBe("APP_PAGE");
if (result!.value!.kind === "APP_PAGE") {
expect(result!.value!.html).toBe("<div>App page</div>");
// rscData should be restored as ArrayBuffer
expect(result!.value!.rscData).toBeInstanceOf(ArrayBuffer);
const restored = new TextDecoder().decode(result!.value!.rscData!);
expect(restored).toBe("RSC payload data");
}
});
it("serializes and restores APP_ROUTE with body ArrayBuffer", async () => {
const { KVCacheHandler } =
await import("../packages/cloudflare/src/cache/kv-data-adapter.runtime.js");
const kv = createMockKV();
const handler = new KVCacheHandler(kv as any);
const body = new TextEncoder().encode('{"ok":true}');
await handler.set("route-key", {
kind: "APP_ROUTE",
body: body.buffer as ArrayBuffer,
status: 200,
headers: { "content-type": "application/json" },
});
const result = await handler.get("route-key");
expect(result).not.toBeNull();
if (result!.value!.kind === "APP_ROUTE") {
const restored = new TextDecoder().decode(result!.value!.body);
expect(restored).toBe('{"ok":true}');
}
});
it("sets KV expiration TTL based on revalidation period", async () => {
const { KVCacheHandler } =
await import("../packages/cloudflare/src/cache/kv-data-adapter.runtime.js");
const kv = createMockKV();
const handler = new KVCacheHandler(kv as any);
await handler.set(
"ttl-key",
{
kind: "PAGES",
html: "<h1>TTL</h1>",
pageData: {},
headers: undefined,
status: 200,
},
{ revalidate: 60 }, // 60 seconds
);
// The KV entry should have an expiration TTL set
const stored = kv.store.get("cache:ttl-key");
expect(stored).toBeDefined();
expect(stored!.expirationTtl).toBeDefined();
// KV TTL is always 30 days (2592000s) regardless of revalidation period.
// Staleness is tracked via revalidateAt in the stored JSON, not KV eviction.
// Tying TTL to revalidation period would cause frequently-revalidated pages
// (e.g. revalidate=5) to be evicted quickly under low traffic, forcing a
// blocking fresh render on the next request instead of serving stale content.
expect(stored!.expirationTtl).toBe(30 * 24 * 3600);
});
it("handles multiple tag invalidation in parallel", async () => {
const { KVCacheHandler } =
await import("../packages/cloudflare/src/cache/kv-data-adapter.runtime.js");
const kv = createMockKV();
const handler = new KVCacheHandler(kv as any);
await handler.set(
"multi-tag",
{
kind: "FETCH",
data: { headers: {}, body: "{}", url: "test" },
tags: ["tag-a", "tag-b"],
revalidate: 0,
},
{ tags: ["tag-a", "tag-b"] },
);
// Invalidate both tags at once
await handler.revalidateTag(["tag-a", "tag-b"]);
const result = await handler.get("multi-tag");
expect(result).toBeNull();
});
it("handles corrupted KV entries gracefully", async () => {
const { KVCacheHandler } =
await import("../packages/cloudflare/src/cache/kv-data-adapter.runtime.js");
const kv = createMockKV();
const handler = new KVCacheHandler(kv as any);
// Put corrupted data directly
await kv.put("cache:corrupt-key", "not valid json {{{");
const result = await handler.get("corrupt-key");
expect(result).toBeNull();
// The corrupted entry should be cleaned up
expect(await kv.get("cache:corrupt-key")).toBeNull();
});
});
// ─── server-only / client-only shims ─────────────────────────────────────────
describe("server-only shim", () => {
it("can be imported without error", async () => {
const mod = await import("../packages/vinext/src/shims/server-only.js");
expect(mod).toBeDefined();
});
it("exports nothing (empty marker module)", async () => {
const mod = await import("../packages/vinext/src/shims/server-only.js");
// The module should have no named exports (just the default module namespace)
const keys = Object.keys(mod).filter((k) => k !== "__esModule" && k !== "default");
expect(keys).toHaveLength(0);
});
});
describe("client-only shim", () => {
it("can be imported without error", async () => {
const mod = await import("../packages/vinext/src/shims/client-only.js");
expect(mod).toBeDefined();
});
it("exports nothing (empty marker module)", async () => {
const mod = await import("../packages/vinext/src/shims/client-only.js");
const keys = Object.keys(mod).filter((k) => k !== "__esModule" && k !== "default");
expect(keys).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// next/link — onNavigate / NavigateEvent (View Transitions support, Issue #38)
// ---------------------------------------------------------------------------
describe("next/link onNavigate / NavigateEvent", () => {
it("exports Link as default and useLinkStatus as named export", async () => {
const mod = await import("../packages/vinext/src/shims/link.js");
expect(typeof mod.default).toBe("object"); // forwardRef returns an object
expect(typeof mod.useLinkStatus).toBe("function");
});
it("NavigateEvent.preventDefault() sets defaultPrevented to true", () => {
// Mirrors the NavigateEvent construction in the Link click handler
let prevented = false;
const navEvent = {
url: new URL("/about", "http://localhost"),
preventDefault() {
prevented = true;
},
get defaultPrevented() {
return prevented;
},
};
expect(navEvent.defaultPrevented).toBe(false);
navEvent.preventDefault();
expect(navEvent.defaultPrevented).toBe(true);
});
it("NavigateEvent.defaultPrevented is false when preventDefault is not called", () => {
let prevented = false;
const navEvent = {
url: new URL("/products/1", "http://localhost"),
preventDefault() {
prevented = true;
},
get defaultPrevented() {
return prevented;
},
};
expect(navEvent.defaultPrevented).toBe(false);
expect(navEvent.url.pathname).toBe("/products/1");
});
it("onNavigate callback receives event with correct url", () => {
// Simulate what the Link component does in its click handler
const resolvedHref = "/view-transitions/posts/42";
const navUrl = new URL(resolvedHref, "http://localhost:3000");
let prevented = false;
const navEvent = {
url: navUrl,
preventDefault() {
prevented = true;
},
get defaultPrevented() {
return prevented;
},
};
// Simulated TransitionLink-style callback
const onNavigate = (event: typeof navEvent) => {
event.preventDefault();
};
onNavigate(navEvent);
expect(navEvent.defaultPrevented).toBe(true);
expect(navEvent.url.pathname).toBe("/view-transitions/posts/42");
});
it("multiple preventDefault() calls are idempotent", () => {
let prevented = false;
const navEvent = {
url: new URL("/", "http://localhost"),
preventDefault() {
prevented = true;
},
get defaultPrevented() {
return prevented;
},
};
navEvent.preventDefault();
navEvent.preventDefault();
navEvent.preventDefault();
expect(navEvent.defaultPrevented).toBe(true);
});
});
// ---------------------------------------------------------------------------
// vinext:react-canary — ViewTransition & addTransitionType polyfills (Issue #42)
// ---------------------------------------------------------------------------
describe("vinext:react-canary transform logic", () => {
// These tests verify the regex patterns used by the vinext:react-canary plugin
// to detect and rewrite imports of React canary APIs.
const canaryImportRegex =
/import\s*\{[^}]*(ViewTransition|addTransitionType)[^}]*\}\s*from\s*['"]react['"]/;
it("detects ViewTransition import from react", () => {
const code = `import { ViewTransition } from "react";`;
expect(canaryImportRegex.test(code)).toBe(true);
});
it("detects addTransitionType import from react", () => {
const code = `import { addTransitionType } from "react";`;
expect(canaryImportRegex.test(code)).toBe(true);
});
it("detects ViewTransition alongside other React imports", () => {
const code = `import { useState, ViewTransition, useEffect } from "react";`;
expect(canaryImportRegex.test(code)).toBe(true);
});
it("detects addTransitionType alongside other React imports", () => {
const code = `import { useRef, addTransitionType } from 'react';`;
expect(canaryImportRegex.test(code)).toBe(true);
});
it("detects both canary APIs in a single import", () => {
const code = `import { ViewTransition, addTransitionType } from "react";`;
expect(canaryImportRegex.test(code)).toBe(true);
});
it("does NOT match imports from other modules", () => {
const code = `import { ViewTransition } from "some-other-lib";`;
expect(canaryImportRegex.test(code)).toBe(false);
});
it("does NOT match non-canary React imports", () => {
const code = `import { useState, useEffect } from "react";`;
expect(canaryImportRegex.test(code)).toBe(false);
});
it("does NOT match default React import", () => {
const code = `import React from "react";`;
expect(canaryImportRegex.test(code)).toBe(false);
});
it("does NOT match namespace React import", () => {
const code = `import * as React from "react";`;
expect(canaryImportRegex.test(code)).toBe(false);
});
it("rewrites all 'from react' occurrences in a file with canary imports", () => {
const code = [
`import { useState } from "react";`,
`import { ViewTransition } from "react";`,
``,
`export default function Template({ children }) {`,
` return <ViewTransition>{children}</ViewTransition>;`,
`}`,
].join("\n");
// The transform replaces all `from "react"` in the file
const result = code.replace(/from\s*['"]react['"]/g, 'from "virtual:vinext-react-canary"');
expect(result).toContain('from "virtual:vinext-react-canary"');
expect(result).not.toContain('from "react"');
// Both import lines should be rewritten
expect(result.match(/virtual:vinext-react-canary/g)?.length).toBe(2);
});
it("handles single-quoted imports", () => {
const code = `import { ViewTransition } from 'react';`;
expect(canaryImportRegex.test(code)).toBe(true);
const result = code.replace(/from\s*['"]react['"]/g, 'from "virtual:vinext-react-canary"');
expect(result).toBe(`import { ViewTransition } from "virtual:vinext-react-canary";`);
});
it("handles multiline imports", () => {
const code = `import {\n ViewTransition,\n useState,\n} from "react";`;
expect(canaryImportRegex.test(code)).toBe(true);
});
});
describe("ViewTransition polyfill behavior", () => {
it("provides a passthrough component when React lacks ViewTransition", () => {
// Simulate the polyfill logic from the virtual module
const React = { ViewTransition: undefined };
const ViewTransition =
React.ViewTransition ||
function ViewTransition({ children }: { children: any }) {
return children;
};
// ViewTransition should be a function
expect(typeof ViewTransition).toBe("function");
// It should pass through children unchanged
const children = { type: "div", props: {} };
const result = ViewTransition({ children });
expect(result).toBe(children);
});
it("uses native ViewTransition when React exports it", () => {
// Simulate React canary that HAS ViewTransition
const nativeViewTransition = function NativeViewTransition({ children }: { children: any }) {
return { wrapped: children };
};
const React = { ViewTransition: nativeViewTransition };
const ViewTransition =
React.ViewTransition ||
function ViewTransition({ children }: { children: any }) {
return children;
};
expect(ViewTransition).toBe(nativeViewTransition);
});
it("provides a no-op addTransitionType when React lacks it", () => {
const React = { addTransitionType: undefined };
const addTransitionType = React.addTransitionType || function addTransitionType() {};
expect(typeof addTransitionType).toBe("function");
// Should not throw when called
expect(() => addTransitionType()).not.toThrow();
});
it("uses native addTransitionType when React exports it", () => {
const nativeAddTransitionType = function nativeAddTransitionType(type: string) {
return type;
};
const React = { addTransitionType: nativeAddTransitionType };
const addTransitionType = React.addTransitionType || function addTransitionType() {};
expect(addTransitionType).toBe(nativeAddTransitionType);
});
});
// ---------------------------------------------------------------------------
// next/head SSR security tests
// ---------------------------------------------------------------------------
describe("next/head SSR security", () => {
async function collectHeadHTML(children: React.ReactElement[]) {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const {
default: Head,
resetSSRHead,
getSSRHeadHTML,
} = await import("../packages/vinext/src/shims/head.js");
resetSSRHead();
// Render Head with children — SSR path collects elements
renderToStaticMarkup(React.createElement(Head, null, ...children));
return getSSRHeadHTML();
}
it("escapes HTML special characters in title children", async () => {
const React = await import("react");
const html = await collectHeadHTML([
React.createElement("title", null, '</title><script>alert("xss")</script>'),
]);
// The injected script tag must be escaped, not raw
expect(html).not.toContain("<script>");
expect(html).not.toContain("</title><script>");
expect(html).toContain("&lt;/title&gt;&lt;script&gt;");
});
it("escapes ampersands and angle brackets in children", async () => {
const React = await import("react");
const html = await collectHeadHTML([
React.createElement("title", null, "Tom & Jerry < Friends > Foes"),
]);
expect(html).toContain("Tom &amp; Jerry &lt; Friends &gt; Foes");
expect(html).not.toContain("Tom & Jerry < Friends > Foes");
});
it("still allows dangerouslySetInnerHTML (intentionally raw)", async () => {
const React = await import("react");
const html = await collectHeadHTML([
React.createElement("style", {
dangerouslySetInnerHTML: { __html: "body { color: red; }" },
}),
]);
expect(html).toContain("body { color: red; }");
});
it("escapes </script> in dangerouslySetInnerHTML for script tags", async () => {
const React = await import("react");
const html = await collectHeadHTML([
React.createElement("script", {
dangerouslySetInnerHTML: {
__html: 'var x = "</script><img src=x onerror=alert(1)>";',
},
}),
]);
// The raw </script> must NOT appear in the output
expect(html).not.toContain("</script><img");
// The escaped form preserves the JS string content
expect(html).toContain("<\\/script>");
});
it("escapes </style> in dangerouslySetInnerHTML for style tags", async () => {
const React = await import("react");
const html = await collectHeadHTML([
React.createElement("style", {
dangerouslySetInnerHTML: {
__html: "body::after { content: '</style><img src=x onerror=alert(1)>'; }",
},
}),
]);
// The raw </style> must NOT appear
expect(html).not.toContain("</style><img");
expect(html).toContain("<\\/style>");
});
it("escapes case-insensitive closing tags in dangerouslySetInnerHTML", async () => {
const React = await import("react");
const html = await collectHeadHTML([
React.createElement("script", {
dangerouslySetInnerHTML: {
__html: 'var x = "</SCRIPT><img src=x onerror=alert(1)>";',
},
}),
]);
expect(html).not.toContain("</SCRIPT>");
expect(html).toContain("<\\/SCRIPT>");
});
it("attributes are still properly escaped", async () => {
const React = await import("react");
const html = await collectHeadHTML([
React.createElement("meta", {
name: "description",
content: 'He said "hello" & <goodbye>',
}),
]);
expect(html).toContain("&quot;hello&quot;");
expect(html).toContain("&amp;");
expect(html).toContain("&lt;goodbye&gt;");
});
it("rejects disallowed tag types (iframe)", async () => {
const React = await import("react");
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
const html = await collectHeadHTML([
React.createElement("iframe" as any, { src: "https://evil.com" }),
]);
// After issue #1569, the default head (charset + viewport) is always
// emitted, so the output is never an empty string. The disallowed tag
// must still not appear.
expect(html).not.toContain("<iframe");
expect(html).not.toContain("evil.com");
expect(consoleWarn).toHaveBeenCalledWith(
expect.stringContaining("ignoring disallowed tag <iframe>"),
);
consoleWarn.mockRestore();
});
it("rejects disallowed tag types (object, embed, form)", async () => {
const React = await import("react");
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
const html = await collectHeadHTML([
React.createElement("object" as any, { data: "https://evil.com" }),
React.createElement("embed" as any, { src: "https://evil.com" }),
React.createElement("form" as any, { action: "https://evil.com" }),
]);
expect(html).not.toContain("<object");
expect(html).not.toContain("<embed");
expect(html).not.toContain("<form");
expect(html).not.toContain("evil.com");
consoleWarn.mockRestore();
});
it("allows all valid head tags", async () => {
const React = await import("react");
const { resetSSRHead } = await import("../packages/vinext/src/shims/head.js");
const allowedTags = ["title", "meta", "link", "style", "script", "base", "noscript"];
for (const tag of allowedTags) {
resetSSRHead();
const selfClosing = ["meta", "link", "base"].includes(tag);
const el = selfClosing
? React.createElement(tag, { name: "test", content: "test" })
: React.createElement(tag, null, "test content");
const html = await collectHeadHTML([el]);
expect(html).toContain(`<${tag}`);
expect(html).toContain('data-next-head=""');
}
});
});
describe("escapeInlineContent", () => {
it("escapes </script> within script content", async () => {
const { escapeInlineContent } = await import("../packages/vinext/src/shims/head.js");
const input = 'var x = "</script><img src=x onerror=alert(1)>";';
const result = escapeInlineContent(input, "script");
expect(result).toBe('var x = "<\\/script><img src=x onerror=alert(1)>";');
expect(result).not.toContain("</script>");
});
it("escapes </style> within style content", async () => {
const { escapeInlineContent } = await import("../packages/vinext/src/shims/head.js");
const input = "body::after { content: '</style><div>'; }";
const result = escapeInlineContent(input, "style");
expect(result).toBe("body::after { content: '<\\/style><div>'; }");
expect(result).not.toContain("</style>");
});
it("handles case-insensitive closing tags", async () => {
const { escapeInlineContent } = await import("../packages/vinext/src/shims/head.js");
expect(escapeInlineContent("</Script>", "script")).toBe("<\\/Script>");
expect(escapeInlineContent("</SCRIPT>", "script")).toBe("<\\/SCRIPT>");
expect(escapeInlineContent("</sCrIpT>", "script")).toBe("<\\/sCrIpT>");
});
it("handles multiple occurrences", async () => {
const { escapeInlineContent } = await import("../packages/vinext/src/shims/head.js");
const input = "</script></script></SCRIPT>";
const result = escapeInlineContent(input, "script");
expect(result).toBe("<\\/script><\\/script><\\/SCRIPT>");
expect(result).not.toContain("</script>");
expect(result).not.toContain("</SCRIPT>");
});
it("does not escape unrelated closing tags", async () => {
const { escapeInlineContent } = await import("../packages/vinext/src/shims/head.js");
// Escaping for "script" should not touch </style>
const input = "</style></div>";
const result = escapeInlineContent(input, "script");
expect(result).toBe("</style></div>");
});
it("passes through content with no closing tags", async () => {
const { escapeInlineContent } = await import("../packages/vinext/src/shims/head.js");
const input = "console.log('hello world');";
expect(escapeInlineContent(input, "script")).toBe(input);
});
});
describe("isValidModulePath", () => {
it("accepts valid absolute paths", () => {
expect(isValidModulePath("/src/pages/index.tsx")).toBe(true);
expect(isValidModulePath("/pages/about.js")).toBe(true);
expect(isValidModulePath("/src/pages/posts/[id].tsx")).toBe(true);
});
it("accepts valid relative paths starting with ./", () => {
expect(isValidModulePath("./src/pages/index.tsx")).toBe(true);
expect(isValidModulePath("./pages/about.js")).toBe(true);
});
it("rejects external https:// URLs", () => {
expect(isValidModulePath("https://evil.com/steal-cookies.js")).toBe(false);
});
it("rejects external http:// URLs", () => {
expect(isValidModulePath("http://evil.com/steal-cookies.js")).toBe(false);
});
it("rejects protocol-relative URLs (//)", () => {
expect(isValidModulePath("//evil.com/steal-cookies.js")).toBe(false);
expect(isValidModulePath("//cdn.example.com/script.js")).toBe(false);
});
it("rejects directory traversal", () => {
expect(isValidModulePath("/src/../../../etc/passwd")).toBe(false);
expect(isValidModulePath("./../../secret.js")).toBe(false);
expect(isValidModulePath("/pages/..%2F..%2Fsecret.js")).toBe(false);
});
it("rejects data: URLs", () => {
expect(isValidModulePath("data:text/javascript,alert(1)")).toBe(false);
});
it("rejects blob: URLs", () => {
expect(isValidModulePath("blob:http://localhost/abc")).toBe(false);
});
it("rejects bare specifiers", () => {
expect(isValidModulePath("evil-package")).toBe(false);
expect(isValidModulePath("@evil/package")).toBe(false);
});
it("rejects non-string values", () => {
expect(isValidModulePath(null)).toBe(false);
expect(isValidModulePath(undefined)).toBe(false);
expect(isValidModulePath(42)).toBe(false);
expect(isValidModulePath({})).toBe(false);
expect(isValidModulePath("")).toBe(false);
});
it("rejects javascript: protocol", () => {
expect(isValidModulePath("javascript:alert(1)")).toBe(false);
});
it("rejects ftp:// protocol", () => {
expect(isValidModulePath("ftp://evil.com/script.js")).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Cache scope guards — headers()/cookies()/connection() must throw inside
// "use cache" and unstable_cache() scopes (matches Next.js behavior).
// Ported from Next.js: test/e2e/app-dir/use-cache/use-cache.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/use-cache/use-cache.test.ts
describe("cache scope guards for dynamic APIs", () => {
it('headers() throws inside "use cache" scope', async () => {
const { cacheContextStorage } = await import("../packages/vinext/src/shims/cache-runtime.js");
const { headers, setHeadersContext } = await import("../packages/vinext/src/shims/headers.js");
// Set up a valid headers context so the "no context" error doesn't fire
setHeadersContext({ headers: new Headers(), cookies: new Map() });
// Run inside a "use cache" ALS scope
await cacheContextStorage.run(
{
tags: [],
lifeConfigs: [],
variant: "default",
hasExplicitRevalidate: false,
hasExplicitExpire: false,
dynamicNestedCacheError: undefined,
},
async () => {
await expect(headers()).rejects.toThrow(/cannot be called inside "use cache"/);
},
);
setHeadersContext(null);
});
it('headers() sync access throws the "use cache" error instead of a TypeError', async () => {
const { cacheContextStorage } = await import("../packages/vinext/src/shims/cache-runtime.js");
const { headers, setHeadersContext } = await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers({ "x-test": "blocked" }),
cookies: new Map(),
});
await cacheContextStorage.run(
{
tags: [],
lifeConfigs: [],
variant: "default",
hasExplicitRevalidate: false,
hasExplicitExpire: false,
dynamicNestedCacheError: undefined,
},
async () => {
expect(() => headers().get("x-test")).toThrow(/cannot be called inside "use cache"/);
},
);
setHeadersContext(null);
});
it('cookies() throws inside "use cache" scope', async () => {
const { cacheContextStorage } = await import("../packages/vinext/src/shims/cache-runtime.js");
const { cookies, setHeadersContext } = await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({ headers: new Headers(), cookies: new Map() });
await cacheContextStorage.run(
{
tags: [],
lifeConfigs: [],
variant: "default",
hasExplicitRevalidate: false,
hasExplicitExpire: false,
dynamicNestedCacheError: undefined,
},
async () => {
await expect(cookies()).rejects.toThrow(/cannot be called inside "use cache"/);
},
);
setHeadersContext(null);
});
it('headers() is allowed inside "use cache: private" scope', async () => {
const { cacheContextStorage } = await import("../packages/vinext/src/shims/cache-runtime.js");
const { headers, setHeadersContext } = await import("../packages/vinext/src/shims/headers.js");
// Next.js private-cache stores carry request headers.
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/app-render/work-unit-async-storage.external.ts
try {
setHeadersContext({
headers: new Headers({ "x-private": "allowed" }),
cookies: new Map(),
});
await cacheContextStorage.run(
{
tags: [],
lifeConfigs: [],
variant: "private",
hasExplicitRevalidate: false,
hasExplicitExpire: false,
dynamicNestedCacheError: undefined,
},
async () => {
expect((await headers()).get("x-private")).toBe("allowed");
},
);
} finally {
setHeadersContext(null);
}
});
it('cookies() is allowed inside "use cache: private" scope', async () => {
const { cacheContextStorage } = await import("../packages/vinext/src/shims/cache-runtime.js");
const { cookies, setHeadersContext } = await import("../packages/vinext/src/shims/headers.js");
// Ported from Next.js: test/e2e/app-dir/use-cache-private/use-cache-private.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/use-cache-private/use-cache-private.test.ts
try {
setHeadersContext({
headers: new Headers({ cookie: "test-cookie=allowed" }),
cookies: new Map([["test-cookie", "allowed"]]),
});
await cacheContextStorage.run(
{
tags: [],
lifeConfigs: [],
variant: "private",
hasExplicitRevalidate: false,
hasExplicitExpire: false,
dynamicNestedCacheError: undefined,
},
async () => {
await expect(cookies()).resolves.toMatchObject({
get: expect.any(Function),
});
expect((await cookies()).get("test-cookie")?.value).toBe("allowed");
},
);
} finally {
setHeadersContext(null);
}
});
it('cookies() sync access throws the "use cache" error instead of a TypeError', async () => {
const { cacheContextStorage } = await import("../packages/vinext/src/shims/cache-runtime.js");
const { cookies, setHeadersContext } = await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers(),
cookies: new Map([["session", "blocked"]]),
});
await cacheContextStorage.run(
{
tags: [],
lifeConfigs: [],
variant: "default",
hasExplicitRevalidate: false,
hasExplicitExpire: false,
dynamicNestedCacheError: undefined,
},
async () => {
expect(() => cookies().get("session")).toThrow(/cannot be called inside "use cache"/);
},
);
setHeadersContext(null);
});
it('connection() throws inside "use cache" scope', async () => {
const { cacheContextStorage } = await import("../packages/vinext/src/shims/cache-runtime.js");
const { connection } = await import("../packages/vinext/src/shims/server.js");
await cacheContextStorage.run(
{
tags: [],
lifeConfigs: [],
variant: "default",
hasExplicitRevalidate: false,
hasExplicitExpire: false,
dynamicNestedCacheError: undefined,
},
async () => {
await expect(connection()).rejects.toThrow(/cannot be called inside "use cache"/);
},
);
});
it("headers() throws inside unstable_cache() scope", async () => {
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { headers, setHeadersContext } = await import("../packages/vinext/src/shims/headers.js");
setCacheHandler(new MemoryCacheHandler());
setHeadersContext({ headers: new Headers(), cookies: new Map() });
const cached = unstable_cache(async () => {
// This should throw because we're inside an unstable_cache scope
const h = await headers();
return h.get("x-test");
}, ["test-headers-in-cache"]);
await expect(cached()).rejects.toThrow(/unstable_cache/);
setHeadersContext(null);
setCacheHandler(new MemoryCacheHandler());
});
it("cookies() throws inside unstable_cache() scope", async () => {
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { cookies, setHeadersContext } = await import("../packages/vinext/src/shims/headers.js");
setCacheHandler(new MemoryCacheHandler());
setHeadersContext({
headers: new Headers(),
cookies: new Map([["session", "abc"]]),
});
const cached = unstable_cache(async () => {
const c = await cookies();
return c.get("session");
}, ["test-cookies-in-cache"]);
await expect(cached()).rejects.toThrow(/unstable_cache/);
setHeadersContext(null);
setCacheHandler(new MemoryCacheHandler());
});
it("connection() throws inside unstable_cache() scope", async () => {
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { connection } = await import("../packages/vinext/src/shims/server.js");
setCacheHandler(new MemoryCacheHandler());
const cached = unstable_cache(async () => {
await connection();
}, ["test-connection-in-cache"]);
await expect(cached()).rejects.toThrow(/unstable_cache/);
setCacheHandler(new MemoryCacheHandler());
});
it("headers() works normally outside cache scopes", async () => {
const { headers, setHeadersContext } = await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers({ "x-test": "works" }),
cookies: new Map(),
});
// Should not throw outside any cache scope
const h = await headers();
expect(h.get("x-test")).toBe("works");
setHeadersContext(null);
});
it("cookies() works normally outside cache scopes", async () => {
const { cookies, setHeadersContext } = await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers(),
cookies: new Map([["token", "abc"]]),
});
// Should not throw outside any cache scope
const c = await cookies();
expect(c.get("token")).toEqual({ name: "token", value: "abc" });
setHeadersContext(null);
});
// Ported from Next.js: packages/next/src/server/request/draft-mode.ts
// (`getDraftModeProviderForCacheScope` + `trackDynamicDraftMode`).
// Reading `draftMode()` / `.isEnabled` is allowed inside cache scopes;
// only the mutating `enable()` / `disable()` methods throw. Issue #1489.
it('draftMode().isEnabled reads inside "use cache" scope', async () => {
const { cacheContextStorage } = await import("../packages/vinext/src/shims/cache-runtime.js");
const { draftMode, setHeadersContext } =
await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers({ cookie: "__prerender_bypass=test-secret" }),
cookies: new Map([["__prerender_bypass", "test-secret"]]),
draftModeSecret: "test-secret",
});
await cacheContextStorage.run(
{
tags: [],
lifeConfigs: [],
variant: "default",
hasExplicitRevalidate: false,
hasExplicitExpire: false,
dynamicNestedCacheError: undefined,
},
async () => {
const dm = await draftMode();
expect(dm.isEnabled).toBe(true);
},
);
setHeadersContext(null);
});
it("draftMode().isEnabled reads inside unstable_cache() scope", async () => {
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { draftMode, setHeadersContext } =
await import("../packages/vinext/src/shims/headers.js");
setCacheHandler(new MemoryCacheHandler());
setHeadersContext({
headers: new Headers({ cookie: "__prerender_bypass=test-secret" }),
cookies: new Map([["__prerender_bypass", "test-secret"]]),
draftModeSecret: "test-secret",
});
let observed: boolean | undefined;
const cached = unstable_cache(async () => {
const dm = await draftMode();
observed = dm.isEnabled;
return observed;
}, ["test-draftmode-in-cache"]);
await expect(cached()).resolves.toBe(true);
expect(observed).toBe(true);
setHeadersContext(null);
setCacheHandler(new MemoryCacheHandler());
});
it('draftMode().enable() throws inside "use cache" scope', async () => {
const { cacheContextStorage } = await import("../packages/vinext/src/shims/cache-runtime.js");
const { draftMode, setHeadersContext } =
await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({ headers: new Headers(), cookies: new Map() });
await cacheContextStorage.run(
{
tags: [],
lifeConfigs: [],
variant: "default",
hasExplicitRevalidate: false,
hasExplicitExpire: false,
dynamicNestedCacheError: undefined,
},
async () => {
const dm = await draftMode();
expect(() => dm.enable()).toThrow(/cannot be called inside "use cache"/);
expect(() => dm.disable()).toThrow(/cannot be called inside "use cache"/);
},
);
setHeadersContext(null);
});
it("draftMode().enable() throws inside unstable_cache() scope", async () => {
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { draftMode, setHeadersContext } =
await import("../packages/vinext/src/shims/headers.js");
setCacheHandler(new MemoryCacheHandler());
setHeadersContext({ headers: new Headers(), cookies: new Map() });
const cached = unstable_cache(async () => {
const dm = await draftMode();
dm.enable();
}, ["test-draftmode-enable-in-cache"]);
await expect(cached()).rejects.toThrow(/unstable_cache/);
setHeadersContext(null);
setCacheHandler(new MemoryCacheHandler());
});
it('headers() throws inside nested scopes (unstable_cache inside "use cache")', async () => {
const { cacheContextStorage } = await import("../packages/vinext/src/shims/cache-runtime.js");
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { headers, setHeadersContext } = await import("../packages/vinext/src/shims/headers.js");
setCacheHandler(new MemoryCacheHandler());
setHeadersContext({ headers: new Headers(), cookies: new Map() });
// Nest unstable_cache inside a "use cache" scope: the outermost
// scope ("use cache") should be detected first.
await cacheContextStorage.run(
{
tags: [],
lifeConfigs: [],
variant: "default",
hasExplicitRevalidate: false,
hasExplicitExpire: false,
dynamicNestedCacheError: undefined,
},
async () => {
const cached = unstable_cache(async () => {
const h = await headers();
return h.get("x-test");
}, ["test-nested-scopes"]);
// Either scope's guard triggers (the "use cache" check runs first)
await expect(cached()).rejects.toThrow(/cannot be called inside/);
},
);
setHeadersContext(null);
setCacheHandler(new MemoryCacheHandler());
});
it("existing unstable_cache tests still pass (cache miss executes callback)", async () => {
// Verify that the ALS wrapping in unstable_cache doesn't break normal caching
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const cached = unstable_cache(
async (x: number) => {
callCount++;
return x * 2;
},
["regression-test"],
);
const r1 = await cached(5);
expect(r1).toBe(10);
expect(callCount).toBe(1);
const r2 = await cached(5);
expect(r2).toBe(10);
expect(callCount).toBe(1); // Cached, not called again
setCacheHandler(new MemoryCacheHandler());
});
// Ported from Next.js: workStore.invalidDynamicUsageError in
// packages/next/src/server/app-render/app-render.tsx
// https://github.com/vercel/next.js/commit/f5e54c06726b571a042fce67417e40a29f6b8689
it("records invalid dynamic usage error on request context (survives try/catch)", async () => {
const { cacheContextStorage } = await import("../packages/vinext/src/shims/cache-runtime.js");
const { setHeadersContext, throwIfInsideCacheScope, consumeInvalidDynamicUsageError } =
await import("../packages/vinext/src/shims/headers.js");
const { createRequestContext, runWithRequestContext } =
await import("../packages/vinext/src/shims/unified-request-context.js");
setHeadersContext({ headers: new Headers(), cookies: new Map() });
const ctx = createRequestContext({
headersContext: { headers: new Headers(), cookies: new Map() },
});
let recordedError: unknown = null;
await runWithRequestContext(ctx, async () => {
try {
await cacheContextStorage.run(
{
tags: [],
lifeConfigs: [],
variant: "default",
hasExplicitRevalidate: false,
hasExplicitExpire: false,
dynamicNestedCacheError: undefined,
},
async () => {
throwIfInsideCacheScope("cookies()");
},
);
} catch {
// User try/catch — the error should still be recorded on the context
}
recordedError = consumeInvalidDynamicUsageError();
});
expect(recordedError).toBeInstanceOf(Error);
expect((recordedError as Error).message).toContain('cannot be called inside "use cache"');
// After consumption, the error is cleared
expect(consumeInvalidDynamicUsageError()).toBeNull();
setHeadersContext(null);
});
it("consumeInvalidDynamicUsageError returns null when no error was recorded", async () => {
const { consumeInvalidDynamicUsageError } =
await import("../packages/vinext/src/shims/headers.js");
const { createRequestContext, runWithRequestContext } =
await import("../packages/vinext/src/shims/unified-request-context.js");
const ctx = createRequestContext();
let result: unknown;
await runWithRequestContext(ctx, async () => {
result = consumeInvalidDynamicUsageError();
});
expect(result!).toBeNull();
});
it("consumeInvalidDynamicUsageError works outside unified request scope", async () => {
const { consumeInvalidDynamicUsageError } =
await import("../packages/vinext/src/shims/headers.js");
// Should return null without throwing when no unified scope is active
expect(consumeInvalidDynamicUsageError()).toBeNull();
});
});
describe("shim alias map .js variants", () => {
it("every top-level next/* alias has a corresponding .js variant", async () => {
const plugins = vinext() as Plugin[];
const configPlugin = plugins.find((p) => p.name === "vinext:config");
if (!configPlugin?.config) throw new Error("vinext:config plugin not found");
const hookFn = (
typeof configPlugin.config === "function" ? configPlugin.config : configPlugin.config.handler
) as (config: { root: string }, env: { mode: string; command: string }) => Promise<any>;
const result = await hookFn(
{ root: PAGES_FIXTURE_DIR },
{ mode: "development", command: "serve" },
);
const aliases = result?.resolve?.alias as Record<string, string> | undefined;
expect(aliases).toBeDefined();
// Collect top-level next/<name> keys (exclude next/dist/*, next/font/*, next/compat/*, next/legacy/*)
const topLevel = Object.keys(aliases!).filter((key) => {
if (!key.startsWith("next/")) return false;
if (key.endsWith(".js")) return false;
const segment = key.slice("next/".length);
if (segment.startsWith("dist/")) return false;
if (segment.startsWith("font/")) return false;
if (segment.startsWith("compat/")) return false;
if (segment.startsWith("legacy/")) return false;
return true;
});
expect(topLevel.length).toBeGreaterThan(0);
const missing = topLevel.filter((key) => !(key + ".js" in aliases!));
expect(missing, `Missing .js aliases for: ${missing.join(", ")}`).toEqual([]);
});
it("resolves next/error to the react-server shim only in the RSC environment", () => {
const plugins = vinext() as Plugin[];
const configPlugin = plugins.find((plugin) => plugin.name === "vinext:config");
if (!configPlugin?.resolveId || typeof configPlugin.resolveId === "function") {
throw new Error("vinext:config resolveId hook not found");
}
const hook = configPlugin.resolveId as {
handler: (this: { environment?: { name?: string } }, id: string) => string | undefined;
};
// resolveId returns Vite-style ids: forward slashes on every platform.
const clientShim = normalizePathSeparators(
path.resolve(import.meta.dirname, "../packages/vinext/src/shims/error.tsx"),
);
const reactServerShim = normalizePathSeparators(
path.resolve(import.meta.dirname, "../packages/vinext/src/shims/error.react-server.ts"),
);
expect(hook.handler.call({ environment: { name: "rsc" } }, "next/error")).toBe(reactServerShim);
expect(hook.handler.call({ environment: { name: "ssr" } }, "next/error")).toBe(clientShim);
expect(hook.handler.call({ environment: { name: "rsc" } }, "next/error.js")).toBe(
reactServerShim,
);
});
it("keeps the App Router request handler source-resolved in source checkouts", () => {
const plugins = vinext() as Plugin[];
const configPlugin = plugins.find((plugin) => plugin.name === "vinext:config");
if (!configPlugin?.resolveId || typeof configPlugin.resolveId === "function") {
throw new Error("vinext:config resolveId hook not found");
}
const hook = configPlugin.resolveId as {
filter: { id: RegExp };
handler: (
this: {
environment?: {
name?: string;
config: {
command: "serve" | "build";
};
};
},
id: string,
) => string | { id: string; external: true } | undefined;
};
const id = "vinext/server/app-rsc-handler";
// resolveId returns a forward-slash id (Vite-canonical) on every platform.
const expected = normalizePathSeparators(
path.resolve(import.meta.dirname, "../packages/vinext/src/server/app-rsc-handler.ts"),
);
expect(hook.filter.id.test(id)).toBe(true);
expect(
hook.handler.call({ environment: { name: "rsc", config: { command: "serve" } } }, id),
).toBe(expected);
expect(
hook.handler.call({ environment: { name: "rsc", config: { command: "build" } } }, id),
).toBe(expected);
expect(
hook.handler.call({ environment: { name: "ssr", config: { command: "serve" } } }, id),
).toBe(expected);
});
});
describe("@vercel/og compatibility resolution", () => {
type ResolveIdHook = {
filter: { id: RegExp };
handler: (
this: { environment?: { name?: string } },
id: string,
importer?: string,
) => string | undefined;
};
function getResolveIdHook(): ResolveIdHook {
const plugins = vinext() as Plugin[];
const configPlugin = plugins.find((p) => p.name === "vinext:config");
if (!configPlugin?.resolveId || typeof configPlugin.resolveId === "function") {
throw new Error("vinext:config resolveId hook not found");
}
return configPlugin.resolveId as ResolveIdHook;
}
it("routes direct @vercel/og app imports through the Next-compatible ImageResponse shim", () => {
const hook = getResolveIdHook();
// resolveId returns Vite-style ids: forward slashes on every platform.
const expectedShim = normalizePathSeparators(
path.resolve(import.meta.dirname, "../packages/vinext/src/shims/og.tsx"),
);
expect(hook.filter.id.test("@vercel/og")).toBe(true);
expect(hook.filter.id.test("@vercel/og.js")).toBe(true);
expect(
hook.handler.call({}, "@vercel/og", path.join(FIXTURE_DIR, "app/opengraph-image.tsx")),
).toBe(expectedShim);
expect(
hook.handler.call({}, "@vercel/og.js", path.join(FIXTURE_DIR, "app/twitter-image.tsx")),
).toBe(expectedShim);
});
it("lets the ImageResponse shim delegate to the real @vercel/og package", () => {
const hook = getResolveIdHook();
expect(
hook.handler.call({}, "@vercel/og", "/repo/packages/vinext/src/shims/og.tsx"),
).toBeUndefined();
expect(
hook.handler.call({}, "@vercel/og", "/repo/node_modules/vinext/dist/shims/og.js"),
).toBeUndefined();
});
});
describe("vinext shim package-subpath resolution", () => {
type ResolveIdHook = {
filter: { id: RegExp };
handler: (
this: { environment?: { name?: string } },
id: string,
importer?: string,
) => string | undefined;
};
function getResolveIdHook(): ResolveIdHook {
const plugins = vinext() as Plugin[];
const configPlugin = plugins.find((plugin) => plugin.name === "vinext:config");
if (!configPlugin?.resolveId || typeof configPlugin.resolveId === "function") {
throw new Error("vinext:config resolveId hook not found");
}
return configPlugin.resolveId as ResolveIdHook;
}
it("strips JavaScript extensions and Vite queries from package subpaths", () => {
const hook = getResolveIdHook();
// resolveId returns Vite-style ids: forward slashes on every platform.
const expectedShim = normalizePathSeparators(
path.resolve(import.meta.dirname, "../packages/vinext/src/shims/navigation.ts"),
);
const expectedReactServerShim = normalizePathSeparators(
path.resolve(import.meta.dirname, "../packages/vinext/src/shims/navigation.react-server.ts"),
);
const expectedOgShim = normalizePathSeparators(
path.resolve(import.meta.dirname, "../packages/vinext/src/shims/og.tsx"),
);
expect(hook.filter.id.test("vinext/shims/navigation.js?v=123")).toBe(true);
expect(hook.filter.id.test("\0vinext/shims/navigation.js?v=123")).toBe(true);
expect(hook.filter.id.test("\0next/navigation")).toBe(true);
expect(hook.filter.id.test("\0@vercel/og")).toBe(true);
expect(hook.handler.call({}, "unrelated-next/navigation")).toBeUndefined();
expect(hook.handler.call({}, "vinext/shims/navigation.js?v=123")).toBe(expectedShim);
expect(hook.handler.call({}, "\0vinext/shims/navigation.js?v=123")).toBe(expectedShim);
expect(hook.handler.call({ environment: { name: "rsc" } }, "\0next/navigation")).toBe(
expectedReactServerShim,
);
expect(
hook.handler.call({}, "\0@vercel/og", path.join(FIXTURE_DIR, "app/opengraph-image.tsx")),
).toBe(expectedOgShim);
});
});
// ── next/head attribute name validation ─────────────────────────────────────
describe("isSafeAttrName", () => {
let isSafeAttrName: (name: string) => boolean;
beforeEach(async () => {
const mod = await import("../packages/vinext/src/shims/head.js");
isSafeAttrName = mod.isSafeAttrName;
});
it("allows standard HTML attribute names", () => {
expect(isSafeAttrName("name")).toBe(true);
expect(isSafeAttrName("content")).toBe(true);
expect(isSafeAttrName("charset")).toBe(true);
expect(isSafeAttrName("http-equiv")).toBe(true);
expect(isSafeAttrName("data-testid")).toBe(true);
expect(isSafeAttrName("property")).toBe(true);
expect(isSafeAttrName("rel")).toBe(true);
expect(isSafeAttrName("href")).toBe(true);
expect(isSafeAttrName("crossOrigin")).toBe(true);
});
it("allows xml-namespaced attributes", () => {
expect(isSafeAttrName("xml:lang")).toBe(true);
expect(isSafeAttrName("xlink:href")).toBe(true);
});
it("rejects attribute names containing quotes", () => {
expect(isSafeAttrName('x"')).toBe(false);
expect(isSafeAttrName("x'")).toBe(false);
});
it("rejects attribute names containing angle brackets", () => {
expect(isSafeAttrName("x>")).toBe(false);
expect(isSafeAttrName("x<script")).toBe(false);
});
it("rejects attribute names containing slashes", () => {
expect(isSafeAttrName("x/")).toBe(false);
expect(isSafeAttrName('x"/><script>alert(1)</script><meta a="')).toBe(false);
});
it("rejects attribute names containing spaces", () => {
expect(isSafeAttrName("x y")).toBe(false);
expect(isSafeAttrName("x\ty")).toBe(false);
});
it("rejects attribute names containing equals", () => {
expect(isSafeAttrName("x=y")).toBe(false);
});
it("rejects inline event handler attributes", () => {
expect(isSafeAttrName("onclick")).toBe(false);
expect(isSafeAttrName("onerror")).toBe(false);
expect(isSafeAttrName("onload")).toBe(false);
expect(isSafeAttrName("onmouseover")).toBe(false);
});
it("allows attributes starting with 'o' that are not event handlers", () => {
expect(isSafeAttrName("open")).toBe(true);
expect(isSafeAttrName("og:title")).toBe(true);
});
it("rejects empty or non-alpha-starting names", () => {
expect(isSafeAttrName("")).toBe(false);
expect(isSafeAttrName("123")).toBe(false);
expect(isSafeAttrName("-foo")).toBe(false);
});
});
// ── has/missing condition value matching (anchored regex) ──────────────────
describe("checkHasConditions value anchoring", () => {
let checkHasConditions: Function;
let requestContextFromRequest: Function;
beforeEach(async () => {
const mod = await import("../packages/vinext/src/config/config-matchers.js");
checkHasConditions = mod.checkHasConditions;
requestContextFromRequest = mod.requestContextFromRequest;
});
it("exact value matches fully", () => {
const ctx = requestContextFromRequest(
new Request("http://localhost/", { headers: { cookie: "role=admin" } }),
);
const result = checkHasConditions(
[{ type: "cookie", key: "role", value: "admin" }],
undefined,
ctx,
);
expect(result).toBe(true);
});
it("does not match substring (anchored regex prevents partial match)", () => {
const ctx = requestContextFromRequest(
new Request("http://localhost/", { headers: { cookie: "role=not-admin" } }),
);
const result = checkHasConditions(
[{ type: "cookie", key: "role", value: "admin" }],
undefined,
ctx,
);
expect(result).toBe(false);
});
it("does not match superstring", () => {
const ctx = requestContextFromRequest(
new Request("http://localhost/", { headers: { cookie: "role=admin-temp" } }),
);
const result = checkHasConditions(
[{ type: "cookie", key: "role", value: "admin" }],
undefined,
ctx,
);
expect(result).toBe(false);
});
it("regex patterns still work with anchoring", () => {
const ctx = requestContextFromRequest(
new Request("http://localhost/", { headers: { "x-lang": "en-US" } }),
);
const result = checkHasConditions(
[{ type: "header", key: "x-lang", value: "en.*" }],
undefined,
ctx,
);
expect(result).toBe(true);
});
});
// ── CSRF origin wildcard matching ─────────────────────────────────────────
// Ported from Next.js: packages/next/src/server/app-render/csrf-protection.test.ts
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/app-render/csrf-protection.test.ts
describe("isOriginAllowed", () => {
let isOriginAllowed: (origin: string, allowed: string[]) => boolean;
beforeEach(async () => {
const mod = await import("../packages/vinext/src/server/request-pipeline.js");
isOriginAllowed = mod.isOriginAllowed;
});
it("exact match", () => {
expect(isOriginAllowed("vercel.com", ["vercel.com"])).toBe(true);
expect(isOriginAllowed("www.vercel.com", ["www.vercel.com"])).toBe(true);
});
it("single-level wildcard matches one subdomain", () => {
expect(isOriginAllowed("asdf.vercel.com", ["*.vercel.com"])).toBe(true);
});
it("single-level wildcard does NOT match multiple subdomains", () => {
expect(isOriginAllowed("asdf.jkl.vercel.com", ["*.vercel.com"])).toBe(false);
});
it("double wildcard matches one or more subdomains", () => {
expect(isOriginAllowed("asdf.vercel.com", ["**.vercel.com"])).toBe(true);
expect(isOriginAllowed("asdf.jkl.vercel.com", ["**.vercel.com"])).toBe(true);
});
it("does not match different TLD", () => {
expect(isOriginAllowed("asdf.vercel.com", ["*.vercel.app"])).toBe(false);
expect(isOriginAllowed("asdf.jkl.vercel.app", ["**.vercel.com"])).toBe(false);
});
it("does not match unrelated domain", () => {
expect(isOriginAllowed("vercel.com", ["nextjs.org"])).toBe(false);
});
it("returns false for undefined/empty allowed list", () => {
expect(isOriginAllowed("vercel.com", [])).toBe(false);
});
it("returns false for empty string pattern", () => {
expect(isOriginAllowed("vercel.com", [""])).toBe(false);
});
it("wildcards only match below the domain level", () => {
expect(isOriginAllowed("vercel.com", ["*"])).toBe(false);
expect(isOriginAllowed("vercel.com", ["**"])).toBe(false);
});
it("matches case-insensitively (RFC 1035)", () => {
expect(isOriginAllowed("sub.VERCEL.com", ["*.vercel.com"])).toBe(true);
expect(isOriginAllowed("SUB.vercel.COM", ["*.vercel.com"])).toBe(true);
expect(isOriginAllowed("VERCEL.COM", ["vercel.com"])).toBe(true);
expect(isOriginAllowed("vercel.com", ["VERCEL.COM"])).toBe(true);
});
it("localhost patterns", () => {
expect(isOriginAllowed("subdomain.localhost", ["*.localhost"])).toBe(true);
expect(isOriginAllowed("localhost", ["*.localhost"])).toBe(false);
expect(isOriginAllowed("subdomain.localhost", ["**.localhost"])).toBe(true);
expect(isOriginAllowed("a.b.localhost", ["**.localhost"])).toBe(true);
expect(isOriginAllowed("localhost", ["**.localhost"])).toBe(false);
expect(isOriginAllowed("localhost", ["localhost"])).toBe(true);
});
it("does NOT match attacker-controlled suffix domains", () => {
// This was the original vulnerability: endsWith(".example.com") matching
// evil.example.com.attacker.com
expect(isOriginAllowed("evil.example.com.attacker.com", ["*.example.com"])).toBe(false);
expect(isOriginAllowed("evil.example.com.attacker.com", ["**.example.com"])).toBe(false);
});
});
// Reference: vercel/next.js#92012 — useOffline() hook
describe("next/offline shim", () => {
it("exports useOffline", async () => {
const offline = await import("../packages/vinext/src/shims/offline.js");
expect(offline.useOffline).toBeDefined();
expect(typeof offline.useOffline).toBe("function");
});
it("useOffline returns false (no-op stub)", async () => {
const offline = await import("../packages/vinext/src/shims/offline.js");
expect(offline.useOffline()).toBe(false);
});
});
// Regression for cloudflare/vinext#1353. The Pages Router navigation shim
// referenced `window` unconditionally inside `performNavigation()`, so any
// component calling `router.push()` during SSR or prerendering
// (VINEXT_PRERENDER=1) crashed the render pipeline with
// `ReferenceError: window is not defined`. Next.js's `ServerRouter`
// (packages/next/src/server/render.tsx) instead throws a documented
// "No router instance found" error from every navigation method. We mirror
// that here so the failure mode is a recoverable render error rather than
// a global ReferenceError.
//
// Ported from Next.js: test/e2e/with-router/pages/router-method-ssr.js +
// test/e2e/with-router/index.test.ts (the "router-method-ssr" SSR case).
describe("next/router SSR guard (issue #1353)", () => {
let previousWindow: unknown;
beforeEach(() => {
previousWindow = (globalThis as any).window;
delete (globalThis as any).window;
vi.resetModules();
});
afterEach(() => {
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
vi.resetModules();
});
it("Router.push throws the documented 'no router instance' error during SSR (no ReferenceError)", async () => {
const Router = (await import("../packages/vinext/src/shims/router.js")).default;
expect(() => Router.push("/a")).toThrow(/No router instance found/);
});
it("Router.replace throws the documented 'no router instance' error during SSR", async () => {
const Router = (await import("../packages/vinext/src/shims/router.js")).default;
expect(() => Router.replace("/a")).toThrow(/No router instance found/);
});
it("Router.back throws during SSR instead of touching window.history", async () => {
const Router = (await import("../packages/vinext/src/shims/router.js")).default;
expect(() => Router.back()).toThrow(/No router instance found/);
});
it("Router.reload throws during SSR instead of touching window.location", async () => {
const Router = (await import("../packages/vinext/src/shims/router.js")).default;
expect(() => Router.reload()).toThrow(/No router instance found/);
});
it("Router.prefetch throws during SSR instead of touching document", async () => {
const Router = (await import("../packages/vinext/src/shims/router.js")).default;
expect(() => Router.prefetch("/a")).toThrow(/No router instance found/);
});
it("Router.beforePopState throws during SSR", async () => {
const Router = (await import("../packages/vinext/src/shims/router.js")).default;
expect(() => Router.beforePopState(() => true)).toThrow(/No router instance found/);
});
// Render a `withRouter`-wrapped page that calls `router.push()` during SSR
// (the exact pattern from Next.js's `router-method-ssr.js` fixture).
// Before the fix this crashed with `ReferenceError: window is not defined`
// from `performNavigation`. After the fix the render throws the documented
// "No router instance found" error, which is what Next.js does too.
it("withRouter component that calls router.push() during SSR throws a render error, not a ReferenceError", async () => {
const { withRouter, wrapWithRouterContext } =
await import("../packages/vinext/src/shims/router.js");
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const RouterMethodSSR = ({ router }: { router: NextRouter }) => {
// Mirrors Next.js's `router-method-ssr.js` fixture, which calls
// `router.push('/a')` synchronously during SSR. The push must throw
// synchronously for the test to observe the error; we ignore the
// returned Promise type via `void` to satisfy the lint rule (the
// Promise is never produced because the sync throw happens first).
void router.push("/a");
return React.createElement("p", null, "Navigating...");
};
const Wrapped = withRouter(RouterMethodSSR as React.ComponentType<{ router: NextRouter }>);
let caught: unknown = null;
try {
renderToStaticMarkup(wrapWithRouterContext(React.createElement(Wrapped)));
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(Error);
expect((caught as Error).message).toMatch(/No router instance found/);
expect((caught as Error).message).not.toMatch(/window is not defined/);
expect(caught as Error).not.toBeInstanceOf(ReferenceError);
});
});
// ---------------------------------------------------------------------------
// i18n `locale: false` on rewrites/redirects (issue #1336, item 1).
// Ported from Next.js: test/e2e/i18n-ignore-rewrite-source-locale/rewrites.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/i18n-ignore-rewrite-source-locale/rewrites.test.ts
//
// When a rewrite/redirect source carries `locale: false`, Next.js leaves the
// source pattern untouched (no internal locale prefix is prepended). So a
// source like `/:locale/rewrite-files/:path*` matches the raw locale-prefixed
// path with `:locale` capturing the actual locale segment.
//
// Without `locale: false`, Next.js prepends `/:nextInternalLocale(en|sv|nl)?`
// to the source so that the rule matches both prefixed and unprefixed paths
// (and the user-supplied source pattern matches against the de-localised
// remainder).
describe("locale: false on rewrites/redirects (issue #1336)", () => {
const emptyCtx = {
headers: new Headers(),
cookies: {},
query: new URLSearchParams(),
host: "localhost",
};
it("matches multi-param source with trailing catch-all (regression)", async () => {
// Even before the locale: false logic, the underlying matcher must handle
// a source like `/:locale/rewrite-files/:path*` where there is another
// named param before the trailing catch-all. Previously this fell into
// the simple catch-all branch which only handled the trailing param.
const { matchConfigPattern } = await import("../packages/vinext/src/config/config-matchers.js");
const pattern = "/:locale/rewrite-files/:path*";
const result = matchConfigPattern("/en/rewrite-files/file.txt", pattern);
expect(result).toEqual({ locale: "en", path: "file.txt" });
const nested = matchConfigPattern("/sv/rewrite-files/sub/dir/file.txt", pattern);
expect(nested).toEqual({ locale: "sv", path: "sub/dir/file.txt" });
});
it("processRoutes: with locale:false leaves source untouched", async () => {
const { applyLocaleToRoutes } =
await import("../packages/vinext/src/config/config-matchers.js");
const i18n = { locales: ["en", "sv", "nl"], defaultLocale: "en" };
const rules = [
{
source: "/:locale/rewrite-files/:path*",
destination: "/:path*",
locale: false as const,
},
];
const out = applyLocaleToRoutes(rules, i18n, "rewrite");
expect(out).toHaveLength(1);
// locale:false → no internal locale prefix is injected.
expect(out[0].source).toBe("/:locale/rewrite-files/:path*");
expect(out[0].destination).toBe("/:path*");
});
it("processRoutes: without locale:false prepends the locale alternation", async () => {
const { applyLocaleToRoutes } =
await import("../packages/vinext/src/config/config-matchers.js");
const i18n = { locales: ["en", "sv", "nl"], defaultLocale: "en" };
const rules = [{ source: "/old", destination: "/new", permanent: true as const }];
const out = applyLocaleToRoutes(rules, i18n, "redirect");
// Next.js produces two source-variants per default-locale (and per domain
// default locale for redirects): one default-prefixed and one with the
// internal locale capture group. We only require that one of the produced
// rules captures the locale alternation so the original source matches
// localised URLs.
const hasInternalLocaleVariant = out.some((r) => r.source.startsWith("/:nextInternalLocale("));
expect(hasInternalLocaleVariant).toBe(true);
});
it("matchRewrite honours locale:false against locale-prefixed paths", async () => {
const { matchRewrite, applyLocaleToRoutes } =
await import("../packages/vinext/src/config/config-matchers.js");
const i18n = { locales: ["en", "sv", "nl"], defaultLocale: "en" };
const rules = applyLocaleToRoutes(
[
{
source: "/:locale/rewrite-files/:path*",
destination: "/:path*",
locale: false as const,
},
{
source: "/:locale/rewrite-api/:path*",
destination: "/api/:path*",
locale: false as const,
},
],
i18n,
"rewrite",
);
expect(matchRewrite("/en/rewrite-files/file.txt", rules, emptyCtx)).toBe("/file.txt");
expect(matchRewrite("/sv/rewrite-files/file.txt", rules, emptyCtx)).toBe("/file.txt");
expect(matchRewrite("/nl/rewrite-files/file.txt", rules, emptyCtx)).toBe("/file.txt");
expect(matchRewrite("/en/rewrite-api/hello", rules, emptyCtx)).toBe("/api/hello");
});
it("matchRewrite without locale:false matches all locale-prefixed forms", async () => {
// When the user writes a plain source like `/old` and i18n is configured,
// vinext emits a `/:nextInternalLocale(en|sv|nl)` variant so the rule
// matches any locale-prefixed URL while retaining the original `/old`
// source for default-locale requests that arrive without a prefix.
const { matchRewrite, applyLocaleToRoutes } =
await import("../packages/vinext/src/config/config-matchers.js");
const i18n = { locales: ["en", "sv", "nl"], defaultLocale: "en" };
const rules = applyLocaleToRoutes([{ source: "/old", destination: "/new" }], i18n, "rewrite");
// Locale-prefixed paths match and the destination is also locale-prefixed.
expect(matchRewrite("/en/old", rules, emptyCtx)).toBe("/en/new");
expect(matchRewrite("/sv/old", rules, emptyCtx)).toBe("/sv/new");
expect(matchRewrite("/nl/old", rules, emptyCtx)).toBe("/nl/new");
// Unprefixed default-locale paths still match the original source.
expect(matchRewrite("/old", rules, emptyCtx)).toBe("/new");
});
it("matchRedirect uses the locale-static fast path for nextInternalLocale variants", async () => {
// The `_LOCALE_STATIC_RE` detection regex must accept both
// `/:locale(en|fr)?/foo` (optional, user-written)
// and
// `/:nextInternalLocale(en|fr)/foo` (mandatory, emitted by
// applyLocaleToRoutes)
// — otherwise the locale-capture variants emitted by this PR would fall
// into the linear scan, regressing the O(1) lookup performance of i18n
// apps with many redirects.
const { matchRedirect, applyLocaleToRoutes } =
await import("../packages/vinext/src/config/config-matchers.js");
const i18n = { locales: ["en", "sv", "nl"], defaultLocale: "en" };
const rules = applyLocaleToRoutes(
[{ source: "/security", destination: "/security-dest", permanent: false as const }],
i18n,
"redirect",
);
// Locale-capture (mandatory) variant must hit the fast path AND substitute
// the captured locale into the destination.
expect(matchRedirect("/sv/security", rules, emptyCtx)?.destination).toBe("/sv/security-dest");
// Default-locale-literal variant strips the prefix.
expect(matchRedirect("/en/security", rules, emptyCtx)?.destination).toBe("/security-dest");
// No-locale-prefix path must not be matched by the mandatory variant —
// the original (unprefixed) source matches and produces the unprefixed
// destination.
expect(matchRedirect("/security", rules, emptyCtx)?.destination).toBe("/security-dest");
});
it("applyLocaleToRoutes preserves trailing slash when trailingSlash is true", async () => {
const { applyLocaleToRoutes } =
await import("../packages/vinext/src/config/config-matchers.js");
const i18n = { locales: ["en", "fr"], defaultLocale: "en" };
// trailingSlash: false (default) — root source collapses to "".
const noTrailing = applyLocaleToRoutes(
[{ source: "/", destination: "/home" }],
i18n,
"rewrite",
{ trailingSlash: false },
);
const internalNoTrailing = noTrailing.find((r) => r.source.startsWith("/:nextInternalLocale("));
expect(internalNoTrailing?.source).toBe("/:nextInternalLocale(en|fr)");
// trailingSlash: true — root source is preserved.
const withTrailing = applyLocaleToRoutes(
[{ source: "/", destination: "/home" }],
i18n,
"rewrite",
{ trailingSlash: true },
);
const internalWithTrailing = withTrailing.find((r) =>
r.source.startsWith("/:nextInternalLocale("),
);
expect(internalWithTrailing?.source).toBe("/:nextInternalLocale(en|fr)/");
});
it("matchRedirect emits both default-locale-literal and locale-capture variants", async () => {
// For redirects, Next.js emits two source variants per rule:
// 1. `/${defaultLocale}/old` with destination `/new` (no locale prefix
// in destination — the default-locale variant strips the prefix).
// 2. `/:nextInternalLocale(en|sv|nl)/old` with destination
// `/:nextInternalLocale/new` so non-default locales keep their prefix.
// This matches packages/next/src/lib/load-custom-routes.ts processRoutes.
const { matchRedirect, applyLocaleToRoutes } =
await import("../packages/vinext/src/config/config-matchers.js");
const i18n = { locales: ["en", "sv", "nl"], defaultLocale: "en" };
const rules = applyLocaleToRoutes(
[{ source: "/old", destination: "/new", permanent: false as const }],
i18n,
"redirect",
);
// `/en/old` matches the default-locale-literal variant first → strips the prefix.
expect(matchRedirect("/en/old", rules, emptyCtx)?.destination).toBe("/new");
// Non-default locales match the `:nextInternalLocale` variant.
expect(matchRedirect("/sv/old", rules, emptyCtx)?.destination).toBe("/sv/new");
expect(matchRedirect("/nl/old", rules, emptyCtx)?.destination).toBe("/nl/new");
});
});
// ---------------------------------------------------------------------------
// Default-locale path normalisation before route matching (issue #1336, item 4).
//
// Next.js normalises every request that arrives without a locale prefix by
// prepending the (domain-aware) defaultLocale before any redirect, rewrite,
// header, or filesystem route match runs. The issue body claims the desired
// behaviour is a 308 from `/en/page` → `/page`, but Next.js does NOT redirect:
// it serves the same content under both URLs and lets the application normalise
// the path internally.
//
// Ported from Next.js:
// - test/e2e/i18n-default-locale-redirect/i18n-default-locale-redirect.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/i18n-default-locale-redirect/i18n-default-locale-redirect.test.ts
// - test/e2e/i18n-ignore-redirect-source-locale/redirects.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/i18n-ignore-redirect-source-locale/redirects.test.ts
// - packages/next/src/server/lib/router-utils/resolve-routes.ts (lines ~250-263)
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/router-utils/resolve-routes.ts
//
describe("default-locale path normalisation (issue #1336, item 4)", () => {
const emptyCtx = {
headers: new Headers(),
cookies: {},
query: new URLSearchParams(),
host: "localhost",
};
it("normalizeDefaultLocalePathname prepends the default locale when path has none", async () => {
const { normalizeDefaultLocalePathname } =
await import("../packages/vinext/src/server/pages-i18n.js");
const i18n = { locales: ["en", "sv", "nl"], defaultLocale: "en" };
expect(normalizeDefaultLocalePathname("/about", i18n)).toBe("/en/about");
expect(normalizeDefaultLocalePathname("/", i18n)).toBe("/en");
expect(normalizeDefaultLocalePathname("/api/health", i18n)).toBe("/en/api/health");
});
it("normalizeDefaultLocalePathname leaves locale-prefixed paths untouched", async () => {
const { normalizeDefaultLocalePathname } =
await import("../packages/vinext/src/server/pages-i18n.js");
const i18n = { locales: ["en", "sv", "nl"], defaultLocale: "en" };
expect(normalizeDefaultLocalePathname("/en/about", i18n)).toBe("/en/about");
expect(normalizeDefaultLocalePathname("/sv/about", i18n)).toBe("/sv/about");
expect(normalizeDefaultLocalePathname("/nl", i18n)).toBe("/nl");
});
it("normalizeDefaultLocalePathname is a no-op when i18n is not configured", async () => {
const { normalizeDefaultLocalePathname } =
await import("../packages/vinext/src/server/pages-i18n.js");
expect(normalizeDefaultLocalePathname("/about", null)).toBe("/about");
expect(normalizeDefaultLocalePathname("/", undefined)).toBe("/");
expect(normalizeDefaultLocalePathname("/about", null, { hostname: "example.com" })).toBe(
"/about",
);
});
it("normalizeDefaultLocalePathname skips internal Next.js / vinext paths", async () => {
// Next.js's resolve-routes.ts explicitly excludes /_next/* from
// default-locale normalisation. Build assets and prerender manifests must
// not get a locale prefix.
const { normalizeDefaultLocalePathname } =
await import("../packages/vinext/src/server/pages-i18n.js");
const i18n = { locales: ["en", "sv", "nl"], defaultLocale: "en" };
expect(normalizeDefaultLocalePathname("/_next/static/chunks/main.js", i18n)).toBe(
"/_next/static/chunks/main.js",
);
expect(normalizeDefaultLocalePathname("/_next/data/build-id/index.json", i18n)).toBe(
"/_next/data/build-id/index.json",
);
// vinext-internal endpoints (prerender, image optimisation) likewise.
expect(normalizeDefaultLocalePathname("/__vinext/prerender/pages-static-paths", i18n)).toBe(
"/__vinext/prerender/pages-static-paths",
);
});
it("normalizeDefaultLocalePathname prefers the domain-mapped default locale", async () => {
// Next.js's resolve-routes.ts picks `domainLocale.defaultLocale` over the
// global default when the inbound host matches an i18n.domains entry.
const { normalizeDefaultLocalePathname } =
await import("../packages/vinext/src/server/pages-i18n.js");
const i18n = {
locales: ["en", "sv", "nl"],
defaultLocale: "en",
domains: [{ domain: "example.nl", defaultLocale: "nl" }],
};
expect(normalizeDefaultLocalePathname("/about", i18n, { hostname: "example.nl" })).toBe(
"/nl/about",
);
// Unknown host falls back to the global default.
expect(normalizeDefaultLocalePathname("/about", i18n, { hostname: "example.com" })).toBe(
"/en/about",
);
// Domain-mapped path that already carries a locale stays untouched.
expect(normalizeDefaultLocalePathname("/sv/about", i18n, { hostname: "example.nl" })).toBe(
"/sv/about",
);
});
it("matchRedirect honours :locale capture on locale:false rules after normalisation", async () => {
// Ported from Next.js: test/e2e/i18n-ignore-redirect-source-locale/redirects.test.ts
// A user-supplied source like `/:locale/to-sv` with `locale: false` is
// matched against the POST-normalisation pathname, so requests that
// arrive without a locale prefix get the default locale spliced in and
// the `:locale` segment captures it.
const { matchRedirect, applyLocaleToRoutes } =
await import("../packages/vinext/src/config/config-matchers.js");
const { normalizeDefaultLocalePathname } =
await import("../packages/vinext/src/server/pages-i18n.js");
const i18n = { locales: ["en", "sv", "nl"], defaultLocale: "en" };
const rules = applyLocaleToRoutes(
[
{
source: "/:locale/to-sv",
destination: "/sv/newpage",
permanent: false as const,
locale: false as const,
},
{
source: "/:locale/to-en",
destination: "/en/newpage",
permanent: false as const,
locale: false as const,
},
{
source: "/:locale/to-slash",
destination: "/newpage",
permanent: false as const,
locale: false as const,
},
{
source: "/:locale/to-same",
destination: "/:locale/newpage",
permanent: false as const,
locale: false as const,
},
],
i18n,
"redirect",
);
// Unprefixed paths get normalised to the default locale first.
expect(
matchRedirect(normalizeDefaultLocalePathname("/to-sv", i18n), rules, emptyCtx)?.destination,
).toBe("/sv/newpage");
expect(
matchRedirect(normalizeDefaultLocalePathname("/to-en", i18n), rules, emptyCtx)?.destination,
).toBe("/en/newpage");
expect(
matchRedirect(normalizeDefaultLocalePathname("/to-slash", i18n), rules, emptyCtx)
?.destination,
).toBe("/newpage");
expect(
matchRedirect(normalizeDefaultLocalePathname("/to-same", i18n), rules, emptyCtx)?.destination,
).toBe("/en/newpage");
// Locale-prefixed paths are passed through unchanged.
expect(
matchRedirect(normalizeDefaultLocalePathname("/sv/to-sv", i18n), rules, emptyCtx)
?.destination,
).toBe("/sv/newpage");
expect(
matchRedirect(normalizeDefaultLocalePathname("/nl/to-same", i18n), rules, emptyCtx)
?.destination,
).toBe("/nl/newpage");
expect(
matchRedirect(normalizeDefaultLocalePathname("/en/to-en", i18n), rules, emptyCtx)
?.destination,
).toBe("/en/newpage");
});
it("matchRedirect on default-locale paths matches the literal default-locale variant", async () => {
// For redirects WITHOUT `locale: false`, applyLocaleToRoutes already emits
// a literal `/${defaultLocale}/...` variant. Normalisation routes unprefixed
// requests through that variant.
const { matchRedirect, applyLocaleToRoutes } =
await import("../packages/vinext/src/config/config-matchers.js");
const { normalizeDefaultLocalePathname } =
await import("../packages/vinext/src/server/pages-i18n.js");
const i18n = { locales: ["en", "sv", "nl"], defaultLocale: "en" };
const rules = applyLocaleToRoutes(
[{ source: "/old", destination: "/new", permanent: false as const }],
i18n,
"redirect",
);
// `/old` (unprefixed) → normalised to `/en/old` → matches default-locale variant.
expect(
matchRedirect(normalizeDefaultLocalePathname("/old", i18n), rules, emptyCtx)?.destination,
).toBe("/new");
// `/sv/old` already prefixed → matches the locale-capture variant.
expect(
matchRedirect(normalizeDefaultLocalePathname("/sv/old", i18n), rules, emptyCtx)?.destination,
).toBe("/sv/new");
});
});
// ---------------------------------------------------------------------------
// Pages Router runtime state sharing
//
// Vite can evaluate `next/router` in both the client entry and in page chunks.
// State that must be consistent across those instances (events, history keys,
// abort/cancel state, and router readiness) must live on a window-scoped
// runtime object rather than in module-local variables.
// ---------------------------------------------------------------------------
describe("Pages Router runtime state sharing", () => {
it("shares RouterContext across duplicated next/router module instances", async () => {
vi.resetModules();
const contextA = await import("../packages/vinext/src/shims/internal/router-context.js");
vi.resetModules();
const contextB = await import("../packages/vinext/src/shims/internal/router-context.js");
expect(contextB.RouterContext).toBe(contextA.RouterContext);
});
it("shares router readiness across duplicated next/router module instances", async () => {
const previousWindow = (globalThis as any).window;
const win: any = {
location: {
pathname: "/1",
search: "",
hash: "",
href: "http://localhost/1",
hostname: "localhost",
},
history: { state: null, pushState() {}, replaceState() {} },
addEventListener() {},
dispatchEvent() {},
scrollTo() {},
__NEXT_DATA__: {
page: "/[id]",
query: {},
autoExport: true,
isFallback: false,
props: { pageProps: {} },
},
__VINEXT_PAGE_LOADERS__: {},
};
(globalThis as any).window = win;
try {
vi.resetModules();
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
// First `next/router` instance: the generated Pages client entry.
const routerModuleA = await import("../packages/vinext/src/shims/router.js");
const RouterA = routerModuleA.default;
const markReadyA = routerModuleA._markPagesRouterReady;
// Second `next/router` instance: a later page chunk that re-evaluates the
// module and overwrites window.next.router.
vi.resetModules();
const routerModuleB = await import("../packages/vinext/src/shims/router.js");
const RouterB = routerModuleB.default;
const useRouterB = routerModuleB.useRouter;
// Both singletons start with the same deferred-ready state.
expect(win.next.router).toBe(RouterB);
expect(RouterA.isReady).toBe(false);
expect(RouterB.isReady).toBe(false);
// After hydration, the readiness bit still stays false until the provider
// publishes it.
win.__NEXT_HYDRATED = true;
expect(RouterA.isReady).toBe(false);
expect(RouterB.isReady).toBe(false);
// Simulate the provider mounting and flipping the shared ready bit.
expect(markReadyA()).toBe(true);
expect(markReadyA()).toBe(false);
// The readiness transition is visible from every module instance and from
// the global singleton.
expect(RouterA.isReady).toBe(true);
expect(RouterB.isReady).toBe(true);
expect(win.next.router.isReady).toBe(true);
// useRouter() backed by the second instance also converges to ready.
function ProbeB() {
const router = useRouterB();
return React.createElement("span", null, router.isReady ? "ready" : "not-ready");
}
const htmlB = renderToStaticMarkup(
routerModuleB.wrapWithRouterContext(React.createElement(ProbeB)),
);
expect(htmlB).toBe("<span>ready</span>");
// And a provider-mounted hook from the first instance sees it too.
function ProbeA() {
const router = routerModuleA.useRouter();
return React.createElement("span", null, router.isReady ? "ready" : "not-ready");
}
const htmlA = renderToStaticMarkup(
routerModuleA.wrapWithRouterContext(React.createElement(ProbeA)),
);
expect(htmlA).toBe("<span>ready</span>");
} finally {
vi.resetModules();
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
}
});
});