mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
22587 lines
865 KiB
TypeScript
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 " in text content
|
|
const Q = """;
|
|
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§ion=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&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("</title><script>");
|
|
});
|
|
|
|
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 & Jerry < Friends > 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(""hello"");
|
|
expect(html).toContain("&");
|
|
expect(html).toContain("<goodbye>");
|
|
});
|
|
|
|
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;
|
|
}
|
|
}
|
|
});
|
|
});
|