Files
cloudflare__vinext/tests/shims.test.ts
T
Nathan Nguyen cda19e1c58 fix(app-router): preserve recent segment state with Activity BFCache (#1739)
* fix(app-router): preserve recent segment state with Activity BFCache

Fresh App Router navigations were reusing the active React tree when moving between dynamic segment values. That leaked client state into a newly entered segment and made the upstream back-forward-cache suite fail before it even reached browser traversal assertions.

The root cause was that vinext had history-entry bfcacheId plumbing but no separate segment state-key cache. Next.js treats those identities separately: render state is keyed by segment identity/pathname, while useRouter().bfcacheId identifies a concrete history entry.

Add state-key maps for app router slots, render retained entries through React.Activity when cacheComponents is enabled, keep the active one-entry keyed reset path otherwise, and port focused Next.js regression coverage for the three-entry BFCache behavior.

* fix(bfcache): store stateKeyMap in retained slot entries and re-provide on render

Review feedback requested two changes:

1. Add stateKeyMap to BfcacheSlotEntry so each retained BFCache entry carries
the state-key map that belonged to it when active, rather than borrowing the
current navigation's map. This prevents nested hidden entries from being rendered
with old elements but new pathname-derived state keys — an identity corruption
bug that upstream Next.js avoids by rendering each retained entry from its own
state.

2. Add a nested dynamic regression test: /nested/[section]/item/[id] with client
counters in both the [section] layout and [id] page. The existing flat /page/[n]
tests do not exercise nested retained-entry identity, which is exactly where the
missing state-key-map bug lives.

Changes:
- BfcacheSlotEntry gains stateKeyMap?: Readonly<Record<string, string>>
- updateBfcacheSlotEntries accepts and stores an optional stateKeyMap argument
- BfcacheSlotBoundary stores the current stateKeyMap alongside elements when
caching the active entry
- Both the Activity visible/hidden path and the non-cacheComponents single-entry
path now wrap rendered entries in BfcacheStateKeyMapContext.Provider using the
entry's own stateKeyMap (falling back to current map for forward compatibility)
- New e2e test verifies nested layout + page counter preservation across
segment navigations

* refactor(bfcache): extract shared provider stack into BfcacheEntryProviders

BfcacheSlotBoundary rendered the BfcacheStateKeyMapContext / ElementsContext /
SegmentContext provider stack in two places: the one-entry keyed reset path and
the per-entry Activity retention list. The two stacks were identical except for
where the React key sat, so the retained-entry bookkeeping and its render shape
were tangled together and harder to audit than needed.

Extract BfcacheEntryProviders as a pure presentational helper and render it from
both paths. The keyed-reset path keeps its remount semantics: the key now sits on
the BfcacheEntryProviders element, so a fresh active stateKey still remounts the
whole provider subtree.

Hook ordering is unchanged. All hooks stay above the early returns in
BfcacheSlotBoundary, so the boundary still satisfies the Rules of Hooks across the
activeStateKey undefined-to-defined transition. Covered by the slot BFCache
entry-state suite in tests/shims.test.ts.

* test(bfcache): isolate cacheComponents BFCache coverage in a dedicated fixture

The back/forward-cache e2e suite ran against the shared app-basic fixture with
cacheComponents: true. app-basic backs the single app-router Playwright server
that every app-router spec uses, so enabling cacheComponents globally changed a
core DOM invariant for unrelated tests: inactive route trees stay mounted as
hidden Activity DOM. The contamination already leaked a :visible workaround into
the interception-modal bfcacheId spec, which had nothing to do with Activity.

cacheComponents is an opt-in semantic mode, not default app-router behaviour, so
it does not belong on the shared baseline fixture. Move the BFCache routes into a
dedicated tests/fixtures/app-bfcache fixture with cacheComponents: true, served by
a new app-router-bfcache Playwright project on port 4183, and run the
back-forward-cache spec there against the project baseURL. The :visible locators
stay in that spec, where duplicate hidden DOM is the expected contract.

Remove cacheComponents and the BFCache routes from app-basic so the shared fixture
models default behaviour again, and revert the :visible workaround in
use-router-bfcache-id.spec.ts now that the modal route no longer retains hidden
DOM. Fix the nested item page import while moving it: nested/[section]/item/[id]/
page.tsx imported ../counter, but the counter lives two levels up at
nested/[section]/counter.tsx, so it must be ../../counter.

Validation: PLAYWRIGHT_PROJECT=app-router-bfcache (5 passed) and the full
use-router-bfcache-id spec under app-router (11 passed), plus the slot BFCache
unit suite (1172 passed) and vp check (formatting + lint + types clean).

* ci: run the app-router-bfcache e2e project in the matrix

The dedicated app-router-bfcache Playwright project was added to
playwright.config.ts but not to the CI E2E matrix, so the back/forward-cache
specs never ran in CI. Add it to the matrix project list so the BFCache suite
runs on every push. It uses the default chromium browser, so the existing setup
and browser-cache steps apply unchanged.

* fix(bfcache): keep persisted slots mounted when cacheComponents is off

Without cacheComponents there is no Activity retention, yet BfcacheSlotBoundary
still keyed the active entry by its segment stateKey. That stateKey tracks the
pathname (createBfcacheSegmentIdentity), so any slot whose identity moves with
the URL (shared layouts, interception source slots) was remounted on navigation,
discarding client state that survives a normal route change. The baseline router
reconciles these slots in place.

This regressed seven existing app-router e2e tests once cacheComponents was no
longer masking it on the shared fixture: interception source-context and modal
preservation in advanced.spec.ts, and layout-persistence.spec.ts dynamic-segment
layout counter survival. Fresh-entry reset is driven by userland bfcacheId keying
in the fixtures, not by remounting the slot subtree, so the keying was pure
overreach in the non-cacheComponents path.

Render the single active entry unkeyed when cacheComponents is off so the boundary
reconciles in place like the baseline. The keyed Activity retention path is
unchanged for cacheComponents.

Validation: full app-router project 436 passed / 0 failed (the seven regressions
now pass); use-router-bfcache-id 11 passed (fresh-push reset intact); app-bfcache
5 passed (Activity retention intact); shims unit suite 1024 passed.

* docs(test): point bfcacheId spec comment at the app-router-bfcache project

The header noted that Activity-backed preservation is covered in
back-forward-cache.spec.ts, but that spec moved to the dedicated app-bfcache
fixture under the app-router-bfcache project. Name the project and path so the
cross-reference stays accurate.

* docs: align cacheComponents comment with unkeyed active-segment behavior

* test(bfcache): assert fresh bfcacheId after Activity-restored return

* style: collapse locator chains per oxfmt

* refactor(bfcache): split slot entry order from snapshots

* fix(bfcache): keep non-cacheComponents slot path baseline

* fix(bfcache): use string cacheComponents define

* test: remove external fetch from revalidate tag fixture

* fix(bfcache): harden Activity state key retention

* docs(bfcache): document cacheComponents Activity caveats

* fix(bfcache): preserve navigation context for fallback SSR

Fallback boundary rendering can outlive the original request-scoped navigation context. That made app SSR fail fast with a missing navigation context when loading-boundary digest routes rendered their HTTP fallback shell.

The SSR boundary contract still requires a navigation context for real request renders, but boundary renderers now synthesize the same typed shape from the request URL and matched params when request scope has already been cleared.

* refactor(bfcache): normalize pathname in state-key map, document invariant callers

Moves hydration-pathname normalization into createBfcacheSegmentStateKeyMap so SSR and client always produce byte-identical Activity keys. Documents the requireNavigationContext guaranteeing callers per review feedback.

* fix(bfcache): tolerate malformed state-key pathnames

* docs(bfcache): document known Next.js divergence and dead-branch contract

* chore(deps): update lockfile for vite 0.1.24

* fix(app-router): preserve encoded delimiters in bfcache keys

* docs(bfcache): track router-owned retention follow-up

* chore: retrigger ci

* fix(app-router): preserve redirect bridge fallback
2026-06-07 23:07:25 +01:00

19203 lines
737 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";
const FIXTURE_DIR = PAGES_FIXTURE_DIR;
describe("vinext next data client helpers", () => {
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("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("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).toHaveBeenCalledWith(
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 = "";
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 }>();
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("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("useSelectedLayoutSegment still works when provider and hook are loaded from different module instances", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const hookPath = "../packages/vinext/src/shims/navigation.js?hook-instance=a";
const providerPath =
"../packages/vinext/src/shims/layout-segment-context.tsx?provider-instance=b";
const hookMod: typeof import("../packages/vinext/src/shims/navigation.js") = await import(
hookPath
);
const providerMod: typeof import("../packages/vinext/src/shims/layout-segment-context.tsx") =
await import(providerPath);
function Probe() {
const segment = hookMod.useSelectedLayoutSegment();
return React.createElement("span", { "data-testid": "segment" }, segment ?? "null");
}
const html = renderToStaticMarkup(
React.createElement(providerMod.LayoutSegmentProvider, {
segmentMap: { children: ["explore"] },
// oxlint-disable-next-line react/no-children-prop
children: React.createElement(Probe),
}),
);
expect(html).toContain(">explore<");
});
it("ServerInsertedHTMLContext stays shared across multiple module instances", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const providerPath = "../packages/vinext/src/shims/navigation.js?inserted-html-provider=a";
const consumerPath = "../packages/vinext/src/shims/navigation.js?inserted-html-consumer=b";
const [providerMod, consumerMod]: [
typeof import("../packages/vinext/src/shims/navigation.js"),
typeof import("../packages/vinext/src/shims/navigation.js"),
] = await Promise.all([import(providerPath), import(consumerPath)]);
const providerCtx = providerMod.ServerInsertedHTMLContext;
const consumerCtx = consumerMod.ServerInsertedHTMLContext;
expect(providerCtx).toBeTruthy();
expect(consumerCtx).toBeTruthy();
const register = () => {};
let received: unknown = undefined;
function Probe() {
received = React.useContext(consumerCtx!);
return null;
}
renderToStaticMarkup(
React.createElement(providerCtx!.Provider, { value: register }, React.createElement(Probe)),
);
expect(received).toBe(register);
});
it("useSelectedLayoutSegments returns empty array outside React context", async () => {
const { useSelectedLayoutSegments } =
await import("../packages/vinext/src/shims/navigation.js");
// Outside a React tree, no LayoutSegmentProvider wraps us,
// so the context defaults to [].
const segments = useSelectedLayoutSegments();
expect(segments).toEqual([]);
});
it("useSelectedLayoutSegment returns null outside React context", async () => {
const { useSelectedLayoutSegment } = await import("../packages/vinext/src/shims/navigation.js");
// Outside a React tree, no LayoutSegmentProvider wraps us,
// so there are no child segments → null.
expect(useSelectedLayoutSegment()).toBeNull();
});
it("useSelectedLayoutSegments() accepts parallelRoutesKey and returns matching segments", async () => {
const nav = await import("../packages/vinext/src/shims/navigation.js");
// Outside a React tree, returns [] regardless of key.
const result = nav.useSelectedLayoutSegments("team");
expect(result).toEqual([]);
});
it("useSelectedLayoutSegment() accepts parallelRoutesKey and returns null outside React tree", async () => {
const nav = await import("../packages/vinext/src/shims/navigation.js");
const result = nav.useSelectedLayoutSegment("team");
expect(result).toBeNull();
});
it("useSelectedLayoutSegments(parallelRoutesKey) returns per-slot segments via segmentMap", async () => {
const React = await import("react");
const ReactDOMServer = await import("react-dom/server");
const { createElement } = React;
const { LayoutSegmentProvider } =
await import("../packages/vinext/src/shims/layout-segment-context.js");
const { useSelectedLayoutSegments, useSelectedLayoutSegment } =
await import("../packages/vinext/src/shims/navigation.js");
function TestComponent() {
const childrenSegs = useSelectedLayoutSegments();
const teamSegs = useSelectedLayoutSegments("team");
const teamSeg = useSelectedLayoutSegment("team");
return createElement(
"div",
null,
createElement("span", { id: "children" }, JSON.stringify(childrenSegs)),
createElement("span", { id: "team" }, JSON.stringify(teamSegs)),
createElement("span", { id: "team-singular" }, teamSeg ?? "null"),
);
}
const html = ReactDOMServer.renderToStaticMarkup(
createElement(LayoutSegmentProvider, {
segmentMap: { children: ["blog", "hello"], team: ["settings"] },
// oxlint-disable-next-line react/no-children-prop
children: createElement(TestComponent),
}),
);
// React SSR HTML-encodes " as &quot; in text content
const Q = "&quot;";
expect(html).toContain(`<span id="children">[${Q}blog${Q},${Q}hello${Q}]</span>`);
expect(html).toContain(`<span id="team">[${Q}settings${Q}]</span>`);
expect(html).toContain('<span id="team-singular">settings</span>');
});
it("useSelectedLayoutSegments(unknownKey) returns [] for missing slot", async () => {
const React = await import("react");
const ReactDOMServer = await import("react-dom/server");
const { createElement } = React;
const { LayoutSegmentProvider } =
await import("../packages/vinext/src/shims/layout-segment-context.js");
const { useSelectedLayoutSegments } =
await import("../packages/vinext/src/shims/navigation.js");
function TestComponent() {
const segs = useSelectedLayoutSegments("nonexistent");
return createElement("span", null, JSON.stringify(segs));
}
const html = ReactDOMServer.renderToStaticMarkup(
createElement(LayoutSegmentProvider, {
segmentMap: { children: ["blog"] },
// oxlint-disable-next-line react/no-children-prop
children: createElement(TestComponent),
}),
);
expect(html).toContain("[]");
});
// -------------------------------------------------------------------------
// unstable_rethrow + unstable_isUnrecognizedActionError
//
// Ported from Next.js:
// https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/unstable-rethrow.ts
// https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/unrecognized-action-error.ts
// -------------------------------------------------------------------------
it("exports unstable_rethrow and unstable_isUnrecognizedActionError", async () => {
const nav = await import("../packages/vinext/src/shims/navigation.js");
expect(typeof nav.unstable_rethrow).toBe("function");
expect(typeof nav.unstable_isUnrecognizedActionError).toBe("function");
expect(typeof nav.isRedirectError).toBe("function");
expect(typeof nav.isNextRouterError).toBe("function");
expect(typeof nav.UnrecognizedActionError).toBe("function");
});
it("unstable_rethrow re-throws redirect errors", async () => {
const { redirect, unstable_rethrow } =
await import("../packages/vinext/src/shims/navigation.js");
let captured: unknown = null;
try {
redirect("/login");
} catch (e) {
captured = e;
}
expect(captured).not.toBeNull();
expect(() => unstable_rethrow(captured)).toThrow();
try {
unstable_rethrow(captured);
} catch (rethrown) {
// Identity-preserving rethrow — must be the same error reference
expect(rethrown).toBe(captured);
}
});
it("unstable_rethrow re-throws notFound/forbidden/unauthorized errors", async () => {
const { notFound, forbidden, unauthorized, unstable_rethrow } =
await import("../packages/vinext/src/shims/navigation.js");
for (const trigger of [notFound, forbidden, unauthorized]) {
let captured: unknown = null;
try {
trigger();
} catch (e) {
captured = e;
}
expect(captured).not.toBeNull();
expect(() => unstable_rethrow(captured)).toThrow();
}
});
it("unstable_rethrow is a no-op for unrelated errors", async () => {
const { unstable_rethrow } = await import("../packages/vinext/src/shims/navigation.js");
// Plain Error: no digest, no cause
expect(() => unstable_rethrow(new Error("plain"))).not.toThrow();
expect(() => unstable_rethrow("string error")).not.toThrow();
expect(() => unstable_rethrow(null)).not.toThrow();
expect(() => unstable_rethrow(undefined)).not.toThrow();
expect(() => unstable_rethrow({ digest: "not-a-next-error" })).not.toThrow();
});
// -------------------------------------------------------------------------
// BailoutToCSRError + DynamicServerError parity
//
// Ported from Next.js:
// https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/lazy-dynamic/bailout-to-csr.ts
// https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/hooks-server-context.ts
// -------------------------------------------------------------------------
it("exports BailoutToCSRError + isBailoutToCSRError with the canonical digest", async () => {
const { BailoutToCSRError, isBailoutToCSRError } =
await import("../packages/vinext/src/shims/navigation.js");
const err = new BailoutToCSRError("test-reason");
expect(err.digest).toBe("BAILOUT_TO_CLIENT_SIDE_RENDERING");
expect(err.reason).toBe("test-reason");
expect(err.message).toBe("Bail out to client-side rendering: test-reason");
expect(isBailoutToCSRError(err)).toBe(true);
// Predicate matches by digest, not by instanceof — a foreign object with
// the canonical digest should also be detected (Next.js parity).
expect(isBailoutToCSRError({ digest: "BAILOUT_TO_CLIENT_SIDE_RENDERING" })).toBe(true);
// Negative cases.
expect(isBailoutToCSRError(new Error("plain"))).toBe(false);
expect(isBailoutToCSRError({ digest: "OTHER" })).toBe(false);
expect(isBailoutToCSRError(null)).toBe(false);
expect(isBailoutToCSRError(undefined)).toBe(false);
});
it("exports DynamicServerError + isDynamicServerError with the canonical digest", async () => {
const { DynamicServerError, isDynamicServerError } =
await import("../packages/vinext/src/shims/navigation.js");
const err = new DynamicServerError("cookies()");
expect(err.digest).toBe("DYNAMIC_SERVER_USAGE");
expect(err.description).toBe("cookies()");
expect(err.message).toBe("Dynamic server usage: cookies()");
expect(isDynamicServerError(err)).toBe(true);
// Predicate matches by digest — Next.js parity.
expect(isDynamicServerError({ digest: "DYNAMIC_SERVER_USAGE" })).toBe(true);
// Negative cases.
expect(isDynamicServerError(new Error("plain"))).toBe(false);
expect(isDynamicServerError({ digest: "OTHER" })).toBe(false);
expect(isDynamicServerError(null)).toBe(false);
expect(isDynamicServerError(undefined)).toBe(false);
});
// Mirrors the Next.js fixture
// .nextjs-ref/test/e2e/app-dir/unstable-rethrow/app/dynamic-error/page.tsx
// where `cookies()` throws a DynamicServerError inside a try/catch and
// unstable_rethrow must propagate it.
it("unstable_rethrow re-throws BailoutToCSRError (next/dynamic ssr:false bailout)", async () => {
const { BailoutToCSRError, unstable_rethrow } =
await import("../packages/vinext/src/shims/navigation.js");
const err = new BailoutToCSRError("Lazy(): No ssr");
expect(() => unstable_rethrow(err)).toThrow();
try {
unstable_rethrow(err);
} catch (rethrown) {
expect(rethrown).toBe(err);
}
});
it("unstable_rethrow re-throws DynamicServerError (cookies()/headers() in static render)", async () => {
const { DynamicServerError, unstable_rethrow } =
await import("../packages/vinext/src/shims/navigation.js");
const err = new DynamicServerError("Route used cookies()");
expect(() => unstable_rethrow(err)).toThrow();
try {
unstable_rethrow(err);
} catch (rethrown) {
expect(rethrown).toBe(err);
}
});
it("unstable_rethrow does NOT match the four server-only categories vinext does not implement", async () => {
// These categories (isDynamicPostpone, isPostpone,
// isHangingPromiseRejectionError, isPrerenderInterruptedError) are
// server-only Next.js internals tied to PPR / prerender-controller
// machinery vinext does not implement. They are deferred as follow-ups
// (see the JSDoc on unstable_rethrow). This test pins that intentional
// gap so the omission is visible to future maintainers.
const { unstable_rethrow } = await import("../packages/vinext/src/shims/navigation.js");
const postpone = { $$typeof: Symbol.for("react.postpone") };
expect(() => unstable_rethrow(postpone)).not.toThrow();
const hanging = Object.assign(new Error("hanging"), { digest: "HANGING_PROMISE_REJECTION" });
expect(() => unstable_rethrow(hanging)).not.toThrow();
const interrupted = Object.assign(new Error("interrupt"), {
digest: "NEXT_PRERENDER_INTERRUPTED",
});
expect(() => unstable_rethrow(interrupted)).not.toThrow();
// No fixed digest — message-shape based detection. We don't try to
// construct a faithful payload here; just confirm an arbitrary message
// doesn't match.
const dynamicPostpone = new Error("some random message");
expect(() => unstable_rethrow(dynamicPostpone)).not.toThrow();
});
it("unstable_rethrow recurses through error.cause to rethrow wrapped Next.js errors", async () => {
const { redirect, unstable_rethrow } =
await import("../packages/vinext/src/shims/navigation.js");
let inner: unknown = null;
try {
redirect("/login");
} catch (e) {
inner = e;
}
const wrapped = new Error("user wrapped this", { cause: inner });
expect(() => unstable_rethrow(wrapped)).toThrow();
try {
unstable_rethrow(wrapped);
} catch (rethrown) {
// The recursion should rethrow the original Next.js error, not the wrapper
expect(rethrown).toBe(inner);
}
});
it("unstable_isUnrecognizedActionError returns true for UnrecognizedActionError instances", async () => {
const { UnrecognizedActionError, unstable_isUnrecognizedActionError } =
await import("../packages/vinext/src/shims/navigation.js");
const err = new UnrecognizedActionError("missing action 'abc'");
expect(err.name).toBe("UnrecognizedActionError");
expect(unstable_isUnrecognizedActionError(err)).toBe(true);
});
it("unstable_isUnrecognizedActionError returns false for other errors", async () => {
const { unstable_isUnrecognizedActionError } =
await import("../packages/vinext/src/shims/navigation.js");
expect(unstable_isUnrecognizedActionError(new Error("other"))).toBe(false);
expect(unstable_isUnrecognizedActionError(null)).toBe(false);
expect(unstable_isUnrecognizedActionError(undefined)).toBe(false);
expect(unstable_isUnrecognizedActionError("string")).toBe(false);
expect(unstable_isUnrecognizedActionError({ name: "UnrecognizedActionError" })).toBe(false);
});
// -------------------------------------------------------------------------
// 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", () => {
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("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: { title: string },
info: { error: unknown; reset: () => void; unstable_retry: () => void },
) {
seenErrors.push(info.error);
const message = info.error instanceof Error ? info.error.message : String(info.error);
return React.createElement(
"div",
null,
React.createElement("p", { id: "title" }, props.title),
React.createElement("p", { id: "msg" }, message),
);
}
const Boundary = unstable_catchError<{ title: string }>(Fallback);
// Locate the inner class component by inspecting what the HOC renders.
// The wrapper function returns `React.createElement(_CatchError, ...)`.
const wrapperResult = (
Boundary as unknown as (props: {
title: string;
children?: React.ReactNode;
}) => React.ReactElement
)({
title: "hello-title",
children: React.createElement("span", null, "child"),
});
const InnerCatchError = wrapperResult.type as unknown as React.ComponentClass<{
fallback: typeof Fallback;
forwardedProps: { title: string };
children?: React.ReactNode;
}>;
const props = {
fallback: Fallback,
forwardedProps: { 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);
instance.state = { error: null };
// 1. No error → renders children.
const childrenOutput = renderToStaticMarkup(instance.render() as React.ReactElement);
expect(childrenOutput).toContain("child");
// 2. After getDerivedStateFromError, renders the fallback with ErrorInfo.
const thrown = new Error("boom");
const derived = (
InnerCatchError as unknown as {
getDerivedStateFromError(e: unknown): { error: { thrownValue: unknown } | null };
}
).getDerivedStateFromError(thrown);
expect(derived).toEqual({ error: { thrownValue: thrown } });
instance.state = derived;
const fallbackOutput = renderToStaticMarkup(instance.render() as React.ReactElement);
expect(fallbackOutput).toContain('id="msg"');
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 { 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);
// Probe the inner class through the wrapper.
const wrapperResult = (Boundary as unknown as (p: Record<string, never>) => { type: unknown })(
{},
);
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("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 { unstable_catchError } = await import("../packages/vinext/src/shims/error.js");
function Fallback() {
return null;
}
const Boundary = unstable_catchError(Fallback);
const wrapperResult = (Boundary as unknown as (p: Record<string, never>) => { type: unknown })(
{},
);
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 },
});
});
// unstable_retry behavior parity. Next.js refreshes the App Router segment
// and resets the boundary. vinext does the same on the client; on the
// server we throw (refresh is meaningless during SSR setup).
it("unstable_retry throws on the server (where refresh is meaningless)", async () => {
const React = (await import("react")).default;
const { unstable_catchError } = await import("../packages/vinext/src/shims/error.js");
function Fallback(
_props: Record<string, never>,
info: { error: unknown; reset: () => void; unstable_retry: () => void },
) {
return React.createElement("button", { onClick: info.unstable_retry }, "retry");
}
const Boundary = unstable_catchError(Fallback);
// Probe the inner class for its instance shape.
const wrapperResult = (Boundary as unknown as (p: Record<string, never>) => { type: unknown })(
{},
);
const InnerCatchError = wrapperResult.type as unknown as new (props: object) => {
state: { error: { thrownValue: unknown } | null };
unstable_retry: () => void;
};
const instance = new InnerCatchError({
fallback: Fallback,
forwardedProps: {},
});
// Manually instantiating the class skips React's context machinery,
// so `this.context` is undefined. Seed it to `null` (matching the
// App Router default) so the Pages Router branch in `unstable_retry`
// doesn't fire.
(instance as unknown as { context: null }).context = null;
instance.state = { error: { thrownValue: new Error("boom") } };
// typeof window === "undefined" in this Node test environment, so
// unstable_retry should throw a clear "client only" error.
expect(() => instance.unstable_retry()).toThrow(/client/i);
});
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`.
// We avoid `as Window & typeof globalThis` because the stub doesn't have
// the full DOM surface — just the bits navigation.ts touches at
// module-load.
const globalAny = globalThis as unknown as { window?: unknown };
const previousWindow = globalAny.window;
const stubWindow = {
location: {
search: "",
pathname: "/",
href: "http://localhost/",
origin: "http://localhost",
},
history: {
pushState: () => {},
replaceState: () => {},
back: () => {},
forward: () => {},
state: null,
},
addEventListener: () => {},
scrollTo: () => {},
};
globalAny.window = stubWindow;
try {
vi.resetModules();
const React = (await import("react")).default;
const { unstable_catchError } = await import("../packages/vinext/src/shims/error.js");
const navigation = await import("../packages/vinext/src/shims/navigation.js");
const refreshSpy = vi
.spyOn(navigation.appRouterInstance, "refresh")
.mockImplementation(() => {});
function Fallback() {
return null;
}
const Boundary = unstable_catchError(Fallback);
const wrapperResult = (
Boundary as unknown as (p: Record<string, never>) => { type: unknown }
)({});
const InnerCatchError = wrapperResult.type as unknown as new (props: object) => {
state: { error: { thrownValue: unknown } | null };
unstable_retry: () => void;
};
const instance = new InnerCatchError({
fallback: Fallback,
forwardedProps: {},
});
// Manually instantiating the class skips React's context machinery,
// so `this.context` is undefined. Seed it to `null` (matching the
// App Router default) so the Pages Router branch in `unstable_retry`
// doesn't fire — this test exercises the App Router branch.
(instance as unknown as { context: null }).context = null;
// 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).toEqual([{ error: null }]);
refreshSpy.mockRestore();
} finally {
if (previousWindow === undefined) {
delete globalAny.window;
} else {
globalAny.window = previousWindow;
}
vi.resetModules();
}
});
// Regression for cloudflare/vinext#1448.
// Ported from Next.js test:
// .nextjs-ref/test/e2e/app-dir/catch-error/catch-error.test.ts
// "should throw when unstable_retry is called on Pages Router"
// Mirrors the App Router-only branch in Next.js's catch-error source:
// .nextjs-ref/packages/next/src/client/components/catch-error.tsx
// The boundary detects Pages Router via `RouterContext` (set as the inner
// 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 { unstable_catchError } = await import("../packages/vinext/src/shims/error.js");
function Fallback() {
return null;
}
const Boundary = unstable_catchError(Fallback);
const wrapperResult = (Boundary as unknown as (p: Record<string, never>) => { type: unknown })(
{},
);
const InnerCatchError = wrapperResult.type as unknown as new (props: object) => {
state: { error: { thrownValue: unknown } | null };
unstable_retry: () => void;
};
const instance = new InnerCatchError({
fallback: Fallback,
forwardedProps: {},
});
instance.state = { error: { thrownValue: new Error("boom") } };
// Seed `this.context` with a truthy value so the Pages Router branch
// fires. In production React fills this from RouterContext when the
// boundary is rendered under `RouterContext.Provider` (every Pages
// Router page). Casting via unknown keeps the seed type-safe under
// the class's declared `context` type.
(instance as unknown as { context: object }).context = {
route: "/some-page",
};
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.",
);
});
});
// ---------------------------------------------------------------------------
// 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("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();
}
});
// 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("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 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 = {
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();
},
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() 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() throws the dynamic = "error" access error before exposing draft controls', 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,
});
await expect(draftMode()).rejects.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("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("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("findMiddlewareFile prefers proxy.ts over middleware.ts (Next.js 16)", 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() {}");
const result = findMiddlewareFile(tmpDir, createValidFileMatcher());
expect(result).not.toBeNull();
expect(result).toContain("proxy.ts");
} 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/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(
"must export a function named `proxy` or a `default` function",
);
});
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(
"must export a function named `proxy` or a `default` function",
);
});
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(
"must export a function named `proxy` or a `default` function",
);
});
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(
"must export a function named `proxy` or a `default` function",
);
});
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(
"must export a function named `middleware` or a `default` function",
);
});
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(
"must export a function named `middleware` or a `default` function",
);
});
});
// ---------------------------------------------------------------------------
// 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("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: 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: rejects pathological ReDoS patterns", async () => {
const { matchPattern } = await import("../packages/vinext/src/server/middleware.js");
// Pathological pattern: (a+)+ causes catastrophic backtracking
// matchPattern should return false (no match) instead of hanging
// lgtm[js/redos] — deliberate pathological regex to test safeRegExp guard
expect(matchPattern("/aaaaaaaaaaaaaaaaaaaac", "(a+)+b")).toBe(false);
});
});
// ---------------------------------------------------------------------------
// 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("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: [],
},
]);
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 returns the configured value", async () => {
const { NextURL } = await import("../packages/vinext/src/shims/server.js");
const url = new NextURL("http://localhost/dashboard", undefined, {
basePath: "/app",
});
expect(url.basePath).toBe("/app");
});
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("basePath is preserved through clone()", async () => {
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("/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("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/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/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/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/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/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");
});
// --- 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/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/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/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/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/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/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" });
});
});
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);
});
// -- 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("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");
});
});
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 Next.js noSSR loading props", 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);
expect(receivedProps.pastDelay).toBe(false);
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("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("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.
it("getPagesNavigationContext returns the same reference for repeated SSR calls 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",
});
try {
const first = getPagesNavigationContext();
const second = getPagesNavigationContext();
expect(first).not.toBeNull();
expect(first).toBe(second);
expect(first!.params).toEqual({ dynamic: "foobar" });
// params and searchParams must also be reference-stable across calls so
// hook-level snapshots (`pagesCtx.params`, `pagesCtx.searchParams`) are
// Object.is-equal between renders.
expect(first!.params).toBe(second!.params);
expect(first!.searchParams).toBe(second!.searchParams);
} finally {
setSSRContext(null);
}
});
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("does not install the Pages Router popstate runtime when next/router is imported", 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");
expect(win.addEventListener).not.toHaveBeenCalledWith("popstate", expect.any(Function));
} 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();
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>`;
}
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 };
}
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 = path.resolve(import.meta.dirname, "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 = path.resolve(import.meta.dirname, "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 = path.resolve(import.meta.dirname, "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 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 = path.resolve(import.meta.dirname, "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("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 = path.resolve(import.meta.dirname, "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 = path.resolve(import.meta.dirname, "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 = path.resolve(import.meta.dirname, "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 = path.resolve(import.meta.dirname, "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;
}
});
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 = path.resolve(import.meta.dirname, "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 = path.resolve(import.meta.dirname, "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("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 = path.resolve(import.meta.dirname, "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 = path.resolve(import.meta.dirname, "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("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();
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("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 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,
});
},
},
};
vi.resetModules();
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
await Router.prefetch("/about");
const prefetchLink = appendedLinks.find((l) => l.rel === "prefetch");
expect(prefetchLink).toBeDefined();
expect(prefetchLink?.as).toBe("fetch");
expect(prefetchLink?.href).toBe(`/_next/data/${buildId}/about.json`);
expect(prefetchLink?.crossOrigin).toBe("anonymous");
// 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;
vi.resetModules();
}
});
it("falls back to as=document prefetch when no loader matches", async () => {
const previousWindow = (globalThis as any).window;
const originalDocument = (globalThis as any).document;
const { win } = createDataNavWindow({
loaders: { "/": vi.fn(async () => makePageModule("home")) },
});
(globalThis as any).window = win;
const appendedLinks: Array<{ rel: string; as: string; href: string }> = [];
(globalThis as any).document = {
createElement: () => ({ rel: "", as: "", href: "" }),
head: {
appendChild: (node: any) => {
appendedLinks.push({ rel: node.rel, as: node.as, href: node.href });
},
},
};
vi.resetModules();
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
await Router.prefetch("/unmapped");
const prefetchLink = appendedLinks.find((l) => l.rel === "prefetch");
expect(prefetchLink?.as).toBe("document");
} finally {
if (previousWindow === undefined) delete (globalThis as any).window;
else (globalThis as any).window = previousWindow;
(globalThis as any).document = originalDocument;
vi.resetModules();
}
});
it("strips the locale prefix before matching loaders and updates __NEXT_DATA__.locale", async () => {
// Regression: route patterns in __VINEXT_PAGE_PATTERNS__ are
// locale-unaware (e.g. /about, not /fr/about). Without stripping the
// locale prefix from the URL before pattern matching, locale transitions
// would always miss the loader map and fall through to the slower HTML
// path. The data URL itself must keep the locale prefix so the server
// returns locale-specific gSSP data.
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const loaderAbout = vi.fn(async () => makePageModule("about"));
const { win, buildId } = createDataNavWindow({
loaders: {
"/": vi.fn(async () => makePageModule("home")),
"/about": loaderAbout,
},
locale: "en",
});
(win as any).__VINEXT_LOCALES__ = ["en", "fr"];
(win as any).__VINEXT_DEFAULT_LOCALE__ = "en";
(win.__NEXT_DATA__ as any).locale = "en";
(win.__NEXT_DATA__ as any).locales = ["en", "fr"];
(win.__NEXT_DATA__ as any).defaultLocale = "en";
(globalThis as any).window = win;
vi.resetModules();
const fetchMock = vi.fn(
async () =>
new Response(JSON.stringify({ pageProps: { hello: "bonjour" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
globalThis.fetch = fetchMock as any;
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
const result = await Router.push("/fr/about");
expect(result).toBe(true);
// URL kept the locale prefix — the server uses it to pick the locale.
const [calledUrl] = (fetchMock.mock.calls[0] ?? []) as unknown as [unknown, RequestInit?];
expect(String(calledUrl)).toBe(`/_next/data/${buildId}/fr/about.json`);
// Loader matched on the locale-stripped pattern.
expect(loaderAbout).toHaveBeenCalledTimes(1);
// Page resolved to the unprefixed route pattern.
expect(win.__NEXT_DATA__.page).toBe("/about");
// Locale globals updated to the new locale so useRouter().locale reflects it.
expect((win.__NEXT_DATA__ as any).locale).toBe("fr");
expect(win.__VINEXT_LOCALE__).toBe("fr");
} finally {
if (previousWindow === undefined) delete (globalThis as any).window;
else (globalThis as any).window = previousWindow;
globalThis.fetch = originalFetch;
vi.resetModules();
}
});
it("restores the default locale on transitions back to an unprefixed URL", async () => {
// `Router.push("/about", undefined, { locale: "en" })` from a French page
// switches back to the default locale. The data URL drops the locale
// prefix (default locale is unprefixed) and __NEXT_DATA__.locale flips
// back to "en" so useRouter().locale tracks the new locale.
const previousWindow = (globalThis as any).window;
const originalFetch = globalThis.fetch;
const loaderAbout = vi.fn(async () => makePageModule("about"));
const { win, buildId } = createDataNavWindow({
loaders: {
"/": vi.fn(async () => makePageModule("home")),
"/about": loaderAbout,
},
locale: "fr",
pathname: "/fr",
});
(win as any).__VINEXT_LOCALES__ = ["en", "fr"];
(win as any).__VINEXT_DEFAULT_LOCALE__ = "en";
(win.__NEXT_DATA__ as any).locale = "fr";
(win.__NEXT_DATA__ as any).locales = ["en", "fr"];
(win.__NEXT_DATA__ as any).defaultLocale = "en";
(globalThis as any).window = win;
vi.resetModules();
globalThis.fetch = vi.fn(
async () =>
new Response(JSON.stringify({ pageProps: {} }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
) as any;
try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
const Router = routerModule.default;
await Router.push("/about", undefined, { locale: "en" });
const [calledUrl] = (globalThis.fetch as any).mock.calls[0];
expect(String(calledUrl)).toBe(`/_next/data/${buildId}/about.json`);
expect((win.__NEXT_DATA__ as any).locale).toBe("en");
expect(win.__VINEXT_LOCALE__).toBe("en");
} finally {
if (previousWindow === undefined) delete (globalThis as any).window;
else (globalThis as any).window = previousWindow;
globalThis.fetch = originalFetch;
vi.resetModules();
}
});
});
describe("next/server enhancements", () => {
it("NextRequest.ip extracts from x-forwarded-for header", async () => {
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
const req = new NextRequest("https://example.com", {
headers: { "x-forwarded-for": "1.2.3.4, 5.6.7.8" },
});
expect(req.ip).toBe("1.2.3.4");
});
it("NextRequest.ip returns undefined without forwarded header", async () => {
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
const req = new NextRequest("https://example.com");
expect(req.ip).toBeUndefined();
});
it("NextRequest.geo extracts from Cloudflare/Vercel headers", async () => {
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
const req = new NextRequest("https://example.com", {
headers: {
"cf-ipcountry": "US",
"cf-ipcity": "San Francisco",
},
});
const geo = req.geo;
expect(geo).toBeDefined();
expect(geo!.country).toBe("US");
expect(geo!.city).toBe("San Francisco");
});
it("NextRequest.geo returns undefined without geo headers", async () => {
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
const req = new NextRequest("https://example.com");
expect(req.geo).toBeUndefined();
});
it("ResponseCookies.getAll returns all set cookies", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("a", "1");
cookies.set("b", "2");
const all = cookies.getAll();
expect(all).toHaveLength(2);
expect(all.find((c: any) => c.name === "a")?.value).toBe("1");
expect(all.find((c: any) => c.name === "b")?.value).toBe("2");
});
});
describe("next/image enhancements", () => {
it("exports StaticImageData type", async () => {
const imageModule = await import("../packages/vinext/src/shims/image.js");
// StaticImageData is an interface, so we can't check at runtime
// but getImageProps uses it — verify that function exists
expect(typeof imageModule.getImageProps).toBe("function");
});
it("getImageProps returns img props from Image props", async () => {
const { getImageProps } = await import("../packages/vinext/src/shims/image.js");
const result = getImageProps({
src: "/photo.jpg",
alt: "Test",
width: 800,
height: 600,
priority: true,
});
// Local images now route through the optimization endpoint
expect(result.props.src).toContain("/_next/image");
expect(result.props.src).toContain("url=%2Fphoto.jpg");
expect(result.props.src).toContain("w=800");
expect(result.props.alt).toBe("Test");
expect(result.props.width).toBe(800);
expect(result.props.height).toBe(600);
expect(result.props.loading).toBe("eager");
});
it("getImageProps handles fill mode", async () => {
const { getImageProps } = await import("../packages/vinext/src/shims/image.js");
const result = getImageProps({
src: "/bg.jpg",
alt: "Background",
fill: true,
});
expect(result.props.width).toBeUndefined();
expect(result.props.height).toBeUndefined();
expect(result.props.style?.position).toBe("absolute");
});
it("getImageProps handles StaticImageData", async () => {
const { getImageProps } = await import("../packages/vinext/src/shims/image.js");
const result = getImageProps({
src: { src: "/imported.jpg", width: 1200, height: 800, blurDataURL: "data:..." },
alt: "Imported",
});
expect(result.props.src).toContain("/_next/image");
expect(result.props.src).toContain("url=%2Fimported.jpg");
expect(result.props.src).toContain("w=1200");
expect(result.props.width).toBe(1200);
expect(result.props.height).toBe(800);
});
it("getImageProps generates srcSet for local images with width", async () => {
const { getImageProps } = await import("../packages/vinext/src/shims/image.js");
const result = getImageProps({
src: "/photo.jpg",
alt: "Test",
width: 1200,
height: 800,
});
expect(result.props.srcSet).toBeDefined();
// srcSet entries point to /_next/image optimization endpoint
expect(result.props.srcSet).toContain("/_next/image");
expect(result.props.srcSet).toContain("url=%2Fphoto.jpg");
expect(result.props.srcSet).toContain("w");
});
it("getImageProps does not generate srcSet for fill images", async () => {
const { getImageProps } = await import("../packages/vinext/src/shims/image.js");
const result = getImageProps({
src: "/bg.jpg",
alt: "Background",
fill: true,
});
expect(result.props.srcSet).toBeUndefined();
expect(result.props.sizes).toBe("100vw"); // fill implies 100vw
});
it("getImageProps includes fetchPriority for priority images", async () => {
const { getImageProps } = await import("../packages/vinext/src/shims/image.js");
const result = getImageProps({
src: "/hero.jpg",
alt: "Hero",
width: 1200,
height: 800,
priority: true,
});
expect(result.props.fetchPriority).toBe("high");
expect(result.props.loading).toBe("eager");
});
it("getImageProps includes data-nimg attribute", async () => {
const { getImageProps } = await import("../packages/vinext/src/shims/image.js");
const result = getImageProps({
src: "/photo.jpg",
alt: "Photo",
width: 800,
height: 600,
});
expect((result.props as any)["data-nimg"]).toBe("1");
const fillResult = getImageProps({
src: "/bg.jpg",
alt: "BG",
fill: true,
});
expect((fillResult.props as any)["data-nimg"]).toBe("fill");
});
it("getImageProps includes blur placeholder background styles", async () => {
const { getImageProps } = await import("../packages/vinext/src/shims/image.js");
const blurUrl = "data:image/jpeg;base64,/9j/4AAQ";
const result = getImageProps({
src: "/photo.jpg",
alt: "Blurry",
width: 800,
height: 600,
placeholder: "blur",
blurDataURL: blurUrl,
});
expect(result.props.style?.backgroundImage).toContain(blurUrl);
expect(result.props.style?.backgroundSize).toBe("cover");
});
it("getImageProps uses custom loader function", async () => {
const { getImageProps } = await import("../packages/vinext/src/shims/image.js");
const result = getImageProps({
src: "/photo.jpg",
alt: "Custom",
width: 800,
height: 600,
loader: ({ src, width, quality }) => `https://cdn.example.com${src}?w=${width}&q=${quality}`,
});
// Custom loader bypasses the /_next/image endpoint
expect(result.props.src).toBe("https://cdn.example.com/photo.jpg?w=800&q=75");
expect(result.props.src).not.toContain("/_next/image");
});
it("unoptimized prop bypasses /_next/image endpoint", async () => {
const { getImageProps } = await import("../packages/vinext/src/shims/image.js");
const result = getImageProps({
src: "/photo.jpg",
alt: "Unoptimized",
width: 800,
height: 600,
unoptimized: true,
});
// unoptimized=true should serve the raw src, not the optimization endpoint
expect(result.props.src).toBe("/photo.jpg");
expect(result.props.src).not.toContain("/_next/image");
});
it("SVG src auto-skips optimization endpoint (default behavior)", async () => {
const { getImageProps } = await import("../packages/vinext/src/shims/image.js");
const result = getImageProps({
src: "/logo.svg",
alt: "SVG logo",
width: 200,
height: 200,
});
// By default (dangerouslyAllowSVG not set), .svg sources bypass the optimizer
expect(result.props.src).toBe("/logo.svg");
expect(result.props.src).not.toContain("/_next/image");
});
it("non-SVG src still uses optimization endpoint", async () => {
const { getImageProps } = await import("../packages/vinext/src/shims/image.js");
const result = getImageProps({
src: "/photo.png",
alt: "PNG photo",
width: 256,
height: 256,
});
expect(result.props.src).toContain("/_next/image");
});
});
describe("next/image component rendering", () => {
it("renders basic image with src, alt, width, height", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, { src: "/photo.jpg", alt: "Test photo", width: 800, height: 600 }),
);
// Local images route through the optimization endpoint
expect(html).toContain("/_next/image");
expect(html).toContain("url=%2Fphoto.jpg");
expect(html).toContain('alt="Test photo"');
expect(html).toContain('width="800"');
expect(html).toContain('height="600"');
});
it("renders fill image with absolute positioning", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, { src: "/bg.jpg", alt: "Background", fill: true }),
);
expect(html).toContain("position:absolute");
expect(html).toContain('data-nimg="fill"');
// fill images should not have width/height attributes
expect(html).not.toContain("width=");
expect(html).not.toContain("height=");
});
it("renders priority image with fetchpriority=high and loading=eager", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, {
src: "/hero.jpg",
alt: "Hero",
width: 1200,
height: 800,
priority: true,
}),
);
expect(html).toContain('fetchPriority="high"');
expect(html).toContain('loading="eager"');
});
it("renders lazy loading by default (no priority)", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, { src: "/photo.jpg", alt: "Photo", width: 800, height: 600 }),
);
expect(html).toContain('loading="lazy"');
expect(html).not.toContain("fetchPriority");
});
it("renders srcSet for local images with width", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, { src: "/photo.jpg", alt: "Photo", width: 1200, height: 800 }),
);
expect(html).toContain("srcSet");
// srcSet entries point to /_next/image optimization endpoint
expect(html).toContain("/_next/image");
expect(html).toContain("url=%2Fphoto.jpg");
});
it("renders blur placeholder with background-image", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const blurUrl = "data:image/jpeg;base64,/9j/4AAQ";
const html = renderToStaticMarkup(
React.createElement(Image, {
src: "/photo.jpg",
alt: "Blurry",
width: 800,
height: 600,
placeholder: "blur",
blurDataURL: blurUrl,
}),
);
expect(html).toContain(blurUrl);
expect(html).toContain("background-image");
expect(html).toContain("background-size:cover");
});
it("renders with custom loader function", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, {
src: "/photo.jpg",
alt: "Custom",
width: 800,
height: 600,
loader: ({ src, width, quality }: { src: string; width: number; quality?: number }) =>
`https://cdn.example.com${src}?w=${width}&q=${quality}`,
}),
);
expect(html).toContain('src="https://cdn.example.com/photo.jpg?w=800&amp;q=75"');
});
it("renders with custom sizes attribute", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, {
src: "/photo.jpg",
alt: "Responsive",
width: 1200,
height: 800,
sizes: "(max-width: 768px) 100vw, 50vw",
}),
);
expect(html).toContain('sizes="(max-width: 768px) 100vw, 50vw"');
});
it("renders fill image with sizes=100vw by default", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, { src: "/bg.jpg", alt: "BG", fill: true }),
);
expect(html).toContain('sizes="100vw"');
});
it("handles StaticImageData import objects", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const staticImport = {
src: "/imported.jpg",
width: 1200,
height: 800,
blurDataURL: "data:...",
};
const html = renderToStaticMarkup(
React.createElement(Image, { src: staticImport, alt: "Imported" }),
);
expect(html).toContain("/_next/image");
expect(html).toContain("url=%2Fimported.jpg");
expect(html).toContain('width="1200"');
expect(html).toContain('height="800"');
});
it("renders with className", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, {
src: "/photo.jpg",
alt: "Styled",
width: 800,
height: 600,
className: "rounded-lg shadow-md",
}),
);
expect(html).toContain('class="rounded-lg shadow-md"');
});
it("includes data-nimg=1 for non-fill images", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, { src: "/photo.jpg", alt: "Test", width: 800, height: 600 }),
);
expect(html).toContain('data-nimg="1"');
});
it("always sets decoding=async", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, { src: "/photo.jpg", alt: "Test", width: 800, height: 600 }),
);
expect(html).toContain('decoding="async"');
});
// ── SSR-only smoke tests: verify SSR output does not crash when onLoad / onError /
// ref are provided. The hydration replay logic (useLayoutEffect, img.src = img.src,
// mergedRef DOM node capture) requires a client-side mount; the handler wiring is
// tested in the Playwright E2E test suite.
it("renders with onError callback attached (SSR smoke test)", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, {
src: "/broken.jpg",
alt: "Broken",
width: 400,
height: 300,
onError: () => {},
}),
);
// SSR should render without errors — the onError replay is client-side only
expect(html).toContain("<img");
expect(html).toContain("/_next/image");
});
it("renders with both onLoad and onError callbacks (SSR smoke test)", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, {
src: "/photo.jpg",
alt: "Photo",
width: 800,
height: 600,
onLoad: () => {},
onError: () => {},
}),
);
expect(html).toContain("<img");
expect(html).toContain("/_next/image");
});
it("forwards ref to img element via mergedRef (SSR smoke test)", async () => {
const React = await import("react");
const { renderToString } = await import("react-dom/server");
const ref = React.createRef<HTMLImageElement>();
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToString(
React.createElement(Image, {
src: "/photo.jpg",
alt: "Ref test",
width: 800,
height: 600,
ref,
}),
);
expect(html).toContain("<img");
expect(html).toContain("/_next/image");
// ref.current is null after SSR — that's expected since refs don't hydrate in SSR
expect(ref.current).toBeNull();
});
it("renders with onError in loader path (SSR smoke test)", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, {
src: "/photo.jpg",
alt: "Loader",
width: 800,
height: 600,
onError: () => {},
loader: ({ src, width, quality }) =>
`https://cdn.example.com${src}?w=${width}&q=${quality}`,
}),
);
expect(html).toContain("<img");
expect(html).not.toContain("/_next/image");
expect(html).toContain("cdn.example.com");
});
});
describe("image remote pattern matching", () => {
it("matchRemotePattern matches exact hostname", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { hostname: "cdn.example.com" };
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/img.jpg"))).toBe(true);
expect(matchRemotePattern(pattern, new URL("https://other.example.com/img.jpg"))).toBe(false);
});
it("matchRemotePattern matches wildcard hostname with *", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { hostname: "*.example.com" };
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/img.jpg"))).toBe(true);
expect(matchRemotePattern(pattern, new URL("https://images.example.com/img.jpg"))).toBe(true);
// Single * should NOT match nested subdomains
expect(matchRemotePattern(pattern, new URL("https://a.b.example.com/img.jpg"))).toBe(false);
// Should not match bare domain
expect(matchRemotePattern(pattern, new URL("https://example.com/img.jpg"))).toBe(false);
});
it("matchRemotePattern matches ** hostname for any depth", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { hostname: "**.example.com" };
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/img.jpg"))).toBe(true);
expect(matchRemotePattern(pattern, new URL("https://a.b.c.example.com/img.jpg"))).toBe(true);
// ** requires at least one segment
expect(matchRemotePattern(pattern, new URL("https://example.com/img.jpg"))).toBe(false);
});
it("matchRemotePattern checks protocol when specified", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { protocol: "https", hostname: "cdn.example.com" };
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/img.jpg"))).toBe(true);
expect(matchRemotePattern(pattern, new URL("http://cdn.example.com/img.jpg"))).toBe(false);
});
it("matchRemotePattern checks port when specified", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { hostname: "localhost", port: "3000" };
expect(matchRemotePattern(pattern, new URL("http://localhost:3000/img.jpg"))).toBe(true);
expect(matchRemotePattern(pattern, new URL("http://localhost:8080/img.jpg"))).toBe(false);
});
it("matchRemotePattern checks pathname when specified", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { hostname: "cdn.example.com", pathname: "/images/**" };
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/images/photo.jpg"))).toBe(
true,
);
expect(
matchRemotePattern(pattern, new URL("https://cdn.example.com/images/nested/photo.jpg")),
).toBe(true);
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/other/photo.jpg"))).toBe(
false,
);
});
it("matchRemotePattern checks search when specified", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { hostname: "cdn.example.com", search: "?v=123" };
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/img.jpg?v=123"))).toBe(
true,
);
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/img.jpg?v=456"))).toBe(
false,
);
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/img.jpg"))).toBe(false);
});
it("matchRemotePattern defaults pathname to ** when not specified", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { hostname: "cdn.example.com" };
expect(
matchRemotePattern(pattern, new URL("https://cdn.example.com/any/deep/path/img.jpg")),
).toBe(true);
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/"))).toBe(true);
});
it("matchRemotePattern handles protocol with trailing colon", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { protocol: "https:", hostname: "cdn.example.com" };
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/img.jpg"))).toBe(true);
});
it("hasRemoteMatch checks domains list", async () => {
const { hasRemoteMatch } = await import("../packages/vinext/src/shims/image-config.js");
const domains = ["cdn.example.com", "images.example.com"];
expect(hasRemoteMatch(domains, [], new URL("https://cdn.example.com/img.jpg"))).toBe(true);
expect(hasRemoteMatch(domains, [], new URL("https://images.example.com/img.jpg"))).toBe(true);
expect(hasRemoteMatch(domains, [], new URL("https://other.example.com/img.jpg"))).toBe(false);
});
it("hasRemoteMatch checks remotePatterns when domains don't match", async () => {
const { hasRemoteMatch } = await import("../packages/vinext/src/shims/image-config.js");
const patterns = [{ protocol: "https", hostname: "**.cdn.example.com" }];
expect(hasRemoteMatch([], patterns, new URL("https://us.cdn.example.com/img.jpg"))).toBe(true);
expect(hasRemoteMatch([], patterns, new URL("http://us.cdn.example.com/img.jpg"))).toBe(false);
});
it("matchRemotePattern handles single * in pathname", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { hostname: "cdn.example.com", pathname: "/images/*" };
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/images/photo.jpg"))).toBe(
true,
);
// Single * should not match nested paths
expect(
matchRemotePattern(pattern, new URL("https://cdn.example.com/images/nested/photo.jpg")),
).toBe(false);
});
it("matchRemotePattern handles regex special chars in hostname", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
// Dots in hostname are literal (escaped for regex)
const pattern = { hostname: "cdn.example.com" };
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/img.jpg"))).toBe(true);
// "cdnXexampleXcom" should not match "cdn.example.com"
expect(matchRemotePattern(pattern, new URL("https://cdnXexample.com/img.jpg"))).toBe(false);
});
});
describe("image optimization URL generation", () => {
it("imageOptimizationUrl generates correct URL", async () => {
const { imageOptimizationUrl } = await import("../packages/vinext/src/shims/image.js");
const url = imageOptimizationUrl("/images/hero.webp", 1200, 75);
expect(url).toBe("/_next/image?url=%2Fimages%2Fhero.webp&w=1200&q=75");
});
it("imageOptimizationUrl encodes special characters", async () => {
const { imageOptimizationUrl } = await import("../packages/vinext/src/shims/image.js");
const url = imageOptimizationUrl("/images/my photo.jpg", 800, 80);
expect(url).toContain("url=%2Fimages%2Fmy%20photo.jpg");
expect(url).toContain("w=800");
expect(url).toContain("q=80");
});
it("imageOptimizationUrl uses default quality of 75", async () => {
const { imageOptimizationUrl } = await import("../packages/vinext/src/shims/image.js");
const url = imageOptimizationUrl("/img.png", 640);
expect(url).toContain("q=75");
});
});
describe("image optimization request parsing", () => {
it("parseImageParams extracts url, width, quality", async () => {
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
const url = new URL("http://localhost/_next/image?url=%2Fimages%2Fhero.webp&w=1200&q=75");
const params = parseImageParams(url);
expect(params).not.toBeNull();
expect(params!.imageUrl).toBe("/images/hero.webp");
expect(params!.width).toBe(1200);
expect(params!.quality).toBe(75);
});
it("parseImageParams returns null when url is missing", async () => {
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
const url = new URL("http://localhost/_next/image?w=800&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=800");
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=800");
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=800");
expect(parseImageParams(url)).toBeNull();
});
it("parseImageParams defaults width to 0 and quality to 75", async () => {
const { parseImageParams } =
await import("../packages/vinext/src/server/image-optimization.js");
const url = new URL("http://localhost/_next/image?url=%2Fimg.jpg");
const params = parseImageParams(url);
expect(params).not.toBeNull();
expect(params!.width).toBe(0);
expect(params!.quality).toBe(75);
});
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=800",
),
),
).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=800")),
).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=800"))).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&q=0"))).toBeNull();
expect(
parseImageParams(new URL("http://localhost/_next/image?url=%2Fimg.jpg&q=101")),
).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=800")),
).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=800")),
).toBeNull();
// /\\evil.com (double backslash)
expect(
parseImageParams(new URL("http://localhost/_next/image?url=%2F%5C%5Cevil.com&w=800")),
).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=800"),
);
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=800"),
);
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")),
).toBeNull();
expect(
parseImageParams(new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=999999999")),
).toBeNull();
expect(
parseImageParams(new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=2147483647")),
).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"));
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"),
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"), allowedWidths),
).toBeNull();
// w=0 (no resize) is always allowed even with allowedWidths
const noResize = parseImageParams(
new URL("http://localhost/_next/image?url=%2Fimg.jpg&w=0"),
allowedWidths,
);
expect(noResize).not.toBeNull();
expect(noResize!.width).toBe(0);
});
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"),
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=800");
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=800");
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=800&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);
expect(response.status).toBe(200);
expect(await response.text()).toBe("transformed");
expect(capturedOptions).toEqual({ width: 800, 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=800");
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=800");
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=800");
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=800");
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=800");
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=800");
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=800",
);
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=100&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=100&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=800");
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=800");
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=800&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);
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=800&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);
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=100&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=100&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=100&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=100&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=800");
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=800");
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=800");
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=800");
const handlers = {
fetchAsset: async () =>
new Response("image-data", {
status: 200,
headers: { "Content-Type": "image/jpeg" },
}),
};
const response = await handleImageOptimization(request, handlers);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Security-Policy")).toBe(
"script-src 'none'; frame-src 'none'; sandbox;",
);
expect(response.headers.get("Content-Disposition")).toBe("inline");
});
});
describe("next/navigation enhancements", () => {
it("exports ReadonlyURLSearchParams runtime class", async () => {
const nav = await import("../packages/vinext/src/shims/navigation.js");
expect(typeof nav.ReadonlyURLSearchParams).toBe("function");
expect(typeof nav.useServerInsertedHTML).toBe("function");
});
it("ReadonlyURLSearchParams preserves reads and blocks mutation methods", async () => {
const { ReadonlyURLSearchParams } = await import("../packages/vinext/src/shims/navigation.js");
const searchParams = new ReadonlyURLSearchParams("foo=bar&foo=baz&zap=zazzle");
expect(searchParams).toBeInstanceOf(URLSearchParams);
expect(searchParams).toBeInstanceOf(ReadonlyURLSearchParams);
expect(searchParams.get("foo")).toBe("bar");
expect(searchParams.getAll("foo")).toEqual(["bar", "baz"]);
expect(searchParams.toString()).toBe("foo=bar&foo=baz&zap=zazzle");
expect(() => searchParams.append("x", "1")).toThrow(
"Method unavailable on `ReadonlyURLSearchParams`.",
);
expect(() => searchParams.delete("foo")).toThrow(
"Method unavailable on `ReadonlyURLSearchParams`.",
);
expect(() => searchParams.set("foo", "qux")).toThrow(
"Method unavailable on `ReadonlyURLSearchParams`.",
);
expect(() => searchParams.sort()).toThrow("Method unavailable on `ReadonlyURLSearchParams`.");
expect(searchParams.toString()).toBe("foo=bar&foo=baz&zap=zazzle");
});
it("useSearchParams returns a readonly wrapper on the server path", async () => {
const { ReadonlyURLSearchParams, setNavigationContext, useSearchParams } =
await import("../packages/vinext/src/shims/navigation.js");
try {
setNavigationContext({
pathname: "/readonly-test",
searchParams: new URLSearchParams("foo=bar&foo=baz"),
params: {},
});
const searchParams = useSearchParams();
expect(searchParams).toBeInstanceOf(ReadonlyURLSearchParams);
expect(searchParams.getAll("foo")).toEqual(["bar", "baz"]);
expect(() => searchParams.set("foo", "qux")).toThrow(
"Method unavailable on `ReadonlyURLSearchParams`.",
);
} finally {
setNavigationContext(null);
}
});
it("useSearchParams reuses the same readonly wrapper for the same server context", async () => {
const { setNavigationContext, useSearchParams } =
await import("../packages/vinext/src/shims/navigation.js");
try {
const ctx = {
pathname: "/readonly-test",
searchParams: new URLSearchParams("foo=bar"),
params: {},
};
setNavigationContext(ctx);
const first = useSearchParams();
const second = useSearchParams();
expect(first).toBe(second);
} finally {
setNavigationContext(null);
}
});
it("useSearchParams keeps wrapper identity stable across concurrent ALS-scoped requests", async () => {
const { runWithNavigationContext } =
await import("../packages/vinext/src/shims/navigation-state.js");
const { setNavigationContext, useSearchParams } =
await import("../packages/vinext/src/shims/navigation.js");
let releaseInterleave!: () => void;
const waitForInterleave = new Promise<void>((resolve) => {
releaseInterleave = resolve;
});
async function runRequest(query: string, pathname: string) {
return runWithNavigationContext(async () => {
setNavigationContext({
pathname,
searchParams: new URLSearchParams(query),
params: {},
});
const first = useSearchParams();
await waitForInterleave;
const second = useSearchParams();
return {
first,
second,
value: first.toString(),
};
});
}
const requestA = runRequest("a=1", "/request-a");
const requestB = runRequest("b=2", "/request-b");
await Promise.resolve();
releaseInterleave();
const [a, b] = await Promise.all([requestA, requestB]);
expect(a.first).toBe(a.second);
expect(b.first).toBe(b.second);
expect(a.value).toBe("a=1");
expect(b.value).toBe("b=2");
expect(a.first).not.toBe(b.first);
});
it("useServerInsertedHTML is a no-op function", async () => {
const { useServerInsertedHTML } = await import("../packages/vinext/src/shims/navigation.js");
// Should not throw
expect(() => useServerInsertedHTML(() => null)).not.toThrow();
});
});
describe("next/legacy/image shim", () => {
it("renders LegacyImage with layout=fill as modern Image with fill prop", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const LegacyImage = (await import("../packages/vinext/src/shims/legacy-image.js")).default;
const html = renderToStaticMarkup(
React.createElement(LegacyImage, {
src: "/photo.jpg",
alt: "Test",
layout: "fill",
objectFit: "cover",
objectPosition: "center",
}),
);
expect(html).toContain("photo.jpg");
expect(html).toContain("alt");
// fill mode should produce absolute positioning styles
expect(html).toContain("position:absolute");
});
it("renders LegacyImage with layout=intrinsic using width/height", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const LegacyImage = (await import("../packages/vinext/src/shims/legacy-image.js")).default;
const html = renderToStaticMarkup(
React.createElement(LegacyImage, {
src: "/photo.jpg",
alt: "Test",
layout: "intrinsic",
width: 640,
height: 480,
}),
);
expect(html).toContain('width="640"');
expect(html).toContain('height="480"');
});
it("renders LegacyImage with string width/height (converts to number)", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const LegacyImage = (await import("../packages/vinext/src/shims/legacy-image.js")).default;
const html = renderToStaticMarkup(
React.createElement(LegacyImage, {
src: "/photo.jpg",
alt: "Test",
width: "200",
height: "150",
}),
);
expect(html).toContain('width="200"');
expect(html).toContain('height="150"');
});
});
describe("next/error shim", () => {
it("renders 404 error page", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const ErrorComponent = (await import("../packages/vinext/src/shims/error.js")).default;
const html = renderToStaticMarkup(React.createElement(ErrorComponent, { statusCode: 404 }));
expect(html).toContain("404");
expect(html).toContain("could not be found");
});
it("renders 500 error page", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const ErrorComponent = (await import("../packages/vinext/src/shims/error.js")).default;
const html = renderToStaticMarkup(React.createElement(ErrorComponent, { statusCode: 500 }));
expect(html).toContain("500");
expect(html).toContain("Internal Server Error");
});
it("renders custom title", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const ErrorComponent = (await import("../packages/vinext/src/shims/error.js")).default;
const html = renderToStaticMarkup(
React.createElement(ErrorComponent, { statusCode: 403, title: "Forbidden" }),
);
expect(html).toContain("403");
expect(html).toContain("Forbidden");
});
});
// next/app default export
//
// Ported from Next.js: packages/next/src/pages/_app.tsx
// https://github.com/vercel/next.js/blob/canary/packages/next/src/pages/_app.tsx
//
// Pages Router fixtures very commonly do:
// import App from "next/app";
// export default class MyApp extends App { ... }
// or call App.getInitialProps(appContext) from a custom getInitialProps.
// Without a runtime default export, every such fixture fails to build with
// "[MISSING_EXPORT] 'default' is not exported by ...shims/app.js".
describe("next/app shim", () => {
it("exports a React class component as default", async () => {
const React = await import("react");
const AppDefault = (await import("../packages/vinext/src/shims/app.js")).default;
expect(typeof AppDefault).toBe("function");
// Class component: prototype must have a render method, instances must
// be instances of React.Component.
expect(typeof AppDefault.prototype.render).toBe("function");
const instance = new AppDefault({ Component: () => null, pageProps: {} });
expect(instance).toBeInstanceOf(React.Component);
});
it("default App.render() returns <Component {...pageProps} />", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const AppDefault = (await import("../packages/vinext/src/shims/app.js")).default;
function Page(props: { greeting: string }) {
return React.createElement("p", null, props.greeting);
}
// Use explicit generics so React.createElement's prop type inference
// lines up with `AppProps<{ greeting: string }>`.
const html = renderToStaticMarkup(
React.createElement(AppDefault<unknown, { greeting: string }>, {
Component: Page,
pageProps: { greeting: "hello world" },
}),
);
expect(html).toBe("<p>hello world</p>");
});
it("App.getInitialProps is a function and forwards Component.getInitialProps result as pageProps", async () => {
const AppDefault = (await import("../packages/vinext/src/shims/app.js")).default;
expect(typeof AppDefault.getInitialProps).toBe("function");
// origGetInitialProps is preserved for userland code that introspects it.
expect(typeof AppDefault.origGetInitialProps).toBe("function");
const pageCtx = { req: { url: "/test" } };
const Component = Object.assign(() => null, {
getInitialProps: async (ctx: unknown) => {
expect(ctx).toBe(pageCtx);
return { foo: "bar" };
},
});
const result = await AppDefault.getInitialProps({
Component,
AppTree: () => null,
ctx: pageCtx,
router: {},
});
expect(result).toEqual({ pageProps: { foo: "bar" } });
});
it("App.getInitialProps returns { pageProps: {} } when Component has no getInitialProps", async () => {
const AppDefault = (await import("../packages/vinext/src/shims/app.js")).default;
const result = await AppDefault.getInitialProps({
Component: () => null,
AppTree: () => null,
ctx: {},
router: {},
});
expect(result).toEqual({ pageProps: {} });
});
});
describe("next/constants shim", () => {
it("exports all phase constants", async () => {
const constants = await import("../packages/vinext/src/shims/constants.js");
const constantsFromNext = await import("next/constants");
const normalizeConstants = (mod: Record<string, unknown>) => {
const {
__esModule: _esModule,
default: _default,
CLIENT_STATIC_FILES_RUNTIME_POLYFILLS_SYMBOL,
...rest
} = mod;
return {
...rest,
CLIENT_STATIC_FILES_RUNTIME_POLYFILLS_SYMBOL:
typeof CLIENT_STATIC_FILES_RUNTIME_POLYFILLS_SYMBOL === "symbol"
? CLIENT_STATIC_FILES_RUNTIME_POLYFILLS_SYMBOL.description
: CLIENT_STATIC_FILES_RUNTIME_POLYFILLS_SYMBOL,
};
};
expect(normalizeConstants(constants)).toEqual(normalizeConstants(constantsFromNext));
});
});
describe("next/script SSR rendering", () => {
it("beforeInteractive renders <script> tag in SSR", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Script = (await import("../packages/vinext/src/shims/script.js")).default;
const html = renderToStaticMarkup(
React.createElement(Script, {
src: "https://example.com/analytics.js",
strategy: "beforeInteractive",
id: "analytics",
}),
);
expect(html).toContain("<script");
expect(html).toContain('src="https://example.com/analytics.js"');
expect(html).toContain('id="analytics"');
});
it("afterInteractive emits a preload <link> (no <script> tag) in SSR", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Script = (await import("../packages/vinext/src/shims/script.js")).default;
const html = renderToStaticMarkup(
React.createElement(Script, {
src: "https://example.com/chat.js",
strategy: "afterInteractive",
}),
);
// afterInteractive does not render a <script> tag during SSR,
// but it does emit <link rel="preload" as="script"> via React Float
// so the script is fetched while HTML streams.
expect(html).toContain('<link rel="preload"');
expect(html).toContain('href="https://example.com/chat.js"');
expect(html).toContain('as="script"');
expect(html).not.toContain("<script");
});
it("lazyOnload returns null (no preload) in SSR", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Script = (await import("../packages/vinext/src/shims/script.js")).default;
const html = renderToStaticMarkup(
React.createElement(Script, {
src: "https://example.com/tracking.js",
strategy: "lazyOnload",
}),
);
expect(html).toBe("");
});
it("default strategy (no strategy prop) emits a preload <link> in SSR", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Script = (await import("../packages/vinext/src/shims/script.js")).default;
const html = renderToStaticMarkup(
React.createElement(Script, {
src: "https://example.com/default.js",
}),
);
// Default is afterInteractive → preload link only, no <script> tag.
expect(html).toContain('<link rel="preload"');
expect(html).toContain('href="https://example.com/default.js"');
expect(html).toContain('as="script"');
expect(html).not.toContain("<script");
});
it("beforeInteractive with dangerouslySetInnerHTML renders inline script", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Script = (await import("../packages/vinext/src/shims/script.js")).default;
const html = renderToStaticMarkup(
React.createElement(Script, {
strategy: "beforeInteractive",
id: "inline-script",
dangerouslySetInnerHTML: { __html: "console.log('hello')" },
}),
);
expect(html).toContain("<script");
expect(html).toContain('id="inline-script"');
expect(html).toContain("console.log('hello')");
});
it("beforeInteractive escapes </script> in dangerouslySetInnerHTML", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Script = (await import("../packages/vinext/src/shims/script.js")).default;
const html = renderToStaticMarkup(
React.createElement(Script, {
strategy: "beforeInteractive",
id: "escape-test",
dangerouslySetInnerHTML: {
__html: 'var x = "</script><img src=x onerror=alert(1)>";',
},
}),
);
// The raw </script> must NOT appear — it would break the tag boundary
expect(html).not.toContain("</script><img");
// The escaped form should be present instead
expect(html).toContain("<\\/script>");
});
it("beforeInteractive escapes </script> case-insensitively", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Script = (await import("../packages/vinext/src/shims/script.js")).default;
const html = renderToStaticMarkup(
React.createElement(Script, {
strategy: "beforeInteractive",
id: "case-test",
dangerouslySetInnerHTML: {
__html: 'var x = "</SCRIPT><img src=x onerror=alert(1)>";',
},
}),
);
// Mixed-case </SCRIPT> must also be escaped
expect(html).not.toContain("</SCRIPT>");
expect(html).toContain("<\\/SCRIPT>");
});
it("exports handleClientScriptLoad and initScriptLoader", async () => {
const scriptModule = await import("../packages/vinext/src/shims/script.js");
expect(typeof scriptModule.handleClientScriptLoad).toBe("function");
expect(typeof scriptModule.initScriptLoader).toBe("function");
});
});
describe("next/dist/* internal import shims", () => {
it("app-router-context exports AppRouterContext and types", async () => {
const mod = await import("../packages/vinext/src/shims/internal/app-router-context.js");
expect(mod.AppRouterContext).toBeDefined();
expect(mod.GlobalLayoutRouterContext).toBeDefined();
expect(mod.LayoutRouterContext).toBeDefined();
expect(mod.MissingSlotContext).toBeDefined();
expect(mod.TemplateContext).toBeDefined();
});
it("app-router-context imports when React.createContext is unavailable", async () => {
vi.resetModules();
vi.doMock("react", () => ({ createContext: undefined }));
try {
const mod = await import("../packages/vinext/src/shims/internal/app-router-context.js");
expect(mod.AppRouterContext).toBeNull();
expect(mod.GlobalLayoutRouterContext).toBeNull();
expect(mod.LayoutRouterContext).toBeNull();
expect(mod.MissingSlotContext).toBeNull();
expect(mod.TemplateContext).toBeNull();
} finally {
vi.doUnmock("react");
vi.resetModules();
}
});
it("utils exports NEXT_DATA type helpers", async () => {
const mod = await import("../packages/vinext/src/shims/internal/utils.js");
expect(typeof mod.execOnce).toBe("function");
expect(typeof mod.getLocationOrigin).toBe("function");
expect(typeof mod.getURL).toBe("function");
// execOnce should only call the function once
let count = 0;
const fn = mod.execOnce(() => ++count);
fn();
fn();
fn();
expect(count).toBe(1);
});
it("api-utils exports NextApiRequestCookies type", async () => {
// This module is primarily type-only, but should resolve without errors
const mod = await import("../packages/vinext/src/shims/internal/api-utils.js");
expect(mod).toBeDefined();
});
it("cookies shim re-exports RequestCookies and ResponseCookies", async () => {
const mod = await import("../packages/vinext/src/shims/internal/cookies.js");
expect(mod.RequestCookies).toBeDefined();
expect(mod.ResponseCookies).toBeDefined();
});
it("work-unit-async-storage exports AsyncLocalStorage instances", async () => {
const mod = await import("../packages/vinext/src/shims/internal/work-unit-async-storage.js");
expect(mod.workUnitAsyncStorage).toBeDefined();
expect(mod.requestAsyncStorage).toBeDefined();
// Both should be the same AsyncLocalStorage instance
expect(mod.workUnitAsyncStorage).toBe(mod.requestAsyncStorage);
});
it("router-context exports RouterContext as a React context", async () => {
const mod = await import("../packages/vinext/src/shims/internal/router-context.js");
expect(mod.RouterContext).toBeDefined();
// Must be a real React context object (has Provider and Consumer)
expect(mod.RouterContext.Provider).toBeDefined();
expect(mod.RouterContext.Consumer).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Cloudflare KV CacheHandler
// ---------------------------------------------------------------------------
describe("KVCacheHandler", () => {
// In-memory mock of Cloudflare KV namespace
function createMockKV() {
const store = new Map<string, { value: string; expirationTtl?: number }>();
return {
store,
async get(key: string): Promise<string | null> {
return store.get(key)?.value ?? null;
},
async put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void> {
store.set(key, { value, expirationTtl: options?.expirationTtl });
},
async delete(key: string): Promise<void> {
store.delete(key);
},
async list(options?: { prefix?: string; limit?: number; cursor?: string }) {
const prefix = options?.prefix ?? "";
const keys: Array<{ name: string }> = [];
for (const k of store.keys()) {
if (k.startsWith(prefix)) keys.push({ name: k });
}
return { keys, list_complete: true };
},
};
}
it("stores and retrieves a cache entry", async () => {
const { KVCacheHandler } =
await import("../packages/cloudflare/src/cache/kv-data-adapter.runtime.js");
const kv = createMockKV();
const handler = new KVCacheHandler(kv as any);
await handler.set("test-key", {
kind: "PAGES",
html: "<h1>Hello</h1>",
pageData: { props: {} },
headers: undefined,
status: 200,
});
const result = await handler.get("test-key");
expect(result).not.toBeNull();
expect(result!.value).not.toBeNull();
expect(result!.value!.kind).toBe("PAGES");
if (result!.value!.kind === "PAGES") {
expect(result!.value!.html).toBe("<h1>Hello</h1>");
expect(result!.value!.status).toBe(200);
}
});
it("returns null for missing keys", async () => {
const { KVCacheHandler } =
await import("../packages/cloudflare/src/cache/kv-data-adapter.runtime.js");
const kv = createMockKV();
const handler = new KVCacheHandler(kv as any);
const result = await handler.get("nonexistent");
expect(result).toBeNull();
});
it("handles tag-based invalidation", async () => {
const { KVCacheHandler } =
await import("../packages/cloudflare/src/cache/kv-data-adapter.runtime.js");
const kv = createMockKV();
const handler = new KVCacheHandler(kv as any);
await handler.set(
"tagged-entry",
{
kind: "FETCH",
data: { headers: {}, body: '{"result":1}', url: "test" },
tags: ["my-tag"],
revalidate: 60,
},
{ revalidate: 60, tags: ["my-tag"] },
);
// Before invalidation — entry exists
const before = await handler.get("tagged-entry");
expect(before).not.toBeNull();
// Invalidate the tag
await handler.revalidateTag("my-tag");
// After invalidation — entry should be treated as miss
const after = await handler.get("tagged-entry");
expect(after).toBeNull();
});
it("returns stale entry when past revalidation time", async () => {
const { KVCacheHandler } =
await import("../packages/cloudflare/src/cache/kv-data-adapter.runtime.js");
const kv = createMockKV();
const handler = new KVCacheHandler(kv as any);
// Set with very short revalidation (already expired)
await handler.set(
"stale-key",
{
kind: "PAGES",
html: "<h1>Stale</h1>",
pageData: {},
headers: undefined,
status: 200,
},
{ revalidate: -1 }, // already past
);
// Manually fix the revalidateAt to be in the past
const raw = await kv.get("cache:stale-key");
const entry = JSON.parse(raw!);
entry.revalidateAt = Date.now() - 1000;
await kv.put("cache:stale-key", JSON.stringify(entry));
const result = await handler.get("stale-key");
expect(result).not.toBeNull();
expect(result!.cacheState).toBe("stale");
expect(result!.value!.kind).toBe("PAGES");
});
it("serializes and restores APP_PAGE with rscData ArrayBuffer", async () => {
const { KVCacheHandler } =
await import("../packages/cloudflare/src/cache/kv-data-adapter.runtime.js");
const kv = createMockKV();
const handler = new KVCacheHandler(kv as any);
const originalData = new TextEncoder().encode("RSC payload data");
await handler.set("app-page-key", {
kind: "APP_PAGE",
html: "<div>App page</div>",
rscData: originalData.buffer as ArrayBuffer,
headers: { "x-custom": "value" },
postponed: undefined,
status: 200,
});
const result = await handler.get("app-page-key");
expect(result).not.toBeNull();
expect(result!.value!.kind).toBe("APP_PAGE");
if (result!.value!.kind === "APP_PAGE") {
expect(result!.value!.html).toBe("<div>App page</div>");
// rscData should be restored as ArrayBuffer
expect(result!.value!.rscData).toBeInstanceOf(ArrayBuffer);
const restored = new TextDecoder().decode(result!.value!.rscData!);
expect(restored).toBe("RSC payload data");
}
});
it("serializes and restores APP_ROUTE with body ArrayBuffer", async () => {
const { KVCacheHandler } =
await import("../packages/cloudflare/src/cache/kv-data-adapter.runtime.js");
const kv = createMockKV();
const handler = new KVCacheHandler(kv as any);
const body = new TextEncoder().encode('{"ok":true}');
await handler.set("route-key", {
kind: "APP_ROUTE",
body: body.buffer as ArrayBuffer,
status: 200,
headers: { "content-type": "application/json" },
});
const result = await handler.get("route-key");
expect(result).not.toBeNull();
if (result!.value!.kind === "APP_ROUTE") {
const restored = new TextDecoder().decode(result!.value!.body);
expect(restored).toBe('{"ok":true}');
}
});
it("sets KV expiration TTL based on revalidation period", async () => {
const { KVCacheHandler } =
await import("../packages/cloudflare/src/cache/kv-data-adapter.runtime.js");
const kv = createMockKV();
const handler = new KVCacheHandler(kv as any);
await handler.set(
"ttl-key",
{
kind: "PAGES",
html: "<h1>TTL</h1>",
pageData: {},
headers: undefined,
status: 200,
},
{ revalidate: 60 }, // 60 seconds
);
// The KV entry should have an expiration TTL set
const stored = kv.store.get("cache:ttl-key");
expect(stored).toBeDefined();
expect(stored!.expirationTtl).toBeDefined();
// KV TTL is always 30 days (2592000s) regardless of revalidation period.
// Staleness is tracked via revalidateAt in the stored JSON, not KV eviction.
// Tying TTL to revalidation period would cause frequently-revalidated pages
// (e.g. revalidate=5) to be evicted quickly under low traffic, forcing a
// blocking fresh render on the next request instead of serving stale content.
expect(stored!.expirationTtl).toBe(30 * 24 * 3600);
});
it("handles multiple tag invalidation in parallel", async () => {
const { KVCacheHandler } =
await import("../packages/cloudflare/src/cache/kv-data-adapter.runtime.js");
const kv = createMockKV();
const handler = new KVCacheHandler(kv as any);
await handler.set(
"multi-tag",
{
kind: "FETCH",
data: { headers: {}, body: "{}", url: "test" },
tags: ["tag-a", "tag-b"],
revalidate: 0,
},
{ tags: ["tag-a", "tag-b"] },
);
// Invalidate both tags at once
await handler.revalidateTag(["tag-a", "tag-b"]);
const result = await handler.get("multi-tag");
expect(result).toBeNull();
});
it("handles corrupted KV entries gracefully", async () => {
const { KVCacheHandler } =
await import("../packages/cloudflare/src/cache/kv-data-adapter.runtime.js");
const kv = createMockKV();
const handler = new KVCacheHandler(kv as any);
// Put corrupted data directly
await kv.put("cache:corrupt-key", "not valid json {{{");
const result = await handler.get("corrupt-key");
expect(result).toBeNull();
// The corrupted entry should be cleaned up
expect(await kv.get("cache:corrupt-key")).toBeNull();
});
});
// ─── server-only / client-only shims ─────────────────────────────────────────
describe("server-only shim", () => {
it("can be imported without error", async () => {
const mod = await import("../packages/vinext/src/shims/server-only.js");
expect(mod).toBeDefined();
});
it("exports nothing (empty marker module)", async () => {
const mod = await import("../packages/vinext/src/shims/server-only.js");
// The module should have no named exports (just the default module namespace)
const keys = Object.keys(mod).filter((k) => k !== "__esModule" && k !== "default");
expect(keys).toHaveLength(0);
});
});
describe("client-only shim", () => {
it("can be imported without error", async () => {
const mod = await import("../packages/vinext/src/shims/client-only.js");
expect(mod).toBeDefined();
});
it("exports nothing (empty marker module)", async () => {
const mod = await import("../packages/vinext/src/shims/client-only.js");
const keys = Object.keys(mod).filter((k) => k !== "__esModule" && k !== "default");
expect(keys).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// next/link — onNavigate / NavigateEvent (View Transitions support, Issue #38)
// ---------------------------------------------------------------------------
describe("next/link onNavigate / NavigateEvent", () => {
it("exports Link as default and useLinkStatus as named export", async () => {
const mod = await import("../packages/vinext/src/shims/link.js");
expect(typeof mod.default).toBe("object"); // forwardRef returns an object
expect(typeof mod.useLinkStatus).toBe("function");
});
it("NavigateEvent.preventDefault() sets defaultPrevented to true", () => {
// Mirrors the NavigateEvent construction in the Link click handler
let prevented = false;
const navEvent = {
url: new URL("/about", "http://localhost"),
preventDefault() {
prevented = true;
},
get defaultPrevented() {
return prevented;
},
};
expect(navEvent.defaultPrevented).toBe(false);
navEvent.preventDefault();
expect(navEvent.defaultPrevented).toBe(true);
});
it("NavigateEvent.defaultPrevented is false when preventDefault is not called", () => {
let prevented = false;
const navEvent = {
url: new URL("/products/1", "http://localhost"),
preventDefault() {
prevented = true;
},
get defaultPrevented() {
return prevented;
},
};
expect(navEvent.defaultPrevented).toBe(false);
expect(navEvent.url.pathname).toBe("/products/1");
});
it("onNavigate callback receives event with correct url", () => {
// Simulate what the Link component does in its click handler
const resolvedHref = "/view-transitions/posts/42";
const navUrl = new URL(resolvedHref, "http://localhost:3000");
let prevented = false;
const navEvent = {
url: navUrl,
preventDefault() {
prevented = true;
},
get defaultPrevented() {
return prevented;
},
};
// Simulated TransitionLink-style callback
const onNavigate = (event: typeof navEvent) => {
event.preventDefault();
};
onNavigate(navEvent);
expect(navEvent.defaultPrevented).toBe(true);
expect(navEvent.url.pathname).toBe("/view-transitions/posts/42");
});
it("multiple preventDefault() calls are idempotent", () => {
let prevented = false;
const navEvent = {
url: new URL("/", "http://localhost"),
preventDefault() {
prevented = true;
},
get defaultPrevented() {
return prevented;
},
};
navEvent.preventDefault();
navEvent.preventDefault();
navEvent.preventDefault();
expect(navEvent.defaultPrevented).toBe(true);
});
});
// ---------------------------------------------------------------------------
// vinext:react-canary — ViewTransition & addTransitionType polyfills (Issue #42)
// ---------------------------------------------------------------------------
describe("vinext:react-canary transform logic", () => {
// These tests verify the regex patterns used by the vinext:react-canary plugin
// to detect and rewrite imports of React canary APIs.
const canaryImportRegex =
/import\s*\{[^}]*(ViewTransition|addTransitionType)[^}]*\}\s*from\s*['"]react['"]/;
it("detects ViewTransition import from react", () => {
const code = `import { ViewTransition } from "react";`;
expect(canaryImportRegex.test(code)).toBe(true);
});
it("detects addTransitionType import from react", () => {
const code = `import { addTransitionType } from "react";`;
expect(canaryImportRegex.test(code)).toBe(true);
});
it("detects ViewTransition alongside other React imports", () => {
const code = `import { useState, ViewTransition, useEffect } from "react";`;
expect(canaryImportRegex.test(code)).toBe(true);
});
it("detects addTransitionType alongside other React imports", () => {
const code = `import { useRef, addTransitionType } from 'react';`;
expect(canaryImportRegex.test(code)).toBe(true);
});
it("detects both canary APIs in a single import", () => {
const code = `import { ViewTransition, addTransitionType } from "react";`;
expect(canaryImportRegex.test(code)).toBe(true);
});
it("does NOT match imports from other modules", () => {
const code = `import { ViewTransition } from "some-other-lib";`;
expect(canaryImportRegex.test(code)).toBe(false);
});
it("does NOT match non-canary React imports", () => {
const code = `import { useState, useEffect } from "react";`;
expect(canaryImportRegex.test(code)).toBe(false);
});
it("does NOT match default React import", () => {
const code = `import React from "react";`;
expect(canaryImportRegex.test(code)).toBe(false);
});
it("does NOT match namespace React import", () => {
const code = `import * as React from "react";`;
expect(canaryImportRegex.test(code)).toBe(false);
});
it("rewrites all 'from react' occurrences in a file with canary imports", () => {
const code = [
`import { useState } from "react";`,
`import { ViewTransition } from "react";`,
``,
`export default function Template({ children }) {`,
` return <ViewTransition>{children}</ViewTransition>;`,
`}`,
].join("\n");
// The transform replaces all `from "react"` in the file
const result = code.replace(/from\s*['"]react['"]/g, 'from "virtual:vinext-react-canary"');
expect(result).toContain('from "virtual:vinext-react-canary"');
expect(result).not.toContain('from "react"');
// Both import lines should be rewritten
expect(result.match(/virtual:vinext-react-canary/g)?.length).toBe(2);
});
it("handles single-quoted imports", () => {
const code = `import { ViewTransition } from 'react';`;
expect(canaryImportRegex.test(code)).toBe(true);
const result = code.replace(/from\s*['"]react['"]/g, 'from "virtual:vinext-react-canary"');
expect(result).toBe(`import { ViewTransition } from "virtual:vinext-react-canary";`);
});
it("handles multiline imports", () => {
const code = `import {\n ViewTransition,\n useState,\n} from "react";`;
expect(canaryImportRegex.test(code)).toBe(true);
});
});
describe("ViewTransition polyfill behavior", () => {
it("provides a passthrough component when React lacks ViewTransition", () => {
// Simulate the polyfill logic from the virtual module
const React = { ViewTransition: undefined };
const ViewTransition =
React.ViewTransition ||
function ViewTransition({ children }: { children: any }) {
return children;
};
// ViewTransition should be a function
expect(typeof ViewTransition).toBe("function");
// It should pass through children unchanged
const children = { type: "div", props: {} };
const result = ViewTransition({ children });
expect(result).toBe(children);
});
it("uses native ViewTransition when React exports it", () => {
// Simulate React canary that HAS ViewTransition
const nativeViewTransition = function NativeViewTransition({ children }: { children: any }) {
return { wrapped: children };
};
const React = { ViewTransition: nativeViewTransition };
const ViewTransition =
React.ViewTransition ||
function ViewTransition({ children }: { children: any }) {
return children;
};
expect(ViewTransition).toBe(nativeViewTransition);
});
it("provides a no-op addTransitionType when React lacks it", () => {
const React = { addTransitionType: undefined };
const addTransitionType = React.addTransitionType || function addTransitionType() {};
expect(typeof addTransitionType).toBe("function");
// Should not throw when called
expect(() => addTransitionType()).not.toThrow();
});
it("uses native addTransitionType when React exports it", () => {
const nativeAddTransitionType = function nativeAddTransitionType(type: string) {
return type;
};
const React = { addTransitionType: nativeAddTransitionType };
const addTransitionType = React.addTransitionType || function addTransitionType() {};
expect(addTransitionType).toBe(nativeAddTransitionType);
});
});
// ---------------------------------------------------------------------------
// next/head SSR security tests
// ---------------------------------------------------------------------------
describe("next/head SSR security", () => {
async function collectHeadHTML(children: React.ReactElement[]) {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const {
default: Head,
resetSSRHead,
getSSRHeadHTML,
} = await import("../packages/vinext/src/shims/head.js");
resetSSRHead();
// Render Head with children — SSR path collects elements
renderToStaticMarkup(React.createElement(Head, null, ...children));
return getSSRHeadHTML();
}
it("escapes HTML special characters in title children", async () => {
const React = await import("react");
const html = await collectHeadHTML([
React.createElement("title", null, '</title><script>alert("xss")</script>'),
]);
// The injected script tag must be escaped, not raw
expect(html).not.toContain("<script>");
expect(html).not.toContain("</title><script>");
expect(html).toContain("&lt;/title&gt;&lt;script&gt;");
});
it("escapes ampersands and angle brackets in children", async () => {
const React = await import("react");
const html = await collectHeadHTML([
React.createElement("title", null, "Tom & Jerry < Friends > Foes"),
]);
expect(html).toContain("Tom &amp; Jerry &lt; Friends &gt; Foes");
expect(html).not.toContain("Tom & Jerry < Friends > Foes");
});
it("still allows dangerouslySetInnerHTML (intentionally raw)", async () => {
const React = await import("react");
const html = await collectHeadHTML([
React.createElement("style", {
dangerouslySetInnerHTML: { __html: "body { color: red; }" },
}),
]);
expect(html).toContain("body { color: red; }");
});
it("escapes </script> in dangerouslySetInnerHTML for script tags", async () => {
const React = await import("react");
const html = await collectHeadHTML([
React.createElement("script", {
dangerouslySetInnerHTML: {
__html: 'var x = "</script><img src=x onerror=alert(1)>";',
},
}),
]);
// The raw </script> must NOT appear in the output
expect(html).not.toContain("</script><img");
// The escaped form preserves the JS string content
expect(html).toContain("<\\/script>");
});
it("escapes </style> in dangerouslySetInnerHTML for style tags", async () => {
const React = await import("react");
const html = await collectHeadHTML([
React.createElement("style", {
dangerouslySetInnerHTML: {
__html: "body::after { content: '</style><img src=x onerror=alert(1)>'; }",
},
}),
]);
// The raw </style> must NOT appear
expect(html).not.toContain("</style><img");
expect(html).toContain("<\\/style>");
});
it("escapes case-insensitive closing tags in dangerouslySetInnerHTML", async () => {
const React = await import("react");
const html = await collectHeadHTML([
React.createElement("script", {
dangerouslySetInnerHTML: {
__html: 'var x = "</SCRIPT><img src=x onerror=alert(1)>";',
},
}),
]);
expect(html).not.toContain("</SCRIPT>");
expect(html).toContain("<\\/SCRIPT>");
});
it("attributes are still properly escaped", async () => {
const React = await import("react");
const html = await collectHeadHTML([
React.createElement("meta", {
name: "description",
content: 'He said "hello" & <goodbye>',
}),
]);
expect(html).toContain("&quot;hello&quot;");
expect(html).toContain("&amp;");
expect(html).toContain("&lt;goodbye&gt;");
});
it("rejects disallowed tag types (iframe)", async () => {
const React = await import("react");
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
const html = await collectHeadHTML([
React.createElement("iframe" as any, { src: "https://evil.com" }),
]);
// After issue #1569, the default head (charset + viewport) is always
// emitted, so the output is never an empty string. The disallowed tag
// must still not appear.
expect(html).not.toContain("<iframe");
expect(html).not.toContain("evil.com");
expect(consoleWarn).toHaveBeenCalledWith(
expect.stringContaining("ignoring disallowed tag <iframe>"),
);
consoleWarn.mockRestore();
});
it("rejects disallowed tag types (object, embed, form)", async () => {
const React = await import("react");
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
const html = await collectHeadHTML([
React.createElement("object" as any, { data: "https://evil.com" }),
React.createElement("embed" as any, { src: "https://evil.com" }),
React.createElement("form" as any, { action: "https://evil.com" }),
]);
expect(html).not.toContain("<object");
expect(html).not.toContain("<embed");
expect(html).not.toContain("<form");
expect(html).not.toContain("evil.com");
consoleWarn.mockRestore();
});
it("allows all valid head tags", async () => {
const React = await import("react");
const { resetSSRHead } = await import("../packages/vinext/src/shims/head.js");
const allowedTags = ["title", "meta", "link", "style", "script", "base", "noscript"];
for (const tag of allowedTags) {
resetSSRHead();
const selfClosing = ["meta", "link", "base"].includes(tag);
const el = selfClosing
? React.createElement(tag, { name: "test", content: "test" })
: React.createElement(tag, null, "test content");
const html = await collectHeadHTML([el]);
expect(html).toContain(`<${tag}`);
expect(html).toContain('data-next-head=""');
}
});
});
describe("escapeInlineContent", () => {
it("escapes </script> within script content", async () => {
const { escapeInlineContent } = await import("../packages/vinext/src/shims/head.js");
const input = 'var x = "</script><img src=x onerror=alert(1)>";';
const result = escapeInlineContent(input, "script");
expect(result).toBe('var x = "<\\/script><img src=x onerror=alert(1)>";');
expect(result).not.toContain("</script>");
});
it("escapes </style> within style content", async () => {
const { escapeInlineContent } = await import("../packages/vinext/src/shims/head.js");
const input = "body::after { content: '</style><div>'; }";
const result = escapeInlineContent(input, "style");
expect(result).toBe("body::after { content: '<\\/style><div>'; }");
expect(result).not.toContain("</style>");
});
it("handles case-insensitive closing tags", async () => {
const { escapeInlineContent } = await import("../packages/vinext/src/shims/head.js");
expect(escapeInlineContent("</Script>", "script")).toBe("<\\/Script>");
expect(escapeInlineContent("</SCRIPT>", "script")).toBe("<\\/SCRIPT>");
expect(escapeInlineContent("</sCrIpT>", "script")).toBe("<\\/sCrIpT>");
});
it("handles multiple occurrences", async () => {
const { escapeInlineContent } = await import("../packages/vinext/src/shims/head.js");
const input = "</script></script></SCRIPT>";
const result = escapeInlineContent(input, "script");
expect(result).toBe("<\\/script><\\/script><\\/SCRIPT>");
expect(result).not.toContain("</script>");
expect(result).not.toContain("</SCRIPT>");
});
it("does not escape unrelated closing tags", async () => {
const { escapeInlineContent } = await import("../packages/vinext/src/shims/head.js");
// Escaping for "script" should not touch </style>
const input = "</style></div>";
const result = escapeInlineContent(input, "script");
expect(result).toBe("</style></div>");
});
it("passes through content with no closing tags", async () => {
const { escapeInlineContent } = await import("../packages/vinext/src/shims/head.js");
const input = "console.log('hello world');";
expect(escapeInlineContent(input, "script")).toBe(input);
});
});
describe("isValidModulePath", () => {
it("accepts valid absolute paths", () => {
expect(isValidModulePath("/src/pages/index.tsx")).toBe(true);
expect(isValidModulePath("/pages/about.js")).toBe(true);
expect(isValidModulePath("/src/pages/posts/[id].tsx")).toBe(true);
});
it("accepts valid relative paths starting with ./", () => {
expect(isValidModulePath("./src/pages/index.tsx")).toBe(true);
expect(isValidModulePath("./pages/about.js")).toBe(true);
});
it("rejects external https:// URLs", () => {
expect(isValidModulePath("https://evil.com/steal-cookies.js")).toBe(false);
});
it("rejects external http:// URLs", () => {
expect(isValidModulePath("http://evil.com/steal-cookies.js")).toBe(false);
});
it("rejects protocol-relative URLs (//)", () => {
expect(isValidModulePath("//evil.com/steal-cookies.js")).toBe(false);
expect(isValidModulePath("//cdn.example.com/script.js")).toBe(false);
});
it("rejects directory traversal", () => {
expect(isValidModulePath("/src/../../../etc/passwd")).toBe(false);
expect(isValidModulePath("./../../secret.js")).toBe(false);
expect(isValidModulePath("/pages/..%2F..%2Fsecret.js")).toBe(false);
});
it("rejects data: URLs", () => {
expect(isValidModulePath("data:text/javascript,alert(1)")).toBe(false);
});
it("rejects blob: URLs", () => {
expect(isValidModulePath("blob:http://localhost/abc")).toBe(false);
});
it("rejects bare specifiers", () => {
expect(isValidModulePath("evil-package")).toBe(false);
expect(isValidModulePath("@evil/package")).toBe(false);
});
it("rejects non-string values", () => {
expect(isValidModulePath(null)).toBe(false);
expect(isValidModulePath(undefined)).toBe(false);
expect(isValidModulePath(42)).toBe(false);
expect(isValidModulePath({})).toBe(false);
expect(isValidModulePath("")).toBe(false);
});
it("rejects javascript: protocol", () => {
expect(isValidModulePath("javascript:alert(1)")).toBe(false);
});
it("rejects ftp:// protocol", () => {
expect(isValidModulePath("ftp://evil.com/script.js")).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Cache scope guards — headers()/cookies()/connection() must throw inside
// "use cache" and unstable_cache() scopes (matches Next.js behavior).
// Ported from Next.js: test/e2e/app-dir/use-cache/use-cache.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/use-cache/use-cache.test.ts
describe("cache scope guards for dynamic APIs", () => {
it('headers() throws inside "use cache" scope', async () => {
const { cacheContextStorage } = await import("../packages/vinext/src/shims/cache-runtime.js");
const { headers, setHeadersContext } = await import("../packages/vinext/src/shims/headers.js");
// Set up a valid headers context so the "no context" error doesn't fire
setHeadersContext({ headers: new Headers(), cookies: new Map() });
// Run inside a "use cache" ALS scope
await cacheContextStorage.run(
{
tags: [],
lifeConfigs: [],
variant: "default",
hasExplicitRevalidate: false,
hasExplicitExpire: false,
dynamicNestedCacheError: undefined,
},
async () => {
await expect(headers()).rejects.toThrow(/cannot be called inside "use cache"/);
},
);
setHeadersContext(null);
});
it('headers() sync access throws the "use cache" error instead of a TypeError', async () => {
const { cacheContextStorage } = await import("../packages/vinext/src/shims/cache-runtime.js");
const { headers, setHeadersContext } = await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers({ "x-test": "blocked" }),
cookies: new Map(),
});
await cacheContextStorage.run(
{
tags: [],
lifeConfigs: [],
variant: "default",
hasExplicitRevalidate: false,
hasExplicitExpire: false,
dynamicNestedCacheError: undefined,
},
async () => {
expect(() => headers().get("x-test")).toThrow(/cannot be called inside "use cache"/);
},
);
setHeadersContext(null);
});
it('cookies() throws inside "use cache" scope', async () => {
const { cacheContextStorage } = await import("../packages/vinext/src/shims/cache-runtime.js");
const { cookies, setHeadersContext } = await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({ headers: new Headers(), cookies: new Map() });
await cacheContextStorage.run(
{
tags: [],
lifeConfigs: [],
variant: "default",
hasExplicitRevalidate: false,
hasExplicitExpire: false,
dynamicNestedCacheError: undefined,
},
async () => {
await expect(cookies()).rejects.toThrow(/cannot be called inside "use cache"/);
},
);
setHeadersContext(null);
});
it('headers() is allowed inside "use cache: private" scope', async () => {
const { cacheContextStorage } = await import("../packages/vinext/src/shims/cache-runtime.js");
const { headers, setHeadersContext } = await import("../packages/vinext/src/shims/headers.js");
// Next.js private-cache stores carry request headers.
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/app-render/work-unit-async-storage.external.ts
try {
setHeadersContext({
headers: new Headers({ "x-private": "allowed" }),
cookies: new Map(),
});
await cacheContextStorage.run(
{
tags: [],
lifeConfigs: [],
variant: "private",
hasExplicitRevalidate: false,
hasExplicitExpire: false,
dynamicNestedCacheError: undefined,
},
async () => {
expect((await headers()).get("x-private")).toBe("allowed");
},
);
} finally {
setHeadersContext(null);
}
});
it('cookies() is allowed inside "use cache: private" scope', async () => {
const { cacheContextStorage } = await import("../packages/vinext/src/shims/cache-runtime.js");
const { cookies, setHeadersContext } = await import("../packages/vinext/src/shims/headers.js");
// Ported from Next.js: test/e2e/app-dir/use-cache-private/use-cache-private.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/use-cache-private/use-cache-private.test.ts
try {
setHeadersContext({
headers: new Headers({ cookie: "test-cookie=allowed" }),
cookies: new Map([["test-cookie", "allowed"]]),
});
await cacheContextStorage.run(
{
tags: [],
lifeConfigs: [],
variant: "private",
hasExplicitRevalidate: false,
hasExplicitExpire: false,
dynamicNestedCacheError: undefined,
},
async () => {
await expect(cookies()).resolves.toMatchObject({
get: expect.any(Function),
});
expect((await cookies()).get("test-cookie")?.value).toBe("allowed");
},
);
} finally {
setHeadersContext(null);
}
});
it('cookies() sync access throws the "use cache" error instead of a TypeError', async () => {
const { cacheContextStorage } = await import("../packages/vinext/src/shims/cache-runtime.js");
const { cookies, setHeadersContext } = await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers(),
cookies: new Map([["session", "blocked"]]),
});
await cacheContextStorage.run(
{
tags: [],
lifeConfigs: [],
variant: "default",
hasExplicitRevalidate: false,
hasExplicitExpire: false,
dynamicNestedCacheError: undefined,
},
async () => {
expect(() => cookies().get("session")).toThrow(/cannot be called inside "use cache"/);
},
);
setHeadersContext(null);
});
it('connection() throws inside "use cache" scope', async () => {
const { cacheContextStorage } = await import("../packages/vinext/src/shims/cache-runtime.js");
const { connection } = await import("../packages/vinext/src/shims/server.js");
await cacheContextStorage.run(
{
tags: [],
lifeConfigs: [],
variant: "default",
hasExplicitRevalidate: false,
hasExplicitExpire: false,
dynamicNestedCacheError: undefined,
},
async () => {
await expect(connection()).rejects.toThrow(/cannot be called inside "use cache"/);
},
);
});
it("headers() throws inside unstable_cache() scope", async () => {
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { headers, setHeadersContext } = await import("../packages/vinext/src/shims/headers.js");
setCacheHandler(new MemoryCacheHandler());
setHeadersContext({ headers: new Headers(), cookies: new Map() });
const cached = unstable_cache(async () => {
// This should throw because we're inside an unstable_cache scope
const h = await headers();
return h.get("x-test");
}, ["test-headers-in-cache"]);
await expect(cached()).rejects.toThrow(/unstable_cache/);
setHeadersContext(null);
setCacheHandler(new MemoryCacheHandler());
});
it("cookies() throws inside unstable_cache() scope", async () => {
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { cookies, setHeadersContext } = await import("../packages/vinext/src/shims/headers.js");
setCacheHandler(new MemoryCacheHandler());
setHeadersContext({
headers: new Headers(),
cookies: new Map([["session", "abc"]]),
});
const cached = unstable_cache(async () => {
const c = await cookies();
return c.get("session");
}, ["test-cookies-in-cache"]);
await expect(cached()).rejects.toThrow(/unstable_cache/);
setHeadersContext(null);
setCacheHandler(new MemoryCacheHandler());
});
it("connection() throws inside unstable_cache() scope", async () => {
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { connection } = await import("../packages/vinext/src/shims/server.js");
setCacheHandler(new MemoryCacheHandler());
const cached = unstable_cache(async () => {
await connection();
}, ["test-connection-in-cache"]);
await expect(cached()).rejects.toThrow(/unstable_cache/);
setCacheHandler(new MemoryCacheHandler());
});
it("headers() works normally outside cache scopes", async () => {
const { headers, setHeadersContext } = await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers({ "x-test": "works" }),
cookies: new Map(),
});
// Should not throw outside any cache scope
const h = await headers();
expect(h.get("x-test")).toBe("works");
setHeadersContext(null);
});
it("cookies() works normally outside cache scopes", async () => {
const { cookies, setHeadersContext } = await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers(),
cookies: new Map([["token", "abc"]]),
});
// Should not throw outside any cache scope
const c = await cookies();
expect(c.get("token")).toEqual({ name: "token", value: "abc" });
setHeadersContext(null);
});
// Ported from Next.js: packages/next/src/server/request/draft-mode.ts
// (`getDraftModeProviderForCacheScope` + `trackDynamicDraftMode`).
// Reading `draftMode()` / `.isEnabled` is allowed inside cache scopes;
// only the mutating `enable()` / `disable()` methods throw. Issue #1489.
it('draftMode().isEnabled reads inside "use cache" scope', async () => {
const { cacheContextStorage } = await import("../packages/vinext/src/shims/cache-runtime.js");
const { draftMode, setHeadersContext } =
await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({
headers: new Headers({ cookie: "__prerender_bypass=test-secret" }),
cookies: new Map([["__prerender_bypass", "test-secret"]]),
draftModeSecret: "test-secret",
});
await cacheContextStorage.run(
{
tags: [],
lifeConfigs: [],
variant: "default",
hasExplicitRevalidate: false,
hasExplicitExpire: false,
dynamicNestedCacheError: undefined,
},
async () => {
const dm = await draftMode();
expect(dm.isEnabled).toBe(true);
},
);
setHeadersContext(null);
});
it("draftMode().isEnabled reads inside unstable_cache() scope", async () => {
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { draftMode, setHeadersContext } =
await import("../packages/vinext/src/shims/headers.js");
setCacheHandler(new MemoryCacheHandler());
setHeadersContext({
headers: new Headers({ cookie: "__prerender_bypass=test-secret" }),
cookies: new Map([["__prerender_bypass", "test-secret"]]),
draftModeSecret: "test-secret",
});
let observed: boolean | undefined;
const cached = unstable_cache(async () => {
const dm = await draftMode();
observed = dm.isEnabled;
return observed;
}, ["test-draftmode-in-cache"]);
await expect(cached()).resolves.toBe(true);
expect(observed).toBe(true);
setHeadersContext(null);
setCacheHandler(new MemoryCacheHandler());
});
it('draftMode().enable() throws inside "use cache" scope', async () => {
const { cacheContextStorage } = await import("../packages/vinext/src/shims/cache-runtime.js");
const { draftMode, setHeadersContext } =
await import("../packages/vinext/src/shims/headers.js");
setHeadersContext({ headers: new Headers(), cookies: new Map() });
await cacheContextStorage.run(
{
tags: [],
lifeConfigs: [],
variant: "default",
hasExplicitRevalidate: false,
hasExplicitExpire: false,
dynamicNestedCacheError: undefined,
},
async () => {
const dm = await draftMode();
expect(() => dm.enable()).toThrow(/cannot be called inside "use cache"/);
expect(() => dm.disable()).toThrow(/cannot be called inside "use cache"/);
},
);
setHeadersContext(null);
});
it("draftMode().enable() throws inside unstable_cache() scope", async () => {
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { draftMode, setHeadersContext } =
await import("../packages/vinext/src/shims/headers.js");
setCacheHandler(new MemoryCacheHandler());
setHeadersContext({ headers: new Headers(), cookies: new Map() });
const cached = unstable_cache(async () => {
const dm = await draftMode();
dm.enable();
}, ["test-draftmode-enable-in-cache"]);
await expect(cached()).rejects.toThrow(/unstable_cache/);
setHeadersContext(null);
setCacheHandler(new MemoryCacheHandler());
});
it('headers() throws inside nested scopes (unstable_cache inside "use cache")', async () => {
const { cacheContextStorage } = await import("../packages/vinext/src/shims/cache-runtime.js");
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { headers, setHeadersContext } = await import("../packages/vinext/src/shims/headers.js");
setCacheHandler(new MemoryCacheHandler());
setHeadersContext({ headers: new Headers(), cookies: new Map() });
// Nest unstable_cache inside a "use cache" scope: the outermost
// scope ("use cache") should be detected first.
await cacheContextStorage.run(
{
tags: [],
lifeConfigs: [],
variant: "default",
hasExplicitRevalidate: false,
hasExplicitExpire: false,
dynamicNestedCacheError: undefined,
},
async () => {
const cached = unstable_cache(async () => {
const h = await headers();
return h.get("x-test");
}, ["test-nested-scopes"]);
// Either scope's guard triggers (the "use cache" check runs first)
await expect(cached()).rejects.toThrow(/cannot be called inside/);
},
);
setHeadersContext(null);
setCacheHandler(new MemoryCacheHandler());
});
it("existing unstable_cache tests still pass (cache miss executes callback)", async () => {
// Verify that the ALS wrapping in unstable_cache doesn't break normal caching
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const cached = unstable_cache(
async (x: number) => {
callCount++;
return x * 2;
},
["regression-test"],
);
const r1 = await cached(5);
expect(r1).toBe(10);
expect(callCount).toBe(1);
const r2 = await cached(5);
expect(r2).toBe(10);
expect(callCount).toBe(1); // Cached, not called again
setCacheHandler(new MemoryCacheHandler());
});
// Ported from Next.js: workStore.invalidDynamicUsageError in
// packages/next/src/server/app-render/app-render.tsx
// https://github.com/vercel/next.js/commit/f5e54c06726b571a042fce67417e40a29f6b8689
it("records invalid dynamic usage error on request context (survives try/catch)", async () => {
const { cacheContextStorage } = await import("../packages/vinext/src/shims/cache-runtime.js");
const { setHeadersContext, throwIfInsideCacheScope, consumeInvalidDynamicUsageError } =
await import("../packages/vinext/src/shims/headers.js");
const { createRequestContext, runWithRequestContext } =
await import("../packages/vinext/src/shims/unified-request-context.js");
setHeadersContext({ headers: new Headers(), cookies: new Map() });
const ctx = createRequestContext({
headersContext: { headers: new Headers(), cookies: new Map() },
});
let recordedError: unknown = null;
await runWithRequestContext(ctx, async () => {
try {
await cacheContextStorage.run(
{
tags: [],
lifeConfigs: [],
variant: "default",
hasExplicitRevalidate: false,
hasExplicitExpire: false,
dynamicNestedCacheError: undefined,
},
async () => {
throwIfInsideCacheScope("cookies()");
},
);
} catch {
// User try/catch — the error should still be recorded on the context
}
recordedError = consumeInvalidDynamicUsageError();
});
expect(recordedError).toBeInstanceOf(Error);
expect((recordedError as Error).message).toContain('cannot be called inside "use cache"');
// After consumption, the error is cleared
expect(consumeInvalidDynamicUsageError()).toBeNull();
setHeadersContext(null);
});
it("consumeInvalidDynamicUsageError returns null when no error was recorded", async () => {
const { consumeInvalidDynamicUsageError } =
await import("../packages/vinext/src/shims/headers.js");
const { createRequestContext, runWithRequestContext } =
await import("../packages/vinext/src/shims/unified-request-context.js");
const ctx = createRequestContext();
let result: unknown;
await runWithRequestContext(ctx, async () => {
result = consumeInvalidDynamicUsageError();
});
expect(result!).toBeNull();
});
it("consumeInvalidDynamicUsageError works outside unified request scope", async () => {
const { consumeInvalidDynamicUsageError } =
await import("../packages/vinext/src/shims/headers.js");
// Should return null without throwing when no unified scope is active
expect(consumeInvalidDynamicUsageError()).toBeNull();
});
});
describe("shim alias map .js variants", () => {
it("every top-level next/* alias has a corresponding .js variant", async () => {
const plugins = vinext() as Plugin[];
const configPlugin = plugins.find((p) => p.name === "vinext:config");
if (!configPlugin?.config) throw new Error("vinext:config plugin not found");
const hookFn = (
typeof configPlugin.config === "function" ? configPlugin.config : configPlugin.config.handler
) as (config: { root: string }, env: { mode: string; command: string }) => Promise<any>;
const result = await hookFn(
{ root: PAGES_FIXTURE_DIR },
{ mode: "development", command: "serve" },
);
const aliases = result?.resolve?.alias as Record<string, string> | undefined;
expect(aliases).toBeDefined();
// Collect top-level next/<name> keys (exclude next/dist/*, next/font/*, next/compat/*, next/legacy/*)
const topLevel = Object.keys(aliases!).filter((key) => {
if (!key.startsWith("next/")) return false;
if (key.endsWith(".js")) return false;
const segment = key.slice("next/".length);
if (segment.startsWith("dist/")) return false;
if (segment.startsWith("font/")) return false;
if (segment.startsWith("compat/")) return false;
if (segment.startsWith("legacy/")) return false;
return true;
});
expect(topLevel.length).toBeGreaterThan(0);
const missing = topLevel.filter((key) => !(key + ".js" in aliases!));
expect(missing, `Missing .js aliases for: ${missing.join(", ")}`).toEqual([]);
});
});
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();
const expectedShim = 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();
});
});
// ── 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");
});
});