Files
cloudflare__vinext/tests/pages-request-pipeline.test.ts
T
Nathan Nguyen a3d2f92152 fix(router): honor hybrid pages route priority (#1997)
* fix(router): honor hybrid pages route priority

* fix(router): share hybrid owner decision with client navigation

PR #1997 fixed the server-side route ownership for direct document loads,
but the same invariant broke for client-side soft navigations and the
matching prefetch path:

- navigateClientSide() always delegated to the App runtime's RSC fetch,
  even when the Pages route had higher priority. renderPagesFallback()
  short-circuits RSC requests with null, so the App catch-all won.
- prefetchUrl() prefetched an RSC stream for any URL that matched an App
  route, again ignoring Pages ownership.
- The Pages entry was loaded on every hybrid request, even when a static
  App route had already matched (Pages can never win in that case).

Expose the Pages route manifest on the client via a new
__VINEXT_PAGES_LINK_PREFETCH_ROUTES__ window global emitted by both the
App and Pages browser entries. The link shim consults a shared
resolveHybridClientRouteOwner helper that mirrors the server-side
pagesRouteHasPriorityOverAppRoute comparison. When Pages owns the URL,
the click handler issues a window.location navigation and the prefetch
path returns early.

Gate the renderPagesFallback call behind a static-App-route check in
handleAppRscRequest: when a static App route matches, the bridge
cannot win, so skip the eager Pages entry load. Centralise the
comparison in a new resolveHybridRouteOwner helper so server and
client reach the same answer for the same (URL, route pair).

Adds the missing client-navigation coverage to the use-params e2e
fixture (Link from /app/ to /pages-dir/foobar) and unit tests for
the shared owner decision.

* fix(router): mirror server hybrid owner decision on the client

PR review flagged two split-brain bugs in the previous hybrid
ownership fix: a hand-copied client comparator and a Link-only
ownership gate that left programmatic App Router navigations on
the wrong path.

The hand-copied routePrecedence in hybrid-client-route-owner.ts
omitted the static-prefix reduction that lives in
routing/utils.ts#routePrecedence, and used a strict-less-than
comparison that returned App for identical dynamic patterns. The
server returns Pages for both cases (Pages providers sort ahead
of App providers, and routePrecedence subtracts 50 per static
prefix segment). The split produced a real ownership disagreement
on overlapping patterns like /_sites/:slug* (Pages) vs /:slug*
(App).

Add a shared compareHybridRoutePatterns to routing/utils.ts as
the single source of truth: the static/dynamic short-circuits plus
a sortRoutes call (which carries the static-prefix reduction and
the Pages-first equal-pattern tiebreak). The server
pagesRouteHasPriorityOverAppRoute and the client
resolveHybridClientRouteOwner both delegate to it, so they
cannot diverge. Drop the hand-copied routePrecedence entirely.

Wire the ownership check at the App navigation runtime boundary
so useRouter().push, useRouter().replace, gesturePush, and form
submits all get the same hard-nav contract as the Link click
handler. Specifically:

- navigateClientSide: after same-origin normalization, if Pages
  owns the URL, hard-navigate via window.location and return
  (matching the existing external-URL branch).
- _appRouter.prefetch: short-circuit RSC URL construction for
  Pages-owned targets so we do not warm an unusable cache entry.

Centralise the existing two inline hard-nav branches in
navigateClientSide into a hardNavigateTo helper for clarity.

Tests:

- Direct unit tests for compareHybridRoutePatterns covering
  identical-dynamic tiebreak, static-prefix dynamic overlap,
  static-prefix catch-all overlap, and infix-static bonus.
- Direct unit tests for resolveHybridClientRouteOwner mirroring
  the server assertions plus a basePath-stripping test.
- e2e: useRouter().push('/pages-dir/foobar') from an App page
  resolves to the Pages document.
- e2e: useRouter().prefetch('/pages-dir/foobar') issues zero
  RSC requests for the target URL.

* docs(router): fix stale score in static-prefix catch-all test comment

The hand-copied comparator note quoted the dynamic-segment scores
(51 / 1000) for the optional-catch-all example (/_sites/:slug*
vs /:slug*). The current optional-catch-all scoring actually
produces 1951 vs 2000. The test assertion was correct; only the
comment was stale.

* test(use-params): fix direct-load single dynamic param to use /a instead of /a/b

* fix(router): compare hybrid routes structurally

* fix(router): reject hybrid route conflicts

* fix(router): refresh hybrid route ownership

* fix(router): preserve hybrid routing lifecycle

* fix(router): recheck pages routes after rewrites

* fix(router): preserve rewritten pages queries

* fix(router): preserve rewritten route ownership

* fix(router): resolve client rewrites sequentially

* fix(router): apply rewrite phases sequentially

* fix(router): preserve rewrite params and endpoints

* fix(router): hand off endpoint navigations

* fix(router): preserve rewrite fragment params

* test(e2e): avoid hybrid fixture route conflicts

* test(pages): await async config validation

* test(hybrid): avoid duplicate page fixtures

* chore(router): clarify hybrid priority semantics

* refactor(router): remove duplicate app route matcher

* docs(router): clarify client hybrid comparator

---------

Co-authored-by: James <james@eli.cx>
2026-06-15 00:22:00 +01:00

951 lines
33 KiB
TypeScript

import { describe, it, expect, vi } from "vite-plus/test";
import {
runPagesRequest,
wrapMiddlewareWithBasePath,
type PagesPipelineDeps,
type MiddlewareResult,
type PagesRenderOptions,
} from "../packages/vinext/src/server/pages-request-pipeline.js";
// Helpers
function makeRequest(pathname: string, headers?: Record<string, string>): Request {
return new Request(`http://localhost${pathname}`, { headers });
}
function baseDeps(overrides?: Partial<PagesPipelineDeps>): PagesPipelineDeps {
return {
basePath: "",
trailingSlash: false,
i18nConfig: null,
configRedirects: [],
configRewrites: { beforeFiles: [], afterFiles: [], fallback: [] },
configHeaders: [],
hadBasePath: true,
isDataReq: false,
isDataRequest: false,
...overrides,
};
}
function makeMiddleware(result: Partial<MiddlewareResult>) {
return vi.fn(async (_req: Request, _ctx: unknown, _opts: { isDataRequest: boolean }) => ({
continue: true,
...result,
}));
}
function makeRenderPage(status = 200, body = "ok") {
return vi.fn(
async (_req: Request, _url: string, _opts?: PagesRenderOptions) =>
new Response(body, { status }),
);
}
// 1. Trailing-slash: /foo/ with trailingSlash: false → {type:"response"} with status 308
describe("trailing slash normalization", () => {
it("redirects /foo/ to /foo when trailingSlash is false", async () => {
const req = makeRequest("/foo/");
const result = await runPagesRequest(req, baseDeps({ trailingSlash: false }));
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(308);
expect(result.response.headers.get("Location")).toBe("/foo");
});
it("does not redirect /foo when trailingSlash is false", async () => {
const req = makeRequest("/foo");
const result = await runPagesRequest(req, baseDeps({ renderPage: makeRenderPage() }));
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(200);
});
});
// 2. Config redirect: permanent redirect → status 308 with Location
describe("config redirects", () => {
it("permanent redirect returns 308", async () => {
const req = makeRequest("/old");
const result = await runPagesRequest(
req,
baseDeps({
configRedirects: [{ source: "/old", destination: "/new", permanent: true }],
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(308);
expect(result.response.headers.get("Location")).toBe("/new");
});
// 3. Config redirect: non-permanent → status 307
it("non-permanent redirect returns 307", async () => {
const req = makeRequest("/old");
const result = await runPagesRequest(
req,
baseDeps({
configRedirects: [{ source: "/old", destination: "/new", permanent: false }],
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(307);
expect(result.response.headers.get("Location")).toBe("/new");
});
it("does not match when source does not match", async () => {
const req = makeRequest("/other");
const result = await runPagesRequest(
req,
baseDeps({
configRedirects: [{ source: "/old", destination: "/new", permanent: false }],
renderPage: makeRenderPage(),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(200);
});
});
// 4. Middleware redirect short-circuit → {type:"response"} status 307
describe("middleware", () => {
it("middleware redirect short-circuits with 307", async () => {
const req = makeRequest("/foo");
const result = await runPagesRequest(
req,
baseDeps({
runMiddleware: makeMiddleware({
continue: false,
redirectUrl: "http://localhost/bar",
redirectStatus: 307,
}),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(307);
expect(result.response.headers.get("Location")).toBe("http://localhost/bar");
});
it("middleware redirect uses default 307 when no redirectStatus given", async () => {
const req = makeRequest("/foo");
const result = await runPagesRequest(
req,
baseDeps({
runMiddleware: makeMiddleware({ continue: false, redirectUrl: "http://localhost/bar" }),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(307);
});
// 5. Middleware rewrite: resolvedUrl changes, pipeline continues
it("middleware rewrite changes resolved URL, pipeline continues to render", async () => {
const renderPage = makeRenderPage(200, "rewrite target");
const req = makeRequest("/foo");
const result = await runPagesRequest(
req,
baseDeps({
runMiddleware: makeMiddleware({ continue: true, rewriteUrl: "/bar" }),
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(200);
// renderPage should have been called with the rewritten URL
expect(renderPage).toHaveBeenCalledWith(
expect.any(Request),
"/bar",
undefined,
expect.any(Headers),
);
});
it("exposes the middleware rewrite target on Pages data responses", async () => {
const result = await runPagesRequest(
makeRequest("/to-blog/post"),
baseDeps({
isDataRequest: true,
runMiddleware: makeMiddleware({
continue: true,
rewriteUrl: "/fallback-true-blog/post",
}),
renderPage: makeRenderPage(200, '{"pageProps":{"slug":"post"}}'),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.headers.get("x-nextjs-rewrite")).toBe("/fallback-true-blog/post");
});
it("does not expose the middleware rewrite target on HTML responses", async () => {
const result = await runPagesRequest(
makeRequest("/to-blog/post"),
baseDeps({
runMiddleware: makeMiddleware({
continue: true,
rewriteUrl: "/fallback-true-blog/post",
}),
renderPage: makeRenderPage(200),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.headers.get("x-nextjs-rewrite")).toBeNull();
});
it("exposes the final config rewrite URL with appended source params", async () => {
const result = await runPagesRequest(
makeRequest("/config-rewrite-to-dynamic-static/post-2"),
baseDeps({
isDataRequest: true,
configRewrites: {
beforeFiles: [
{
source: "/config-rewrite-to-dynamic-static/:rewriteSlug",
destination: "/ssg",
},
],
afterFiles: [],
fallback: [],
},
renderPage: makeRenderPage(200),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.headers.get("x-nextjs-rewrite")).toBe("/ssg?rewriteSlug=post-2");
});
// 6. Middleware response short-circuit → {type:"response"} with middleware response
it("middleware response short-circuit returns the middleware response", async () => {
const middlewareResponse = new Response("blocked", { status: 403 });
const req = makeRequest("/foo");
const result = await runPagesRequest(
req,
baseDeps({
runMiddleware: makeMiddleware({ continue: false, response: middlewareResponse }),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response).toBe(middlewareResponse);
expect(result.response.status).toBe(403);
// Passthrough response: no content-type default → Node sends it verbatim.
expect(result.defaultContentType).toBeUndefined();
});
// 18. middlewareStatus reconciliation: result.status takes priority over result.rewriteStatus
it("middlewareStatus: result.status takes priority over result.rewriteStatus", async () => {
const renderPage = makeRenderPage(200);
const req = makeRequest("/foo");
const result = await runPagesRequest(
req,
baseDeps({
runMiddleware: makeMiddleware({ continue: true, status: 404, rewriteStatus: 403 }),
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
// middlewareStatus=404 from result.status should override rewriteStatus=403
expect(result.response.status).toBe(404);
});
// 20. Multi-value Set-Cookie accumulation from middleware response headers
it("accumulates multiple Set-Cookie headers from middleware", async () => {
const renderPage = makeRenderPage(200);
const req = makeRequest("/foo");
const responseHeaders = new Headers();
responseHeaders.append("set-cookie", "a=1");
responseHeaders.append("set-cookie", "b=2");
const result = await runPagesRequest(
req,
baseDeps({
runMiddleware: vi.fn(async () => ({
continue: true,
responseHeaders: [...responseHeaders.entries()],
})),
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
const cookies = result.response.headers.getSetCookie?.() ?? [];
expect(cookies).toContain("a=1");
expect(cookies).toContain("b=2");
});
});
// 7. Config headers staged
describe("config headers", () => {
it("stages config headers into middleware headers", async () => {
const renderPage = makeRenderPage(200);
const req = makeRequest("/foo");
const result = await runPagesRequest(
req,
baseDeps({
configHeaders: [{ source: "/foo", headers: [{ key: "X-Custom", value: "test" }] }],
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.headers.get("X-Custom")).toBe("test");
});
});
// 8. External proxy after middleware rewrite
describe("external proxy", () => {
it("proxies to external URL when middleware rewrites to external", async () => {
const req = makeRequest("/proxy-me");
// Use fetchMock or just observe isExternalUrl → proxyExternalRequest
// Since proxyExternalRequest will actually fetch, we mock globalThis.fetch
const mockFetch = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(new Response("external response", { status: 200 }));
const result = await runPagesRequest(
req,
baseDeps({
runMiddleware: makeMiddleware({
continue: true,
rewriteUrl: "https://example.com/proxied",
}),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(200);
mockFetch.mockRestore();
});
});
// 9. beforeFiles rewrite with external URL → {type:"response"} from proxy
describe("beforeFiles rewrites", () => {
it("beforeFiles external rewrite proxies the request", async () => {
const req = makeRequest("/proxy-me");
const mockFetch = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(new Response("from proxy", { status: 200 }));
const result = await runPagesRequest(
req,
baseDeps({
configRewrites: {
beforeFiles: [{ source: "/proxy-me", destination: "https://example.com/proxied" }],
afterFiles: [],
fallback: [],
},
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(200);
mockFetch.mockRestore();
});
it("beforeFiles rewrite changes resolved URL", async () => {
const renderPage = makeRenderPage(200);
const req = makeRequest("/from");
const result = await runPagesRequest(
req,
baseDeps({
configRewrites: {
beforeFiles: [{ source: "/from", destination: "/to" }],
afterFiles: [],
fallback: [],
},
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(renderPage).toHaveBeenCalledWith(
expect.any(Request),
"/to",
undefined,
expect.any(Headers),
);
});
it("applies chained beforeFiles rewrites with accumulated query conditions", async () => {
const renderPage = makeRenderPage(200);
const result = await runPagesRequest(
makeRequest("/from?keep=1"),
baseDeps({
configRewrites: {
beforeFiles: [
{ source: "/from", destination: "/middle?stage=1" },
{
source: "/middle",
destination: "/to",
has: [{ type: "query", key: "stage", value: "1" }],
},
],
afterFiles: [],
fallback: [],
},
renderPage,
}),
);
expect(result.type).toBe("response");
expect(renderPage).toHaveBeenCalledWith(
expect.any(Request),
"/to?keep=1&stage=1",
undefined,
expect.any(Headers),
);
});
it("applies every matching beforeFiles rewrite in sequence", async () => {
const renderPage = makeRenderPage(200);
const result = await runPagesRequest(
makeRequest("/start"),
baseDeps({
isDataRequest: true,
configRewrites: {
beforeFiles: [
{ source: "/start", destination: "/middle?first=1" },
{ source: "/middle", destination: "/destination?second=2" },
],
afterFiles: [],
fallback: [],
},
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(renderPage).toHaveBeenCalledWith(
expect.any(Request),
"/destination?first=1&second=2",
undefined,
expect.any(Headers),
);
expect(result.response.headers.get("x-nextjs-rewrite")).toBe("/destination?first=1&second=2");
});
it("excludes beforeFiles fragments from Pages route matching", async () => {
const matchPageRoute = vi.fn((pathname: string) =>
pathname === "/to" ? { route: { isDynamic: false } } : null,
);
await runPagesRequest(
makeRequest("/from"),
baseDeps({
configRewrites: {
beforeFiles: [{ source: "/from", destination: "/to#section" }],
afterFiles: [],
fallback: [],
},
matchPageRoute,
renderPage: makeRenderPage(200),
}),
);
expect(matchPageRoute).toHaveBeenCalledWith("/to", expect.any(Request));
expect(matchPageRoute).not.toHaveBeenCalledWith("/to#section", expect.any(Request));
});
});
// 10. Out-of-basePath reject when basePath: "/base" and hadBasePath: false and no configRewrite fired
describe("out-of-basePath rejection", () => {
it("rejects requests outside basePath with 404 when no rewrite fires", async () => {
const req = makeRequest("/outside/page");
const result = await runPagesRequest(
req,
baseDeps({
basePath: "/base",
hadBasePath: false,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(404);
});
it("allows requests outside basePath when beforeFiles rewrite fires", async () => {
const renderPage = makeRenderPage(200);
const req = makeRequest("/outside");
const result = await runPagesRequest(
req,
baseDeps({
basePath: "/base",
hadBasePath: false,
configRewrites: {
beforeFiles: [{ source: "/outside", destination: "/inside", basePath: false }],
afterFiles: [],
fallback: [],
},
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(200);
});
});
// 11. API route with renderPage absent → {type:"api"} intent
describe("API routes", () => {
it("emits api intent when handleApi absent", async () => {
const req = makeRequest("/api/users");
const result = await runPagesRequest(req, baseDeps());
expect(result.type).toBe("api");
if (result.type !== "api") return;
expect(result.apiUrl).toBe("/api/users");
});
// 12. API route with handleApi present → {type:"response"}
it("returns response when handleApi present", async () => {
const req = makeRequest("/api/users");
const handleApi = vi.fn(async () => new Response('{"ok":true}', { status: 200 }));
const result = await runPagesRequest(req, baseDeps({ handleApi }));
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(200);
expect(handleApi).toHaveBeenCalledWith(expect.any(Request), "/api/users", null);
// API responses default a missing content-type to octet-stream, not text/html.
expect(result.defaultContentType).toBe("application/octet-stream");
});
it("tags page renders with a text/html content-type default", async () => {
const req = makeRequest("/page");
const result = await runPagesRequest(req, baseDeps({ renderPage: makeRenderPage(200) }));
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.defaultContentType).toBe("text/html");
});
it("matches /api exactly", async () => {
const req = makeRequest("/api");
const result = await runPagesRequest(req, baseDeps());
expect(result.type).toBe("api");
});
});
// Public-directory static file serving (Node prod only, post-middleware).
describe("serveStaticFile", () => {
it("returns {type:'handled'} when serveStaticFile serves the request", async () => {
const renderPage = makeRenderPage(200);
const serveStaticFile = vi.fn(async () => true);
const req = makeRequest("/favicon.ico");
const result = await runPagesRequest(req, baseDeps({ serveStaticFile, renderPage }));
expect(result.type).toBe("handled");
// The static file short-circuits — the renderer is never invoked.
expect(renderPage).not.toHaveBeenCalled();
});
it("falls through to render when serveStaticFile returns false", async () => {
const renderPage = makeRenderPage(200);
const serveStaticFile = vi.fn(async () => false);
const req = makeRequest("/page");
const result = await runPagesRequest(req, baseDeps({ serveStaticFile, renderPage }));
expect(serveStaticFile).toHaveBeenCalledTimes(1);
expect(result.type).toBe("response");
expect(renderPage).toHaveBeenCalled();
});
it("passes the original (pre-rewrite) pathname and staged headers", async () => {
const serveStaticFile = vi.fn(async () => true);
const middleware = makeMiddleware({ responseHeaders: [["set-cookie", "a=1"]] });
const req = makeRequest("/robots.txt");
await runPagesRequest(req, baseDeps({ serveStaticFile, runMiddleware: middleware }));
expect(serveStaticFile).toHaveBeenCalledWith("/robots.txt", { "set-cookie": ["a=1"] });
});
it("runs after middleware — a middleware redirect wins over a public file", async () => {
const serveStaticFile = vi.fn(async () => true);
const middleware = makeMiddleware({
continue: false,
redirectUrl: "/elsewhere",
redirectStatus: 307,
});
const req = makeRequest("/favicon.ico");
const result = await runPagesRequest(
req,
baseDeps({ serveStaticFile, runMiddleware: middleware }),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(307);
expect(result.response.headers.get("Location")).toBe("/elsewhere");
expect(serveStaticFile).not.toHaveBeenCalled();
});
});
// 13. afterFiles rewrite: dynamic page match is re-queried
describe("afterFiles rewrites", () => {
it("applies afterFiles rewrite when page match is dynamic", async () => {
const renderPage = makeRenderPage(200);
const req = makeRequest("/dynamic-route");
const result = await runPagesRequest(
req,
baseDeps({
matchPageRoute: vi.fn().mockReturnValue({ route: { isDynamic: true } }),
configRewrites: {
beforeFiles: [],
afterFiles: [{ source: "/dynamic-route", destination: "/rewritten" }],
fallback: [],
},
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(renderPage).toHaveBeenCalledWith(
expect.any(Request),
"/rewritten",
undefined,
expect.any(Headers),
);
});
it("skips afterFiles rewrite when static page match exists", async () => {
const renderPage = makeRenderPage(200);
const req = makeRequest("/static-page");
const result = await runPagesRequest(
req,
baseDeps({
matchPageRoute: vi.fn().mockReturnValue({ route: { isDynamic: false } }),
configRewrites: {
beforeFiles: [],
afterFiles: [{ source: "/static-page", destination: "/rewritten" }],
fallback: [],
},
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
// Should be called with original URL, not rewritten
expect(renderPage).toHaveBeenCalledWith(
expect.any(Request),
"/static-page",
undefined,
expect.any(Headers),
);
});
it("continues afterFiles rewrites until a Pages destination resolves", async () => {
const renderPage = makeRenderPage(200);
const matchPageRoute = vi.fn((pathname: string) =>
pathname === "/resolved" ? { route: { isDynamic: false } } : null,
);
await runPagesRequest(
makeRequest("/from"),
baseDeps({
configRewrites: {
beforeFiles: [],
afterFiles: [
{ source: "/from", destination: "/missing" },
{ source: "/missing", destination: "/resolved#section" },
],
fallback: [],
},
matchPageRoute,
renderPage,
}),
);
expect(matchPageRoute).toHaveBeenCalledWith("/resolved", expect.any(Request));
expect(renderPage).toHaveBeenCalledWith(
expect.any(Request),
"/resolved#section",
undefined,
expect.any(Headers),
);
});
});
// 14. Render intent when renderPage absent → {type:"render"}
describe("render intent", () => {
it("emits render intent when renderPage absent", async () => {
const req = makeRequest("/page");
const result = await runPagesRequest(req, baseDeps());
expect(result.type).toBe("render");
if (result.type !== "render") return;
expect(result.resolvedUrl).toBe("/page");
expect(result.isDataReq).toBe(false);
});
it("emits render intent with isDataReq when isDataReq is true", async () => {
const req = makeRequest("/page");
const result = await runPagesRequest(req, baseDeps({ isDataReq: true }));
expect(result.type).toBe("render");
if (result.type !== "render") return;
expect(result.isDataReq).toBe(true);
expect(result.renderOptions).toEqual({ isDataReq: true });
});
it("stages the final dev fallback rewrite target for data navigation", async () => {
const result = await runPagesRequest(
makeRequest("/to-blog/post"),
baseDeps({
isDataReq: true,
matchPageRoute: () => null,
configRewrites: {
beforeFiles: [],
afterFiles: [],
fallback: [
{
source: "/to-blog/:slug",
destination: "/fallback-true-blog/:slug",
},
],
},
}),
);
expect(result.type).toBe("render");
if (result.type !== "render") return;
expect(result.resolvedUrl).toBe("/fallback-true-blog/post");
expect(result.stagedHeaders["x-nextjs-rewrite"]).toBe("/fallback-true-blog/post");
});
});
// 15. {type:"response"} from renderPage (happy path)
describe("render via renderPage callback", () => {
it("returns response from renderPage", async () => {
const renderPage = makeRenderPage(200, "Hello World");
const req = makeRequest("/about");
const result = await runPagesRequest(req, baseDeps({ renderPage }));
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(200);
});
});
// 16. shouldDeferErrorPageOnMiss: 404 → fallback rewrite → re-render
describe("fallback rewrites on 404", () => {
it("uses fallback rewrite when page misses and renders 404", async () => {
let callCount = 0;
const renderPage = vi.fn(async (_req: Request, url: string) => {
callCount++;
if (callCount === 1) {
// First call: return 404
return new Response("not found", { status: 404 });
}
// After fallback rewrite: return 200
return new Response(`ok ${url}`, { status: 200 });
});
const req = makeRequest("/missing-page");
const result = await runPagesRequest(
req,
baseDeps({
matchPageRoute: vi.fn().mockReturnValue(null), // no page match
configRewrites: {
beforeFiles: [],
afterFiles: [],
fallback: [{ source: "/missing-page", destination: "/fallback" }],
},
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(200);
expect(callCount).toBe(2);
});
it("continues fallback rewrites after an unresolved destination", async () => {
const renderPage = vi.fn(
async (_req: Request, resolvedUrl: string) =>
new Response(resolvedUrl, { status: resolvedUrl.startsWith("/resolved") ? 200 : 404 }),
);
const result = await runPagesRequest(
makeRequest("/from"),
baseDeps({
matchPageRoute: vi.fn().mockReturnValue(null),
configRewrites: {
beforeFiles: [],
afterFiles: [],
fallback: [
{ source: "/from", destination: "/missing" },
{ source: "/missing", destination: "/resolved#section" },
],
},
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(200);
expect(renderPage).toHaveBeenLastCalledWith(
expect.any(Request),
"/resolved#section",
undefined,
expect.any(Headers),
);
});
});
// 17. shouldDeferErrorPageOnMiss: 404 → no fallback → deferred error page re-render
describe("deferred error page re-render on 404", () => {
it("re-renders without renderErrorPageOnMiss when no fallback matches", async () => {
let callCount = 0;
const renderPage = vi.fn(async (_req: Request, _url: string, opts?: PagesRenderOptions) => {
callCount++;
if (opts?.renderErrorPageOnMiss === false) {
// Initial deferred call: return 404
return new Response("not found", { status: 404 });
}
// Second call: render actual error page
return new Response("error page", { status: 404 });
});
const req = makeRequest("/missing-page");
const result = await runPagesRequest(
req,
baseDeps({
matchPageRoute: vi.fn().mockReturnValue(null), // no page match
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(404);
expect(callCount).toBe(2);
// Second call should NOT have renderErrorPageOnMiss: false
expect(renderPage.mock.calls[1][2]).toBeUndefined();
});
it("does not defer or run fallback rewrites for a data request (x-nextjs-data, worker path)", async () => {
// Worker scenario: it never normalizes /_next/data paths (isDataReq stays
// false) but flags the request via the x-nextjs-data header (isDataRequest).
// A data-request miss must render once directly, not defer + run fallback.
const renderPage = makeRenderPage(404, "not found");
const fallback = [{ source: "/missing-page", destination: "/fallback" }];
const req = makeRequest("/missing-page");
const result = await runPagesRequest(
req,
baseDeps({
isDataReq: false,
isDataRequest: true,
matchPageRoute: vi.fn().mockReturnValue(null), // no page match
configRewrites: { beforeFiles: [], afterFiles: [], fallback },
renderPage,
}),
);
expect(result.type).toBe("response");
// Rendered exactly once with no defer option, and the fallback never fired.
expect(renderPage).toHaveBeenCalledTimes(1);
expect(renderPage.mock.calls[0][2]).toBeUndefined();
});
it("resolves fallback rewrites before rendering a production data request", async () => {
const renderPage = vi.fn(
async (_request: Request, url: string) => new Response(`data ${url}`, { status: 200 }),
);
const matchPageRoute = vi.fn((pathname: string) =>
pathname === "/fallback-target" ? ({ route: { isDynamic: false } } as any) : null,
);
const result = await runPagesRequest(
makeRequest("/missing-page"),
baseDeps({
isDataRequest: true,
matchPageRoute,
configRewrites: {
beforeFiles: [],
afterFiles: [],
fallback: [{ source: "/missing-page", destination: "/fallback-target?from=fallback" }],
},
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(renderPage).toHaveBeenCalledOnce();
expect(renderPage).toHaveBeenCalledWith(
expect.any(Request),
"/fallback-target?from=fallback",
undefined,
expect.any(Headers),
);
expect(result.response.headers.get("x-nextjs-rewrite")).toBe("/fallback-target?from=fallback");
});
});
// 19. preserveCredentialHeaders: isExternalUrl(resolvedUrl) → passed to applyMiddlewareRequestHeaders
describe("preserveCredentialHeaders", () => {
it("preserves credential headers when resolvedUrl is external", async () => {
// When middleware rewrites to an external URL, the Authorization header
// should be forwarded. We verify by ensuring the pipeline reaches external proxy.
const mockFetch = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(new Response("proxied", { status: 200 }));
const req = makeRequest("/internal", { Authorization: "Bearer token" });
const result = await runPagesRequest(
req,
baseDeps({
runMiddleware: makeMiddleware({
continue: true,
rewriteUrl: "https://external.com/api",
responseHeaders: [["x-middleware-request-authorization", "Bearer token"]],
}),
}),
);
expect(result.type).toBe("response");
// Verify fetch was called (external proxy triggered)
expect(mockFetch).toHaveBeenCalled();
mockFetch.mockRestore();
});
});
// 20. wrapMiddlewareWithBasePath: shared adapter helper that re-adds the basePath
// to the middleware request URL. Used by prod-server.ts and the generated worker
// entry (deploy.ts) so middleware sees the original (pre-stripping) URL.
describe("wrapMiddlewareWithBasePath", () => {
it("passes through unchanged when hadBasePath is false (out-of-basePath request)", async () => {
const runMiddleware = makeMiddleware({ continue: true });
const wrapped = wrapMiddlewareWithBasePath(runMiddleware, "/root", false);
// Out-of-basePath requests stay bare so middleware sees nextUrl.basePath === "" (#1830).
expect(wrapped).toBe(runMiddleware);
await wrapped(makeRequest("/dashboard"), null, { isDataRequest: false });
expect(runMiddleware.mock.calls[0][0].url).toBe("http://localhost/dashboard");
});
it("passes through unchanged when basePath is empty", () => {
const runMiddleware = makeMiddleware({ continue: true });
expect(wrapMiddlewareWithBasePath(runMiddleware, "", true)).toBe(runMiddleware);
});
it("re-adds the basePath to the request URL when hadBasePath is true", async () => {
const runMiddleware = makeMiddleware({ continue: true });
const wrapped = wrapMiddlewareWithBasePath(runMiddleware, "/root", true);
expect(wrapped).not.toBe(runMiddleware);
const result = await wrapped(makeRequest("/dashboard?q=1", { "x-test": "kept" }), "ctx", {
isDataRequest: true,
});
expect(result.continue).toBe(true);
const [mwReq, ctx, opts] = runMiddleware.mock.calls[0];
expect(mwReq.url).toBe("http://localhost/root/dashboard?q=1");
// Headers, ctx, and opts are forwarded untouched.
expect(mwReq.headers.get("x-test")).toBe("kept");
expect(ctx).toBe("ctx");
expect(opts).toEqual({ isDataRequest: true });
});
it("does not double-add an already-present basePath (addBasePathToPathname is idempotent)", async () => {
const runMiddleware = makeMiddleware({ continue: true });
const wrapped = wrapMiddlewareWithBasePath(runMiddleware, "/root", true);
await wrapped(makeRequest("/root/dashboard"), null, { isDataRequest: false });
expect(runMiddleware.mock.calls[0][0].url).toBe("http://localhost/root/dashboard");
});
});