mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
f1efcc1e60
* fix(cloudflare): validate reusable warm responses * fix(cloudflare): retry staged warmup failures safely * fix(cloudflare): extend staged propagation retries * fix(build): filter Pages-owned RSC warm paths * fix(cloudflare): keep CDN warmup correct with custom adapters and i18n * fix(cloudflare): reject unusable CDN warm responses * fix(cloudflare): keep CDN warmup bound to the deployed build * fix(router): reuse warmed RSC on locale-prefixed hybrid routes * fix(cloudflare): verify exact build and variant during CDN warmup * fix(router): keep warmed RSC ownership rewrite-safe * test(cloudflare): prove browser reuse of warmed RSC variants * test(cloudflare): bind RSC warm proof to each deploy attempt * fix(rsc): keep deferred loading shells contextual * fix(build): keep CDN warm discovery route-correct * fix(cache): preserve valid CDN policy during RSC warmup * fix(cloudflare): warm the deployed Worker route host * fix(cloudflare): preserve staged warmup guarantees * fix(build): discover localized Pages warmup paths * fix(cloudflare): wait for staged Worker before CDN warmup * fix(cloudflare): preserve strict warmup deploy invariants * fix(build): fail closed during CDN warm discovery
2255 lines
86 KiB
TypeScript
2255 lines
86 KiB
TypeScript
/**
|
|
* Prerender phase tests.
|
|
*
|
|
* Tests assert the **structural output** of prerendering — which routes were
|
|
* rendered, which were skipped, which errored, and what files were produced.
|
|
* Tests do NOT assert on raw HTML content (that belongs to E2E/Playwright).
|
|
*
|
|
* Both `prerenderPages()` and `prerenderApp()` are tested against the
|
|
* `pages-basic` and `app-basic` fixtures respectively.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test";
|
|
import fs from "node:fs";
|
|
import { createServer, type Server } from "node:http";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { buildPagesFixture, buildAppFixture, buildCloudflareAppFixture } from "./helpers.js";
|
|
import {
|
|
extractRscPayloadFromPrerenderedHtml,
|
|
resolveParentParams,
|
|
writePrerenderIndex,
|
|
type PrerenderRouteResult,
|
|
type StaticParamsMap,
|
|
} from "../packages/vinext/src/build/prerender.js";
|
|
import { VINEXT_PRERENDER_SPECULATIVE_HEADER } from "../packages/vinext/src/server/headers.js";
|
|
import { safeJsonStringify } from "../packages/vinext/src/server/html.js";
|
|
import type { AppRoute } from "../packages/vinext/src/routing/app-router.js";
|
|
import { getAppRouteOutputPath } from "../packages/vinext/src/utils/prerender-output-paths.js";
|
|
|
|
const PAGES_FIXTURE = path.resolve(import.meta.dirname, "./fixtures/pages-basic");
|
|
const APP_FIXTURE = path.resolve(import.meta.dirname, "./fixtures/app-basic");
|
|
const CF_FIXTURE = path.resolve(import.meta.dirname, "./fixtures/cf-app-basic");
|
|
|
|
// ─── Helper ──────────────────────────────────────────────────────────────────
|
|
|
|
function tmpDir(prefix: string): string {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
}
|
|
|
|
function findRoute(
|
|
results: PrerenderRouteResult[],
|
|
route: string,
|
|
): PrerenderRouteResult | undefined {
|
|
return results.find((r) => r.route === route || ("path" in r && r.path === route));
|
|
}
|
|
|
|
function listen(server: Server): Promise<number> {
|
|
return new Promise((resolve, reject) => {
|
|
server.on("error", reject);
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server.address();
|
|
if (typeof address === "object" && address !== null) {
|
|
resolve(address.port);
|
|
} else {
|
|
reject(new Error("test server did not expose a TCP port"));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function closeServer(server: Server): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
server.close((error) => {
|
|
if (error) reject(error);
|
|
else resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
const RSC_RUNTIME_BOOTSTRAP_EXPRESSION =
|
|
'((self[Symbol.for("vinext.navigationRuntime")]??={bootstrap:{routeManifest:null},functions:{}}).bootstrap.rsc??={rsc:[]})';
|
|
|
|
function runtimeRscChunkScript(chunk: string | [3, string]): string {
|
|
return `<script>${RSC_RUNTIME_BOOTSTRAP_EXPRESSION}.rsc.push(${safeJsonStringify(chunk)})</script>`;
|
|
}
|
|
|
|
function runtimeRscDoneScript(): string {
|
|
return `<script>${RSC_RUNTIME_BOOTSTRAP_EXPRESSION}.done=true</script>`;
|
|
}
|
|
|
|
function runtimeRscDoneScriptWithCacheMetadata(): string {
|
|
return `<script>Object.assign(${RSC_RUNTIME_BOOTSTRAP_EXPRESSION},{"initialCacheKind":"static"});${RSC_RUNTIME_BOOTSTRAP_EXPRESSION}.done=true</script>`;
|
|
}
|
|
|
|
function legacyRscChunkScript(chunk: string | [3, string]): string {
|
|
return (
|
|
"<script>self.__VINEXT_RSC_CHUNKS__=self.__VINEXT_RSC_CHUNKS__||[];" +
|
|
`self.__VINEXT_RSC_CHUNKS__.push(${safeJsonStringify(chunk)})</script>`
|
|
);
|
|
}
|
|
|
|
function legacyRscDoneScript(): string {
|
|
return "<script>self.__VINEXT_RSC_DONE__=true</script>";
|
|
}
|
|
|
|
// ─── App Router RSC payload extraction ───────────────────────────────────────
|
|
|
|
describe("extractRscPayloadFromPrerenderedHtml", () => {
|
|
function decodeExtractedPayload(html: string): string | null {
|
|
const payload = extractRscPayloadFromPrerenderedHtml(html);
|
|
return payload === null ? null : new TextDecoder().decode(payload);
|
|
}
|
|
|
|
it("reconstructs streamed RSC chunks from inline bootstrap scripts", () => {
|
|
const chunks = [
|
|
'0:D{"name":"layout"}\n',
|
|
'1:["$","div",null,{"children":"hello ) world"}]\n',
|
|
'2:["$","span",null,{"children":"</script><script>alert(1)</script>"}]\n',
|
|
];
|
|
const html =
|
|
"<html><body>" +
|
|
chunks.map((chunk) => runtimeRscChunkScript(chunk)).join("") +
|
|
runtimeRscDoneScript() +
|
|
"</body></html>";
|
|
|
|
expect(decodeExtractedPayload(html)).toBe(chunks.join(""));
|
|
});
|
|
|
|
it("reconstructs chunks when cache metadata precedes the done marker", () => {
|
|
const html =
|
|
"<html><body>" +
|
|
runtimeRscChunkScript("0:[]\n") +
|
|
runtimeRscDoneScriptWithCacheMetadata() +
|
|
"</body></html>";
|
|
|
|
expect(decodeExtractedPayload(html)).toBe("0:[]\n");
|
|
});
|
|
|
|
it("keeps parsing legacy streamed RSC chunk scripts", () => {
|
|
const chunks = ['0:D{"name":"layout"}\n', '1:["$","div",null,{"children":"legacy"}]\n'];
|
|
const html =
|
|
"<html><body>" +
|
|
chunks.map((chunk) => legacyRscChunkScript(chunk)).join("") +
|
|
legacyRscDoneScript() +
|
|
"</body></html>";
|
|
|
|
expect(decodeExtractedPayload(html)).toBe(chunks.join(""));
|
|
});
|
|
|
|
it("reconstructs binary RSC chunks from inline bootstrap scripts", () => {
|
|
// Ported from Next.js: test/e2e/app-dir/binary/rsc-binary.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/binary/rsc-binary.test.ts
|
|
const html =
|
|
"<html><body>" +
|
|
runtimeRscChunkScript("0:text\n") +
|
|
runtimeRscChunkScript([3, "/wABAgM="]) +
|
|
runtimeRscDoneScript() +
|
|
"</body></html>";
|
|
|
|
const payload = extractRscPayloadFromPrerenderedHtml(html);
|
|
|
|
expect(payload).toEqual(
|
|
new Uint8Array([...new TextEncoder().encode("0:text\n"), 255, 0, 1, 2, 3]),
|
|
);
|
|
});
|
|
|
|
it("throws when the done marker is missing", () => {
|
|
const html = "<html><body>" + runtimeRscChunkScript("0:[]\n") + "</body></html>";
|
|
|
|
expect(() => extractRscPayloadFromPrerenderedHtml(html)).toThrow(/missing RSC done marker/);
|
|
});
|
|
|
|
it("does not treat marker-looking RSC payload text as the done control script", () => {
|
|
const html =
|
|
"<html><body>" + runtimeRscChunkScript('0:["__VINEXT_RSC_DONE__=true"]\n') + "</body></html>";
|
|
|
|
expect(() => extractRscPayloadFromPrerenderedHtml(html)).toThrow(/missing RSC done marker/);
|
|
});
|
|
|
|
it("ignores non-chunk runtime scripts that start with the bootstrap expression", () => {
|
|
const html =
|
|
"<html><body>" +
|
|
`<script>${RSC_RUNTIME_BOOTSTRAP_EXPRESSION}.metadata={}</script>` +
|
|
runtimeRscChunkScript("0:[]\n") +
|
|
runtimeRscDoneScript() +
|
|
"</body></html>";
|
|
|
|
expect(decodeExtractedPayload(html)).toBe("0:[]\n");
|
|
});
|
|
|
|
it("rejects chunk scripts with trailing code after the payload push", () => {
|
|
const html =
|
|
"<html><body>" +
|
|
`<script>${RSC_RUNTIME_BOOTSTRAP_EXPRESSION}.rsc.push(${safeJsonStringify("0:[]\n")})alert(1)</script>` +
|
|
runtimeRscDoneScript() +
|
|
"</body></html>";
|
|
|
|
// JSON.parse rejects the slice (which includes the `)` and `alert(1` after
|
|
// the JSON-encoded string), so this is reported as malformed JSON rather
|
|
// than a separate "trailing code" diagnostic.
|
|
expect(() => extractRscPayloadFromPrerenderedHtml(html)).toThrow(
|
|
"[vinext] Malformed prerender RSC embed: invalid chunk JSON",
|
|
);
|
|
});
|
|
|
|
it("rejects chunk scripts with invalid JSON", () => {
|
|
const html =
|
|
"<html><body>" +
|
|
`<script>${RSC_RUNTIME_BOOTSTRAP_EXPRESSION}.rsc.push("\\uZZZZ")</script>` +
|
|
runtimeRscDoneScript() +
|
|
"</body></html>";
|
|
|
|
expect(() => extractRscPayloadFromPrerenderedHtml(html)).toThrow(
|
|
"[vinext] Malformed prerender RSC embed: invalid chunk JSON",
|
|
);
|
|
});
|
|
|
|
it("returns null when no chunk scripts and no done marker are present (middleware short-circuit)", () => {
|
|
// Middleware that returns a custom 200 HTML body bypasses the App Router
|
|
// pipeline entirely — no chunks, no done marker. The driver detects this
|
|
// null and falls back to a second invocation with `RSC: 1`.
|
|
expect(extractRscPayloadFromPrerenderedHtml("<html><body>legacy</body></html>")).toBeNull();
|
|
});
|
|
|
|
it("throws when only the done marker is present without any chunks", () => {
|
|
// Half-emitted embed (done marker but no chunks) is a real bug — partial
|
|
// emission shouldn't fall back silently.
|
|
const html = `<html><body>${runtimeRscDoneScript()}</body></html>`;
|
|
|
|
expect(() => extractRscPayloadFromPrerenderedHtml(html)).toThrow(
|
|
"[vinext] Malformed prerender RSC embed: done marker present without chunk scripts",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("prerenderApp — RSC extraction", () => {
|
|
it("requests metadata routes through basePath while writing basePath-free artifacts", async () => {
|
|
const root = tmpDir("vinext-prerender-metadata-basepath-");
|
|
const outDir = path.join(root, "out");
|
|
const requestedPaths: string[] = [];
|
|
const server = createServer((req, res) => {
|
|
requestedPaths.push(req.url ?? "");
|
|
if (req.url === "/__vinext/prerender/metadata-routes") {
|
|
res.setHeader("content-type", "application/json");
|
|
res.end(
|
|
JSON.stringify([{ path: "/robots.txt", routePattern: "/robots.txt", routeSegments: [] }]),
|
|
);
|
|
return;
|
|
}
|
|
if (req.url === "/docs/robots.txt") {
|
|
res.setHeader("content-type", "text/plain");
|
|
res.setHeader("cache-control", "public, max-age=0, must-revalidate");
|
|
res.setHeader("x-next-cache-tags", "metadata-user-tag");
|
|
res.setHeader("x-vinext-prerender-cache-life", '{"revalidate":900}');
|
|
res.end("User-Agent: *\nAllow: /buildtime\n");
|
|
return;
|
|
}
|
|
res.statusCode = 404;
|
|
res.end("<html>not found</html>");
|
|
});
|
|
|
|
const port = await listen(server);
|
|
try {
|
|
const { prerenderApp } = await import("../packages/vinext/src/build/prerender.js");
|
|
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
|
|
const config = await resolveNextConfig({ basePath: "/docs" });
|
|
const result = await prerenderApp({
|
|
mode: "default",
|
|
rscBundlePath: path.join(root, "dist", "server", "index.js"),
|
|
routes: [],
|
|
metadataRoutes: [
|
|
{
|
|
type: "robots",
|
|
isDynamic: true,
|
|
filePath: path.join(root, "app", "robots.ts"),
|
|
routePrefix: "",
|
|
routeSegments: [],
|
|
servedUrl: "/robots.txt",
|
|
contentType: "text/plain",
|
|
},
|
|
],
|
|
outDir,
|
|
config,
|
|
_prodServer: { server, port },
|
|
});
|
|
|
|
expect(requestedPaths).toContain("/docs/robots.txt");
|
|
expect(requestedPaths).not.toContain("/robots.txt");
|
|
const metadataResult = findRoute(result.routes, "/robots.txt");
|
|
expect(metadataResult).toMatchObject({
|
|
status: "rendered",
|
|
router: "metadata",
|
|
tags: ["metadata-user-tag"],
|
|
});
|
|
if (metadataResult?.status === "rendered") {
|
|
expect(metadataResult.headers?.["x-next-cache-tags"]).toBeUndefined();
|
|
}
|
|
expect(fs.readFileSync(path.join(outDir, "robots.txt.route"), "utf8")).toContain(
|
|
"/buildtime",
|
|
);
|
|
} finally {
|
|
await closeServer(server);
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not seed metadata artifacts for no-cache or no-store responses", async () => {
|
|
const root = tmpDir("vinext-prerender-metadata-cache-admission-");
|
|
const outDir = path.join(root, "out");
|
|
const server = createServer((req, res) => {
|
|
if (req.url === "/__vinext/prerender/metadata-routes") {
|
|
res.setHeader("content-type", "application/json");
|
|
res.end(
|
|
JSON.stringify([
|
|
{
|
|
path: "/manifest.webmanifest",
|
|
routePattern: "/manifest.webmanifest",
|
|
routeSegments: [],
|
|
},
|
|
{ path: "/icon", routePattern: "/icon", routeSegments: [] },
|
|
]),
|
|
);
|
|
return;
|
|
}
|
|
if (req.url === "/manifest.webmanifest") {
|
|
res.setHeader("content-type", "application/manifest+json");
|
|
res.setHeader("cache-control", "no-cache");
|
|
res.end('{"name":"runtime"}');
|
|
return;
|
|
}
|
|
if (req.url === "/icon") {
|
|
res.setHeader("content-type", "image/png");
|
|
res.setHeader("cache-control", "no-store");
|
|
res.end("runtime image");
|
|
return;
|
|
}
|
|
res.statusCode = 404;
|
|
res.end("<html>not found</html>");
|
|
});
|
|
|
|
const port = await listen(server);
|
|
try {
|
|
const { prerenderApp } = await import("../packages/vinext/src/build/prerender.js");
|
|
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
|
|
const config = await resolveNextConfig({});
|
|
const result = await prerenderApp({
|
|
mode: "default",
|
|
rscBundlePath: path.join(root, "dist", "server", "index.js"),
|
|
routes: [],
|
|
metadataRoutes: [
|
|
{
|
|
type: "manifest",
|
|
isDynamic: true,
|
|
filePath: path.join(root, "app", "manifest.ts"),
|
|
routePrefix: "",
|
|
routeSegments: [],
|
|
servedUrl: "/manifest.webmanifest",
|
|
contentType: "application/manifest+json",
|
|
},
|
|
{
|
|
type: "icon",
|
|
isDynamic: true,
|
|
filePath: path.join(root, "app", "icon.tsx"),
|
|
routePrefix: "",
|
|
routeSegments: [],
|
|
servedUrl: "/icon",
|
|
contentType: "image/png",
|
|
},
|
|
],
|
|
outDir,
|
|
config,
|
|
_prodServer: { server, port },
|
|
});
|
|
|
|
expect(findRoute(result.routes, "/manifest.webmanifest")).toMatchObject({
|
|
status: "skipped",
|
|
reason: "dynamic",
|
|
});
|
|
expect(findRoute(result.routes, "/icon")).toMatchObject({
|
|
status: "skipped",
|
|
reason: "dynamic",
|
|
});
|
|
expect(fs.existsSync(path.join(outDir, "manifest.webmanifest.route"))).toBe(false);
|
|
expect(fs.existsSync(path.join(outDir, "icon.route"))).toBe(false);
|
|
|
|
const manifest = JSON.parse(
|
|
fs.readFileSync(path.join(outDir, "vinext-prerender.json"), "utf8"),
|
|
) as { routes: Array<{ route: string; status: string }> };
|
|
expect(manifest.routes).toEqual(
|
|
expect.arrayContaining([
|
|
{ route: "/manifest.webmanifest", status: "skipped", reason: "dynamic" },
|
|
{ route: "/icon", status: "skipped", reason: "dynamic" },
|
|
]),
|
|
);
|
|
expect(
|
|
manifest.routes.some(
|
|
(route) =>
|
|
route.status === "rendered" &&
|
|
(route.route === "/manifest.webmanifest" || route.route === "/icon"),
|
|
),
|
|
).toBe(false);
|
|
} finally {
|
|
await closeServer(server);
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("writes the .rsc file from rendered HTML without a second RSC request", async () => {
|
|
const root = tmpDir("vinext-prerender-rsc-dedupe-");
|
|
const outDir = path.join(root, "out");
|
|
const appDir = path.join(root, "app");
|
|
const pagePath = path.join(appDir, "page.tsx");
|
|
fs.mkdirSync(appDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
pagePath,
|
|
"export const dynamic = 'force-static';\nexport default function Page() { return null; }\n",
|
|
);
|
|
|
|
const rscPayload = '0:["$","div",null,{"children":"from html"}]\n';
|
|
let rscRequestCount = 0;
|
|
const server = createServer((req, res) => {
|
|
if (req.headers.rsc === "1" || req.headers.accept === "text/x-component") {
|
|
rscRequestCount++;
|
|
res.statusCode = 500;
|
|
res.end("unexpected RSC request");
|
|
return;
|
|
}
|
|
|
|
if (req.url === "/__vinext_nonexistent_for_404__") {
|
|
res.statusCode = 404;
|
|
res.end("<html><body>not found</body></html>");
|
|
return;
|
|
}
|
|
|
|
res.setHeader("content-type", "text/html");
|
|
res.end(
|
|
"<html><body>" +
|
|
runtimeRscChunkScript(rscPayload) +
|
|
runtimeRscDoneScript() +
|
|
"</body></html>",
|
|
);
|
|
});
|
|
|
|
const port = await listen(server);
|
|
try {
|
|
const { prerenderApp } = await import("../packages/vinext/src/build/prerender.js");
|
|
const { appRouter } = await import("../packages/vinext/src/routing/app-router.js");
|
|
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
|
|
const routes = await appRouter(appDir);
|
|
const config = await resolveNextConfig({});
|
|
|
|
const prerenderResult = await prerenderApp({
|
|
mode: "default",
|
|
rscBundlePath: path.join(root, "dist", "server", "index.js"),
|
|
routes,
|
|
outDir,
|
|
config,
|
|
_prodServer: { server, port },
|
|
});
|
|
|
|
expect(findRoute(prerenderResult.routes, "/")).toMatchObject({
|
|
route: "/",
|
|
status: "rendered",
|
|
});
|
|
expect(fs.readFileSync(path.join(outDir, "index.rsc"), "utf-8")).toBe(rscPayload);
|
|
expect(rscRequestCount).toBe(0);
|
|
} finally {
|
|
await closeServer(server);
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("falls back to a second RSC: 1 invocation when middleware short-circuits with custom HTML", async () => {
|
|
// Middleware that returns a 200 HTML body bypasses the App Router
|
|
// pipeline — the response contains no embed chunks. The driver must
|
|
// recover by issuing a second invocation with `RSC: 1` and use whatever
|
|
// that returns as the .rsc file.
|
|
const root = tmpDir("vinext-prerender-rsc-fallback-");
|
|
const outDir = path.join(root, "out");
|
|
const appDir = path.join(root, "app");
|
|
const pagePath = path.join(appDir, "page.tsx");
|
|
fs.mkdirSync(appDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
pagePath,
|
|
"export const dynamic = 'force-static';\nexport default function Page() { return null; }\n",
|
|
);
|
|
|
|
const middlewareHtml = "<html><body>middleware short-circuit</body></html>";
|
|
const fallbackRscPayload = '0:["$","div",null,{"children":"from fallback"}]\n';
|
|
let pageRequestCount = 0;
|
|
let rscRequestCount = 0;
|
|
const server = createServer((req, res) => {
|
|
const isRsc = req.headers.rsc === "1" || req.headers.accept === "text/x-component";
|
|
|
|
if (req.url === "/__vinext_nonexistent_for_404__") {
|
|
res.statusCode = 404;
|
|
res.end("<html><body>not found</body></html>");
|
|
return;
|
|
}
|
|
|
|
if (isRsc) {
|
|
rscRequestCount++;
|
|
res.setHeader("content-type", "text/x-component");
|
|
res.end(fallbackRscPayload);
|
|
return;
|
|
}
|
|
|
|
// Page request: middleware short-circuits with plain HTML and no
|
|
// RSC embed chunks — exercising the fallback path.
|
|
pageRequestCount++;
|
|
res.setHeader("content-type", "text/html");
|
|
res.end(middlewareHtml);
|
|
});
|
|
|
|
const port = await listen(server);
|
|
try {
|
|
const { prerenderApp } = await import("../packages/vinext/src/build/prerender.js");
|
|
const { appRouter } = await import("../packages/vinext/src/routing/app-router.js");
|
|
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
|
|
const routes = await appRouter(appDir);
|
|
const config = await resolveNextConfig({});
|
|
|
|
const prerenderResult = await prerenderApp({
|
|
mode: "default",
|
|
rscBundlePath: path.join(root, "dist", "server", "index.js"),
|
|
routes,
|
|
outDir,
|
|
config,
|
|
_prodServer: { server, port },
|
|
});
|
|
|
|
expect(findRoute(prerenderResult.routes, "/")).toMatchObject({
|
|
route: "/",
|
|
status: "rendered",
|
|
});
|
|
|
|
// HTML on disk is the middleware response.
|
|
expect(fs.readFileSync(path.join(outDir, "index.html"), "utf-8")).toBe(middlewareHtml);
|
|
// .rsc on disk is the fallback RSC: 1 response.
|
|
expect(fs.readFileSync(path.join(outDir, "index.rsc"), "utf-8")).toBe(fallbackRscPayload);
|
|
|
|
// Exactly one page request and one RSC fallback request per route.
|
|
expect(pageRequestCount).toBe(1);
|
|
expect(rscRequestCount).toBe(1);
|
|
} finally {
|
|
await closeServer(server);
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("preserves the speculative prerender marker on fallback RSC requests", async () => {
|
|
const root = tmpDir("vinext-prerender-rsc-speculative-fallback-");
|
|
const outDir = path.join(root, "out");
|
|
const appDir = path.join(root, "app");
|
|
const pagePath = path.join(appDir, "page.tsx");
|
|
fs.mkdirSync(appDir, { recursive: true });
|
|
fs.writeFileSync(pagePath, "export default function Page() { return null; }\n");
|
|
|
|
const middlewareHtml = "<html><body>middleware short-circuit</body></html>";
|
|
const fallbackRscPayload = '0:["$","div",null,{"children":"from fallback"}]\n';
|
|
const seenSpeculativeHeaders: Array<string | string[] | undefined> = [];
|
|
const server = createServer((req, res) => {
|
|
const isRsc = req.headers.rsc === "1" || req.headers.accept === "text/x-component";
|
|
|
|
if (req.url === "/__vinext_nonexistent_for_404__") {
|
|
res.statusCode = 404;
|
|
res.end("<html><body>not found</body></html>");
|
|
return;
|
|
}
|
|
|
|
seenSpeculativeHeaders.push(req.headers[VINEXT_PRERENDER_SPECULATIVE_HEADER]);
|
|
if (isRsc) {
|
|
res.setHeader("content-type", "text/x-component");
|
|
res.end(fallbackRscPayload);
|
|
return;
|
|
}
|
|
|
|
res.setHeader("content-type", "text/html");
|
|
res.end(middlewareHtml);
|
|
});
|
|
|
|
const port = await listen(server);
|
|
try {
|
|
const { prerenderApp } = await import("../packages/vinext/src/build/prerender.js");
|
|
const { appRouter } = await import("../packages/vinext/src/routing/app-router.js");
|
|
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
|
|
const routes = await appRouter(appDir);
|
|
const config = await resolveNextConfig({});
|
|
|
|
const prerenderResult = await prerenderApp({
|
|
mode: "default",
|
|
rscBundlePath: path.join(root, "dist", "server", "index.js"),
|
|
routes,
|
|
outDir,
|
|
config,
|
|
_prodServer: { server, port },
|
|
});
|
|
|
|
expect(findRoute(prerenderResult.routes, "/")).toMatchObject({
|
|
route: "/",
|
|
status: "rendered",
|
|
});
|
|
expect(fs.readFileSync(path.join(outDir, "index.rsc"), "utf-8")).toBe(fallbackRscPayload);
|
|
expect(seenSpeculativeHeaders).toEqual(["1", "1"]);
|
|
} finally {
|
|
await closeServer(server);
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("errors without writing .rsc when the middleware short-circuit fallback RSC request fails", async () => {
|
|
const root = tmpDir("vinext-prerender-rsc-fallback-failure-");
|
|
const outDir = path.join(root, "out");
|
|
const appDir = path.join(root, "app");
|
|
const pagePath = path.join(appDir, "page.tsx");
|
|
fs.mkdirSync(appDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
pagePath,
|
|
"export const dynamic = 'force-static';\nexport default function Page() { return null; }\n",
|
|
);
|
|
|
|
const middlewareHtml = "<html><body>middleware short-circuit</body></html>";
|
|
let pageRequestCount = 0;
|
|
let rscRequestCount = 0;
|
|
const server = createServer((req, res) => {
|
|
const isRsc = req.headers.rsc === "1" || req.headers.accept === "text/x-component";
|
|
|
|
if (req.url === "/__vinext_nonexistent_for_404__") {
|
|
res.statusCode = 404;
|
|
res.end("<html><body>not found</body></html>");
|
|
return;
|
|
}
|
|
|
|
if (isRsc) {
|
|
rscRequestCount++;
|
|
res.statusCode = 500;
|
|
res.end("fallback failed");
|
|
return;
|
|
}
|
|
|
|
pageRequestCount++;
|
|
res.setHeader("content-type", "text/html");
|
|
res.end(middlewareHtml);
|
|
});
|
|
|
|
const port = await listen(server);
|
|
try {
|
|
const { prerenderApp } = await import("../packages/vinext/src/build/prerender.js");
|
|
const { appRouter } = await import("../packages/vinext/src/routing/app-router.js");
|
|
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
|
|
const routes = await appRouter(appDir);
|
|
const config = await resolveNextConfig({});
|
|
|
|
const prerenderResult = await prerenderApp({
|
|
mode: "default",
|
|
rscBundlePath: path.join(root, "dist", "server", "index.js"),
|
|
routes,
|
|
outDir,
|
|
config,
|
|
_prodServer: { server, port },
|
|
});
|
|
|
|
const route = findRoute(prerenderResult.routes, "/");
|
|
expect(route).toMatchObject({
|
|
route: "/",
|
|
status: "error",
|
|
});
|
|
if (route?.status !== "error") throw new Error("expected route to fail prerender");
|
|
expect(route.error).toContain("[vinext] prerenderApp: RSC fallback returned 500 for /");
|
|
expect(fs.existsSync(path.join(outDir, "index.rsc"))).toBe(false);
|
|
expect(pageRequestCount).toBe(1);
|
|
expect(rscRequestCount).toBe(1);
|
|
} finally {
|
|
await closeServer(server);
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Pages Router ─────────────────────────────────────────────────────────────
|
|
|
|
describe("prerenderPages — default mode (pages-basic)", () => {
|
|
let outDir: string;
|
|
let results: PrerenderRouteResult[];
|
|
|
|
beforeAll(async () => {
|
|
const pagesBundlePath = await buildPagesFixture(PAGES_FIXTURE);
|
|
outDir = tmpDir("vinext-prerender-pages-");
|
|
|
|
const { prerenderPages } = await import("../packages/vinext/src/build/prerender.js");
|
|
const { pagesRouter, apiRouter } =
|
|
await import("../packages/vinext/src/routing/pages-router.js");
|
|
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
|
|
|
|
const pagesDir = path.resolve(PAGES_FIXTURE, "pages");
|
|
const pageRoutes = await pagesRouter(pagesDir);
|
|
const apiRoutes = await apiRouter(pagesDir);
|
|
const config = await resolveNextConfig({});
|
|
|
|
const prerenderResult = await prerenderPages({
|
|
mode: "default",
|
|
pagesBundlePath,
|
|
routes: pageRoutes,
|
|
apiRoutes,
|
|
pagesDir,
|
|
outDir,
|
|
config,
|
|
});
|
|
results = prerenderResult.routes;
|
|
}, 60_000);
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(outDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// ── Static pages ───────────────────────────────────────────────────────────
|
|
|
|
it("renders static index page", () => {
|
|
const r = findRoute(results, "/");
|
|
expect(r).toMatchObject({
|
|
route: "/",
|
|
status: "rendered",
|
|
revalidate: false,
|
|
});
|
|
if (r?.status === "rendered") {
|
|
expect(r.outputFiles).toContain("index.html");
|
|
}
|
|
});
|
|
|
|
it("renders static about page", () => {
|
|
const r = findRoute(results, "/about");
|
|
expect(r).toMatchObject({ route: "/about", status: "rendered", revalidate: false });
|
|
if (r?.status === "rendered") {
|
|
expect(r.outputFiles).toContain("about.html");
|
|
}
|
|
});
|
|
|
|
it("renders a static page backed by a bundled CommonJS dependency", () => {
|
|
const r = findRoute(results, "/cjs-dependency-globals-static");
|
|
expect(r).toMatchObject({
|
|
route: "/cjs-dependency-globals-static",
|
|
status: "rendered",
|
|
revalidate: false,
|
|
});
|
|
if (r?.status === "rendered") {
|
|
expect(r.outputFiles).toContain("cjs-dependency-globals-static.html");
|
|
const html = fs.readFileSync(path.join(outDir, "cjs-dependency-globals-static.html"), "utf8");
|
|
expect(html).toContain('<p id="identity-types">string:string</p>');
|
|
expect(html).toContain('<p id="identity-consistent">true</p>');
|
|
expect(html).toContain('<p id="shadowed-global-this">local-globalThis</p>');
|
|
expect(html).toContain('<p id="filename-readable">true</p>');
|
|
expect(html).toMatch(/<p id="concatenated-path">.*\/server\/concatenated\.js<\/p>/);
|
|
}
|
|
});
|
|
|
|
it("renders 404 page", () => {
|
|
const r = findRoute(results, "/404");
|
|
expect(results.filter((result) => result.route === "/404")).toHaveLength(1);
|
|
expect(r).toMatchObject({ route: "/404", status: "rendered", revalidate: false });
|
|
if (r?.status === "rendered") {
|
|
expect(r.outputFiles).toContain("404.html");
|
|
}
|
|
});
|
|
|
|
// ── Dynamic routes with getStaticPaths ────────────────────────────────────
|
|
|
|
it("renders static dynamic routes from getStaticPaths (fallback: false)", () => {
|
|
const slugs = ["hello-world", "getting-started"];
|
|
for (const slug of slugs) {
|
|
const r = findRoute(results, `/blog/${slug}`);
|
|
expect(r).toMatchObject({
|
|
route: "/blog/:slug",
|
|
path: `/blog/${slug}`,
|
|
status: "rendered",
|
|
revalidate: false,
|
|
});
|
|
if (r?.status === "rendered") {
|
|
expect(r.outputFiles).toContain(`blog/${slug}.html`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("renders dynamic routes from getStaticPaths (fallback: 'blocking')", () => {
|
|
const ids = ["1", "2"];
|
|
for (const id of ids) {
|
|
const r = findRoute(results, `/articles/${id}`);
|
|
expect(r).toMatchObject({
|
|
route: "/articles/:id",
|
|
path: `/articles/${id}`,
|
|
status: "rendered",
|
|
revalidate: false,
|
|
});
|
|
}
|
|
});
|
|
|
|
// Next.js accepts both `paths: Array<{ params }>` and `paths: Array<string>`
|
|
// from getStaticPaths. The string-path variant is documented at
|
|
// https://nextjs.org/docs/pages/api-reference/functions/get-static-paths and
|
|
// implemented in .nextjs-ref/packages/next/src/build/static-paths/pages.ts
|
|
// (the `typeof entry === 'string'` branch around line 89).
|
|
it("renders dynamic routes from getStaticPaths with string paths", () => {
|
|
const slugs = ["hello-world", "another-one"];
|
|
for (const slug of slugs) {
|
|
const r = findRoute(results, `/string-paths/${slug}`);
|
|
expect(r).toMatchObject({
|
|
route: "/string-paths/:slug",
|
|
path: `/string-paths/${slug}`,
|
|
status: "rendered",
|
|
revalidate: false,
|
|
});
|
|
if (r?.status === "rendered") {
|
|
expect(r.outputFiles).toContain(`string-paths/${slug}.html`);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Next.js rejects entries with a missing `params` key — see
|
|
// .nextjs-ref/packages/next/src/build/static-paths/pages.ts (around line 169)
|
|
// "A required parameter (X) was not provided as a string received undefined"
|
|
// We must NOT crash the whole prerender phase on this; surface it as a
|
|
// per-route error result, the same shape we use elsewhere.
|
|
it("surfaces missing-params entries as a per-route error (does not crash)", () => {
|
|
const errored = results.find(
|
|
(r) => r.route === "/missing-params/:slug" && r.status === "error",
|
|
);
|
|
expect(errored).toBeDefined();
|
|
if (errored && errored.status === "error") {
|
|
expect(errored.error).toMatch(/missing the `params` key|params is undefined/);
|
|
}
|
|
});
|
|
|
|
// ── ISR page ───────────────────────────────────────────────────────────────
|
|
|
|
it("renders ISR page with correct revalidate interval", () => {
|
|
const r = findRoute(results, "/isr-test");
|
|
expect(r).toMatchObject({ route: "/isr-test", status: "rendered", revalidate: 1 });
|
|
if (r?.status === "rendered") {
|
|
expect(r.outputFiles).toContain("isr-test.html");
|
|
}
|
|
});
|
|
|
|
// ── SSR pages — skipped ────────────────────────────────────────────────────
|
|
|
|
it("skips SSR pages (getServerSideProps) in default mode", () => {
|
|
const ssrRoutes = ["/ssr", "/ssr-headers"];
|
|
for (const route of ssrRoutes) {
|
|
const r = findRoute(results, route);
|
|
expect(r).toMatchObject({ route, status: "skipped", reason: "ssr" });
|
|
}
|
|
});
|
|
|
|
it("skips getServerSideProps dynamic route in default mode", () => {
|
|
// posts/[id] has getServerSideProps — pattern is /posts/:id
|
|
const ssrRoute = results.find(
|
|
(r) =>
|
|
r.status === "skipped" &&
|
|
"reason" in r &&
|
|
r.reason === "ssr" &&
|
|
r.route.startsWith("/posts"),
|
|
);
|
|
expect(ssrRoute).toBeDefined();
|
|
});
|
|
|
|
// ── API routes — always skipped ────────────────────────────────────────────
|
|
|
|
it("skips all API routes", () => {
|
|
const apiResults = results.filter(
|
|
(r) => r.status === "skipped" && "reason" in r && r.reason === "api",
|
|
);
|
|
expect(apiResults.length).toBeGreaterThan(0);
|
|
// hello API is a known API route
|
|
const hello = findRoute(results, "/api/hello");
|
|
expect(hello).toMatchObject({ route: "/api/hello", status: "skipped", reason: "api" });
|
|
});
|
|
|
|
// ── Written files ──────────────────────────────────────────────────────────
|
|
|
|
it("writes HTML files to outDir", () => {
|
|
expect(fs.existsSync(path.join(outDir, "index.html"))).toBe(true);
|
|
expect(fs.existsSync(path.join(outDir, "about.html"))).toBe(true);
|
|
expect(fs.existsSync(path.join(outDir, "isr-test.html"))).toBe(true);
|
|
});
|
|
|
|
// ── vinext-prerender.json ─────────────────────────────────────────────────
|
|
|
|
it("writes vinext-prerender.json with correct structure", () => {
|
|
const indexPath = path.join(outDir, "vinext-prerender.json");
|
|
expect(fs.existsSync(indexPath)).toBe(true);
|
|
|
|
const index = JSON.parse(fs.readFileSync(indexPath, "utf-8"));
|
|
expect(Array.isArray(index.routes)).toBe(true);
|
|
|
|
// Check a rendered entry
|
|
const home = index.routes.find((r: any) => r.route === "/");
|
|
expect(home).toMatchObject({ route: "/", status: "rendered", revalidate: false });
|
|
// outputFiles not in index (stripped)
|
|
expect(home.outputFiles).toBeUndefined();
|
|
|
|
// Check ISR entry
|
|
const isr = index.routes.find((r: any) => r.route === "/isr-test");
|
|
expect(isr).toMatchObject({ route: "/isr-test", status: "rendered", revalidate: 1 });
|
|
|
|
// Check a skipped entry
|
|
const ssr = index.routes.find((r: any) => r.route === "/ssr");
|
|
expect(ssr).toMatchObject({ route: "/ssr", status: "skipped", reason: "ssr" });
|
|
});
|
|
});
|
|
|
|
describe("writePrerenderIndex", () => {
|
|
it("writes metadata artifact paths and response metadata without page classification", () => {
|
|
expect(getAppRouteOutputPath("/products/sitemap/1.xml")).toBe("products/sitemap/1.xml.route");
|
|
|
|
const dir = tmpDir("vinext-prerender-metadata-index-");
|
|
writePrerenderIndex(
|
|
[
|
|
{
|
|
route: "/robots.txt",
|
|
status: "rendered",
|
|
outputFiles: [getAppRouteOutputPath("/robots.txt")],
|
|
revalidate: false,
|
|
router: "metadata",
|
|
routeSegments: ["robots"],
|
|
headers: { "content-type": "text/plain" },
|
|
responseStatus: 200,
|
|
},
|
|
],
|
|
dir,
|
|
{ buildId: "metadata-build" },
|
|
);
|
|
|
|
const index = JSON.parse(fs.readFileSync(path.join(dir, "vinext-prerender.json"), "utf-8"));
|
|
expect(index.routes[0]).toEqual({
|
|
route: "/robots.txt",
|
|
status: "rendered",
|
|
revalidate: false,
|
|
router: "metadata",
|
|
routeSegments: ["robots"],
|
|
headers: { "content-type": "text/plain" },
|
|
responseStatus: 200,
|
|
});
|
|
expect(index.pregeneratedConcretePaths).toEqual([]);
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("carries the resolved cacheLife stale into the written index", () => {
|
|
// Regression: seedMemoryCacheFromPrerender reads `route.stale` from
|
|
// vinext-prerender.json — dropping it here silently reverts seeded cache
|
|
// hits to the configured staleTimes fallback.
|
|
const dir = tmpDir("vinext-prerender-index-");
|
|
writePrerenderIndex(
|
|
[
|
|
{
|
|
route: "/cached",
|
|
status: "rendered",
|
|
outputFiles: ["cached.html"],
|
|
revalidate: 60,
|
|
expire: 300,
|
|
stale: 30,
|
|
router: "app",
|
|
},
|
|
],
|
|
dir,
|
|
{ buildId: "b1" },
|
|
);
|
|
|
|
const index = JSON.parse(fs.readFileSync(path.join(dir, "vinext-prerender.json"), "utf-8"));
|
|
expect(index.routes[0]).toMatchObject({
|
|
route: "/cached",
|
|
revalidate: 60,
|
|
expire: 300,
|
|
stale: 30,
|
|
});
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
describe("prerenderPages — export mode (pages-basic)", () => {
|
|
let outDir: string;
|
|
let results: PrerenderRouteResult[];
|
|
|
|
beforeAll(async () => {
|
|
const pagesBundlePath = await buildPagesFixture(PAGES_FIXTURE);
|
|
outDir = tmpDir("vinext-prerender-pages-export-");
|
|
|
|
const { prerenderPages } = await import("../packages/vinext/src/build/prerender.js");
|
|
const { pagesRouter, apiRouter } =
|
|
await import("../packages/vinext/src/routing/pages-router.js");
|
|
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
|
|
|
|
const pagesDir = path.resolve(PAGES_FIXTURE, "pages");
|
|
const pageRoutes = await pagesRouter(pagesDir);
|
|
const apiRoutes = await apiRouter(pagesDir);
|
|
const config = await resolveNextConfig({ output: "export" });
|
|
|
|
const prerenderResult = await prerenderPages({
|
|
mode: "export",
|
|
pagesBundlePath,
|
|
routes: pageRoutes,
|
|
apiRoutes,
|
|
pagesDir,
|
|
outDir,
|
|
config,
|
|
});
|
|
results = prerenderResult.routes;
|
|
}, 60_000);
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(outDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("renders static and ISR routes (ISR treated as static)", () => {
|
|
expect(findRoute(results, "/")).toMatchObject({ status: "rendered", revalidate: false });
|
|
expect(findRoute(results, "/about")).toMatchObject({ status: "rendered", revalidate: false });
|
|
// ISR route in export mode: revalidate ignored → false
|
|
expect(findRoute(results, "/isr-test")).toMatchObject({
|
|
status: "rendered",
|
|
revalidate: false,
|
|
});
|
|
});
|
|
|
|
it("errors on SSR pages in export mode", () => {
|
|
const ssr = findRoute(results, "/ssr");
|
|
expect(ssr).toMatchObject({ status: "error" });
|
|
if (ssr?.status === "error") {
|
|
expect(ssr.error).toMatch(/getServerSideProps/);
|
|
}
|
|
});
|
|
|
|
it("includes stack trace in error when enablePrerenderSourceMaps is true", () => {
|
|
// enablePrerenderSourceMaps defaults to true in resolveNextConfig (line 230)
|
|
const errorRoute = findRoute(results, "/error-throw");
|
|
expect(errorRoute).toMatchObject({ status: "error" });
|
|
if (errorRoute?.status === "error") {
|
|
// Verify the error includes a stack trace (multiple lines with "at " frames)
|
|
expect(errorRoute.error).toMatch(/\n\s+at /);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── App Router ───────────────────────────────────────────────────────────────
|
|
|
|
describe("prerenderApp — default mode (app-basic)", () => {
|
|
let outDir: string;
|
|
let results: PrerenderRouteResult[];
|
|
let nextPhaseAfterPrerender: string | undefined;
|
|
|
|
beforeAll(async () => {
|
|
const rscBundlePath = await buildAppFixture(APP_FIXTURE);
|
|
outDir = tmpDir("vinext-prerender-app-");
|
|
|
|
const { prerenderApp } = await import("../packages/vinext/src/build/prerender.js");
|
|
const { appRouter } = await import("../packages/vinext/src/routing/app-router.js");
|
|
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
|
|
|
|
const appDir = path.resolve(APP_FIXTURE, "app");
|
|
const routes = await appRouter(appDir);
|
|
const config = await resolveNextConfig({});
|
|
|
|
const previousNextPhase = process.env.NEXT_PHASE;
|
|
process.env.NEXT_PHASE = "phase-production-server";
|
|
try {
|
|
const prerenderResult = await prerenderApp({
|
|
mode: "default",
|
|
rscBundlePath,
|
|
routes,
|
|
outDir,
|
|
config,
|
|
});
|
|
results = prerenderResult.routes;
|
|
nextPhaseAfterPrerender = process.env.NEXT_PHASE;
|
|
} finally {
|
|
if (previousNextPhase === undefined) delete process.env.NEXT_PHASE;
|
|
else process.env.NEXT_PHASE = previousNextPhase;
|
|
}
|
|
}, 120_000);
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(outDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// ── Static routes with explicit config ────────────────────────────────────
|
|
|
|
it("renders force-static page", () => {
|
|
const r = findRoute(results, "/static-test");
|
|
expect(r).toMatchObject({ route: "/static-test", status: "rendered", revalidate: false });
|
|
if (r?.status === "rendered") {
|
|
expect(r.outputFiles).toContain("static-test.html");
|
|
expect(r.outputFiles).toContain("static-test.rsc");
|
|
}
|
|
const html = fs.readFileSync(path.join(outDir, "static-test.html"), "utf-8");
|
|
expect(html).toContain("searchParamsFromBrowser:false");
|
|
});
|
|
|
|
it("emits the nearest Suspense fallback when useSearchParams bails out during prerender", () => {
|
|
// Ported from Next.js: test/e2e/app-dir/app-static/app-static.test.ts
|
|
// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/app-static/app-static.test.ts
|
|
const route = "/nextjs-compat/use-search-params-static-bailout";
|
|
expect(findRoute(results, route)).toMatchObject({
|
|
route,
|
|
status: "rendered",
|
|
revalidate: false,
|
|
});
|
|
|
|
const html = fs.readFileSync(
|
|
path.join(outDir, "nextjs-compat/use-search-params-static-bailout.html"),
|
|
"utf8",
|
|
);
|
|
expect(html).toContain('<p id="search-params-suspense">search params suspense</p>');
|
|
expect(html).not.toContain('id="search-params-value"');
|
|
});
|
|
|
|
it("renders revalidate=Infinity page as static", () => {
|
|
const r = findRoute(results, "/revalidate-infinity-test");
|
|
expect(r).toMatchObject({ status: "rendered", revalidate: false });
|
|
});
|
|
|
|
// ── ISR routes ─────────────────────────────────────────────────────────────
|
|
|
|
it("renders ISR page with revalidate=1", () => {
|
|
const r = findRoute(results, "/isr-test");
|
|
expect(r).toMatchObject({ route: "/isr-test", status: "rendered", revalidate: 1 });
|
|
if (r?.status === "rendered") {
|
|
expect(r.outputFiles).toContain("isr-test.html");
|
|
expect(r.outputFiles).toContain("isr-test.rsc");
|
|
}
|
|
});
|
|
|
|
it("renders ISR page with revalidate=60", () => {
|
|
const r = findRoute(results, "/revalidate-test");
|
|
expect(r).toMatchObject({ route: "/revalidate-test", status: "rendered", revalidate: 60 });
|
|
});
|
|
|
|
it("records App Router preload Link headers for cache seeding", () => {
|
|
const r = findRoute(results, "/nextjs-compat/react-max-headers-length");
|
|
expect(r).toMatchObject({
|
|
status: "rendered",
|
|
headers: { link: expect.stringContaining("rel=preload") },
|
|
});
|
|
|
|
const indexPath = path.join(outDir, "vinext-prerender.json");
|
|
const index = JSON.parse(fs.readFileSync(indexPath, "utf-8"));
|
|
const manifestRoute = index.routes.find(
|
|
(route: { route: string }) => route.route === "/nextjs-compat/react-max-headers-length",
|
|
);
|
|
expect(manifestRoute).toMatchObject({
|
|
headers: { link: expect.stringContaining("rel=preload") },
|
|
});
|
|
});
|
|
|
|
it("uses the rendered cacheLife expire value for App Router ISR prerender entries", () => {
|
|
const r = findRoute(results, "/prerender-cache-life");
|
|
expect(r).toMatchObject({
|
|
route: "/prerender-cache-life",
|
|
status: "rendered",
|
|
revalidate: 1,
|
|
expire: 3,
|
|
});
|
|
|
|
const indexPath = path.join(outDir, "vinext-prerender.json");
|
|
const index = JSON.parse(fs.readFileSync(indexPath, "utf-8"));
|
|
const manifestRoute = index.routes.find(
|
|
(route: { route: string }) => route.route === "/prerender-cache-life",
|
|
);
|
|
expect(manifestRoute).toMatchObject({ revalidate: 1, expire: 3 });
|
|
});
|
|
|
|
it("embeds the completed cacheLife stale time in prerendered initial HTML", () => {
|
|
// The prerender path consumes request-scoped cache metadata after the RSC
|
|
// stream settles. The later HTML done script must reuse that completed
|
|
// value so hydration seeds the visited-response cache with the same stale
|
|
// claim that warm HTML/RSC cache-hit headers replay.
|
|
const r = findRoute(results, "/use-cache-test");
|
|
expect(r).toMatchObject({
|
|
route: "/use-cache-test",
|
|
status: "rendered",
|
|
revalidate: 1,
|
|
stale: 30,
|
|
});
|
|
|
|
const html = fs.readFileSync(path.join(outDir, "use-cache-test.html"), "utf-8");
|
|
expect(html).toContain("searchParamsFromBrowser:true");
|
|
expect(html).toContain('"initialCacheKind":"static"');
|
|
expect(html).toContain('"staleTimeSeconds":30');
|
|
});
|
|
|
|
it("renders inline server actions during the production build phase", () => {
|
|
const r = findRoute(results, "/prerender-inline-server-action");
|
|
expect(r).toMatchObject({
|
|
route: "/prerender-inline-server-action",
|
|
status: "rendered",
|
|
});
|
|
|
|
const html = fs.readFileSync(path.join(outDir, "prerender-inline-server-action.html"), "utf-8");
|
|
expect(html).toContain('<div id="phase">at buildtime</div>');
|
|
expect(nextPhaseAfterPrerender).toBe("phase-production-server");
|
|
});
|
|
|
|
it("records collected App Router cache tags for cache seeding", () => {
|
|
const r = findRoute(results, "/unstable-cache-test");
|
|
expect(r).toMatchObject({
|
|
status: "rendered",
|
|
tags: expect.arrayContaining(["unstable-data"]),
|
|
});
|
|
|
|
const indexPath = path.join(outDir, "vinext-prerender.json");
|
|
const index = JSON.parse(fs.readFileSync(indexPath, "utf-8"));
|
|
const manifestRoute = index.routes.find(
|
|
(route: { route: string }) => route.route === "/unstable-cache-test",
|
|
);
|
|
expect(manifestRoute.tags).toEqual(expect.arrayContaining(["unstable-data"]));
|
|
});
|
|
|
|
it("infers App Router ISR prerender metadata from cacheLife without route revalidate", () => {
|
|
const r = findRoute(results, "/prerender-cache-life-only");
|
|
expect(r).toMatchObject({
|
|
route: "/prerender-cache-life-only",
|
|
status: "rendered",
|
|
revalidate: 1,
|
|
expire: 3,
|
|
});
|
|
|
|
const indexPath = path.join(outDir, "vinext-prerender.json");
|
|
const index = JSON.parse(fs.readFileSync(indexPath, "utf-8"));
|
|
const manifestRoute = index.routes.find(
|
|
(route: { route: string }) => route.route === "/prerender-cache-life-only",
|
|
);
|
|
expect(manifestRoute).toMatchObject({ revalidate: 1, expire: 3 });
|
|
});
|
|
|
|
// ── Dynamic routes — skipped ───────────────────────────────────────────────
|
|
|
|
it("skips force-dynamic page", () => {
|
|
const r = findRoute(results, "/dynamic-test");
|
|
expect(r).toMatchObject({ route: "/dynamic-test", status: "skipped", reason: "dynamic" });
|
|
});
|
|
|
|
it("skips revalidate=0 page", () => {
|
|
const r = findRoute(results, "/revalidate-zero-test");
|
|
expect(r).toMatchObject({ status: "skipped", reason: "dynamic" });
|
|
});
|
|
|
|
// ── Dynamic routes with generateStaticParams ───────────────────────────────
|
|
|
|
it("renders /blog/[slug] expanded paths", () => {
|
|
const slugs = ["hello-world", "getting-started", "advanced-guide"];
|
|
for (const slug of slugs) {
|
|
const r = findRoute(results, `/blog/${slug}`);
|
|
expect(r).toMatchObject({
|
|
route: "/blog/:slug",
|
|
path: `/blog/${slug}`,
|
|
status: "rendered",
|
|
revalidate: false,
|
|
});
|
|
if (r?.status === "rendered") {
|
|
expect(r.outputFiles).toContain(`blog/${slug}.html`);
|
|
expect(r.outputFiles).toContain(`blog/${slug}.rsc`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("renders /products/[id] expanded paths", () => {
|
|
for (const id of ["1", "2", "3"]) {
|
|
const r = findRoute(results, `/products/${id}`);
|
|
expect(r).toMatchObject({
|
|
route: "/products/:id",
|
|
path: `/products/${id}`,
|
|
status: "rendered",
|
|
revalidate: false,
|
|
});
|
|
}
|
|
});
|
|
|
|
it("renders /shop/[category] expanded paths", () => {
|
|
for (const category of ["electronics", "clothing"]) {
|
|
const r = findRoute(results, `/shop/${category}`);
|
|
expect(r).toMatchObject({
|
|
route: "/shop/:category",
|
|
path: `/shop/${category}`,
|
|
status: "rendered",
|
|
revalidate: false,
|
|
});
|
|
}
|
|
});
|
|
|
|
it("renders /shop/[category]/[item] top-down params (nested generateStaticParams)", () => {
|
|
const paths = [
|
|
"/shop/electronics/phone",
|
|
"/shop/electronics/laptop",
|
|
"/shop/clothing/shirt",
|
|
"/shop/clothing/pants",
|
|
];
|
|
for (const urlPath of paths) {
|
|
const r = findRoute(results, urlPath);
|
|
expect(r).toMatchObject({
|
|
route: "/shop/:category/:item",
|
|
path: urlPath,
|
|
status: "rendered",
|
|
revalidate: false,
|
|
});
|
|
}
|
|
});
|
|
|
|
it("dedups duplicate generateStaticParams entries (renders /dedup-params/:slug once each)", () => {
|
|
// generateStaticParams returns [{slug:'alpha'},{slug:'alpha'},{slug:'beta'}].
|
|
// The duplicate 'alpha' must collapse to a single rendered route / manifest
|
|
// entry, matching Next.js' filterUniqueParams. See issue #1983.
|
|
const alpha = results.filter((r) => "path" in r && r.path === "/dedup-params/alpha");
|
|
expect(alpha).toHaveLength(1);
|
|
expect(alpha[0]).toMatchObject({
|
|
route: "/dedup-params/:slug",
|
|
path: "/dedup-params/alpha",
|
|
status: "rendered",
|
|
});
|
|
|
|
const beta = results.filter((r) => "path" in r && r.path === "/dedup-params/beta");
|
|
expect(beta).toHaveLength(1);
|
|
});
|
|
|
|
it("skips dynamic routes without generateStaticParams", () => {
|
|
// /photos/[id] has no generateStaticParams
|
|
const r = results.find(
|
|
(r) =>
|
|
r.status === "skipped" &&
|
|
"reason" in r &&
|
|
r.reason === "no-static-params" &&
|
|
r.route.startsWith("/photos"),
|
|
);
|
|
expect(r).toBeDefined();
|
|
});
|
|
|
|
// ── Speculative rendering: unknown routes ──────────────────────────────────
|
|
|
|
it("renders / speculatively (unknown route with no dynamic APIs)", () => {
|
|
const r = findRoute(results, "/");
|
|
expect(r).toMatchObject({ route: "/", status: "rendered", revalidate: false });
|
|
if (r?.status === "rendered") {
|
|
expect(r.outputFiles).toContain("index.html");
|
|
expect(r.outputFiles).toContain("index.rsc");
|
|
}
|
|
});
|
|
|
|
it("renders /about speculatively", () => {
|
|
const r = findRoute(results, "/about");
|
|
expect(r).toMatchObject({ route: "/about", status: "rendered", revalidate: false });
|
|
});
|
|
|
|
it("renders /dashboard speculatively", () => {
|
|
const r = findRoute(results, "/dashboard");
|
|
expect(r).toMatchObject({ route: "/dashboard", status: "rendered", revalidate: false });
|
|
});
|
|
|
|
it("renders layout-only routes whose content comes from parallel slots", () => {
|
|
// Ported from Next.js: test/e2e/app-dir/parallel-routes-and-interception/parallel-routes-and-interception.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/parallel-routes-and-interception/parallel-routes-and-interception.test.ts
|
|
const parent = findRoute(results, "/parallel-nested/home");
|
|
expect(parent).toMatchObject({
|
|
route: "/parallel-nested/home",
|
|
status: "rendered",
|
|
revalidate: false,
|
|
});
|
|
|
|
const nested = findRoute(results, "/parallel-nested/home/nested");
|
|
expect(nested).toMatchObject({
|
|
route: "/parallel-nested/home/nested",
|
|
status: "rendered",
|
|
revalidate: false,
|
|
});
|
|
|
|
const defaultOnly = findRoute(results, "/slot-collision");
|
|
expect(defaultOnly).toMatchObject({
|
|
route: "/slot-collision",
|
|
status: "rendered",
|
|
revalidate: false,
|
|
});
|
|
});
|
|
|
|
it("skips /headers-test (unknown route that calls headers())", () => {
|
|
const r = findRoute(results, "/headers-test");
|
|
// headers-test calls headers() — should be skipped as dynamic
|
|
expect(r).toBeDefined();
|
|
expect(r?.status).toBe("skipped");
|
|
});
|
|
|
|
// Ported from Next.js: test/e2e/app-dir/rsc-redirect/rsc-redirect.test.ts
|
|
// ('should get 307 status code for document request')
|
|
//
|
|
// A speculative prerender of a route that calls `redirect()` must not
|
|
// follow the redirect server-side and cache the destination's HTML under
|
|
// the redirecting URL. Doing so makes the prod server reply with 200 and
|
|
// the destination's body on every document request to the redirecting
|
|
// route, instead of emitting an HTTP 307 with a Location header.
|
|
//
|
|
// See: https://github.com/cloudflare/vinext/issues/1530
|
|
it("skips /redirect-test instead of capturing the destination HTML", () => {
|
|
const r = findRoute(results, "/redirect-test");
|
|
expect(r).toBeDefined();
|
|
expect(r?.status).toBe("skipped");
|
|
// No HTML/RSC must be written for the redirecting route — otherwise the
|
|
// prod server serves the cached destination body with status 200 for
|
|
// every document request to /redirect-test.
|
|
expect(fs.existsSync(path.join(outDir, "redirect-test.html"))).toBe(false);
|
|
expect(fs.existsSync(path.join(outDir, "redirect-test.rsc"))).toBe(false);
|
|
});
|
|
|
|
// ── API routes — always skipped ────────────────────────────────────────────
|
|
|
|
it("skips all API route handlers", () => {
|
|
const apiSkipped = results.filter(
|
|
(r) => r.status === "skipped" && "reason" in r && r.reason === "api",
|
|
);
|
|
expect(apiSkipped.length).toBeGreaterThan(0);
|
|
|
|
// Known API routes
|
|
const hello = findRoute(results, "/api/hello");
|
|
expect(hello).toMatchObject({ status: "skipped", reason: "api" });
|
|
});
|
|
|
|
// ── Written files ──────────────────────────────────────────────────────────
|
|
|
|
it("writes HTML and RSC files to outDir", () => {
|
|
expect(fs.existsSync(path.join(outDir, "index.html"))).toBe(true);
|
|
expect(fs.existsSync(path.join(outDir, "index.rsc"))).toBe(true);
|
|
expect(fs.existsSync(path.join(outDir, "static-test.html"))).toBe(true);
|
|
expect(fs.existsSync(path.join(outDir, "static-test.rsc"))).toBe(true);
|
|
});
|
|
|
|
it("writes blog expanded pages to correct paths", () => {
|
|
expect(fs.existsSync(path.join(outDir, "blog/hello-world.html"))).toBe(true);
|
|
expect(fs.existsSync(path.join(outDir, "blog/hello-world.rsc"))).toBe(true);
|
|
});
|
|
|
|
// ── vinext-prerender.json ─────────────────────────────────────────────────
|
|
|
|
it("writes vinext-prerender.json with correct structure", () => {
|
|
const indexPath = path.join(outDir, "vinext-prerender.json");
|
|
expect(fs.existsSync(indexPath)).toBe(true);
|
|
|
|
const index = JSON.parse(fs.readFileSync(indexPath, "utf-8"));
|
|
expect(Array.isArray(index.routes)).toBe(true);
|
|
|
|
// Rendered routes present — for dynamic routes the manifest has both route (pattern) and path (concrete URL)
|
|
const rendered = index.routes
|
|
.filter((r: any) => r.status === "rendered")
|
|
.map((r: any) => r.path ?? r.route);
|
|
expect(rendered).toContain("/");
|
|
expect(rendered).toContain("/blog/hello-world");
|
|
expect(rendered).toContain("/blog/getting-started");
|
|
expect(rendered).toContain("/blog/advanced-guide");
|
|
expect(rendered).toContain("/products/1");
|
|
expect(rendered).toContain("/products/2");
|
|
expect(rendered).toContain("/products/3");
|
|
expect(rendered).toContain("/shop/electronics");
|
|
expect(rendered).toContain("/shop/clothing");
|
|
expect(rendered).toContain("/shop/electronics/phone");
|
|
|
|
// ISR route has correct revalidate
|
|
const isrTest = index.routes.find((r: any) => r.route === "/isr-test");
|
|
expect(isrTest).toMatchObject({ route: "/isr-test", status: "rendered", revalidate: 1 });
|
|
|
|
// outputFiles not in index
|
|
expect(isrTest?.outputFiles).toBeUndefined();
|
|
|
|
// Skipped route present
|
|
const dynamic = index.routes.find((r: any) => r.route === "/dynamic-test");
|
|
expect(dynamic).toMatchObject({ route: "/dynamic-test", status: "skipped", reason: "dynamic" });
|
|
});
|
|
});
|
|
|
|
// ─── Hybrid: runPrerender with app/ + pages/ ──────────────────────────────────
|
|
|
|
describe("runPrerender — hybrid app+pages (app-basic)", () => {
|
|
let manifestDir: string;
|
|
let results: PrerenderRouteResult[];
|
|
|
|
beforeAll(async () => {
|
|
const pagesBundlePath = await buildPagesFixture(APP_FIXTURE);
|
|
manifestDir = tmpDir("vinext-prerender-hybrid-");
|
|
|
|
// runPrerender writes files to real paths derived from root, but we
|
|
// override by calling prerenderPages/prerenderApp directly with a tmp
|
|
// manifestDir. Instead, call runPrerender which needs a real-looking root.
|
|
// We test it indirectly: call prerenderPages on app-basic's pages/ dir
|
|
// with a manifestDir so we can check hybrid manifest merging.
|
|
const { prerenderPages } = await import("../packages/vinext/src/build/prerender.js");
|
|
const { pagesRouter, apiRouter } =
|
|
await import("../packages/vinext/src/routing/pages-router.js");
|
|
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
|
|
|
|
const pagesDir = path.resolve(APP_FIXTURE, "pages");
|
|
const pageRoutes = await pagesRouter(pagesDir);
|
|
const apiRoutes = await apiRouter(pagesDir);
|
|
const config = await resolveNextConfig({});
|
|
|
|
const prerenderResult = await prerenderPages({
|
|
mode: "default",
|
|
pagesBundlePath,
|
|
routes: pageRoutes,
|
|
apiRoutes,
|
|
pagesDir,
|
|
outDir: manifestDir,
|
|
config,
|
|
});
|
|
results = prerenderResult.routes;
|
|
}, 60_000);
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(manifestDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("renders old-school static page from pages/ in app-basic fixture", () => {
|
|
const r = findRoute(results, "/old-school");
|
|
expect(r).toMatchObject({ route: "/old-school", status: "rendered", revalidate: false });
|
|
if (r?.status === "rendered") {
|
|
expect(r.outputFiles).toContain("old-school.html");
|
|
}
|
|
});
|
|
|
|
it("skips pages-header-override-delete (getServerSideProps) in default mode", () => {
|
|
const r = findRoute(results, "/pages-header-override-delete");
|
|
expect(r).toMatchObject({
|
|
route: "/pages-header-override-delete",
|
|
status: "skipped",
|
|
reason: "ssr",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("prerender — generateStaticParams/getStaticPaths errors (#1982)", () => {
|
|
// Ported from Next.js: test/production/app-dir/generate-static-params-errors/generate-static-params-errors.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/production/app-dir/generate-static-params-errors/generate-static-params-errors.test.ts
|
|
// Next.js surfaces the real generateStaticParams/getStaticPaths error and fails the build. vinext
|
|
// must not swallow the 500 returned by the static-params/static-paths endpoint into a misleading
|
|
// "stale or missing prerender secret" skip — the route must fail with the real error message.
|
|
//
|
|
// NOTE: these tests mock the prerender endpoint's HTTP response (the prod server here has no
|
|
// secret configured), so they exercise the build-side proxy's status branching, not the real
|
|
// app-prerender-endpoints.ts 500 path end-to-end. The endpoint's own behaviour (throw → 500 with
|
|
// `{ error }`) is covered by tests/app-prerender-endpoints.test.ts.
|
|
it("surfaces a thrown generateStaticParams error instead of silently skipping the route", async () => {
|
|
const root = tmpDir("vinext-prerender-gsp-error-");
|
|
const outDir = path.join(root, "out");
|
|
const appDir = path.join(root, "app");
|
|
const pageDir = path.join(appDir, "blog", "[slug]");
|
|
fs.mkdirSync(pageDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pageDir, "page.tsx"),
|
|
"export default function Page() { return null; }\n",
|
|
);
|
|
|
|
// The static-params endpoint returns 500 with the real error in the body when
|
|
// the user's generateStaticParams throws (app-prerender-endpoints.ts).
|
|
const server = createServer((req, res) => {
|
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
if (url.pathname === "/__vinext/prerender/static-params") {
|
|
res.statusCode = 500;
|
|
res.setHeader("content-type", "application/json");
|
|
res.end(JSON.stringify({ error: "Error: boom from generateStaticParams" }));
|
|
return;
|
|
}
|
|
res.setHeader("content-type", "text/html");
|
|
res.end(
|
|
"<html><body>" +
|
|
runtimeRscChunkScript(`0:["$","div",null,{}]\n`) +
|
|
runtimeRscDoneScript() +
|
|
"</body></html>",
|
|
);
|
|
});
|
|
|
|
const port = await listen(server);
|
|
try {
|
|
const { prerenderApp } = await import("../packages/vinext/src/build/prerender.js");
|
|
const { appRouter } = await import("../packages/vinext/src/routing/app-router.js");
|
|
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
|
|
const routes = await appRouter(appDir);
|
|
const config = await resolveNextConfig({});
|
|
|
|
const result = await prerenderApp({
|
|
mode: "default",
|
|
rscBundlePath: path.join(root, "dist", "server", "index.js"),
|
|
routes,
|
|
outDir,
|
|
config,
|
|
_prodServer: { server, port },
|
|
});
|
|
|
|
const route = result.routes.find((r) => r.route.includes("slug"));
|
|
// `fatal: true` makes run-prerender fail the build in default mode too,
|
|
// matching Next.js (not just a visible-but-non-fatal error). #1982
|
|
expect(route).toMatchObject({ status: "error", fatal: true });
|
|
if (route?.status !== "error") {
|
|
throw new Error("expected the throwing generateStaticParams route to fail prerender");
|
|
}
|
|
expect(route.error).toContain("boom from generateStaticParams");
|
|
} finally {
|
|
await closeServer(server);
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("still warn-skips (does not fail) when the static-params endpoint 404s (disabled/stale secret)", async () => {
|
|
const root = tmpDir("vinext-prerender-gsp-secret-");
|
|
const outDir = path.join(root, "out");
|
|
const appDir = path.join(root, "app");
|
|
const pageDir = path.join(appDir, "blog", "[slug]");
|
|
fs.mkdirSync(pageDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pageDir, "page.tsx"),
|
|
"export default function Page() { return null; }\n",
|
|
);
|
|
|
|
// A 404 models the genuine disabled / stale-secret case (notFoundResponse),
|
|
// which must keep the warn-and-skip behavior rather than failing the build.
|
|
const server = createServer((req, res) => {
|
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
if (url.pathname === "/__vinext/prerender/static-params") {
|
|
res.statusCode = 404;
|
|
res.end("not found");
|
|
return;
|
|
}
|
|
res.setHeader("content-type", "text/html");
|
|
res.end(
|
|
"<html><body>" +
|
|
runtimeRscChunkScript(`0:["$","div",null,{}]\n`) +
|
|
runtimeRscDoneScript() +
|
|
"</body></html>",
|
|
);
|
|
});
|
|
|
|
const port = await listen(server);
|
|
try {
|
|
const { prerenderApp } = await import("../packages/vinext/src/build/prerender.js");
|
|
const { appRouter } = await import("../packages/vinext/src/routing/app-router.js");
|
|
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
|
|
const routes = await appRouter(appDir);
|
|
const config = await resolveNextConfig({});
|
|
|
|
const result = await prerenderApp({
|
|
mode: "default",
|
|
rscBundlePath: path.join(root, "dist", "server", "index.js"),
|
|
routes,
|
|
outDir,
|
|
config,
|
|
_prodServer: { server, port },
|
|
});
|
|
|
|
const route = result.routes.find((r) => r.route.includes("slug"));
|
|
expect(route).toMatchObject({ status: "skipped", reason: "no-static-params" });
|
|
} finally {
|
|
await closeServer(server);
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("surfaces a thrown getStaticPaths error instead of silently skipping the route", async () => {
|
|
const root = tmpDir("vinext-prerender-pages-gsp-error-");
|
|
const outDir = path.join(root, "out");
|
|
const pagesDir = path.join(root, "pages");
|
|
fs.mkdirSync(path.join(pagesDir, "posts"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pagesDir, "posts", "[id].tsx"),
|
|
"export default function Post() { return null; }\n",
|
|
);
|
|
|
|
const server = createServer((req, res) => {
|
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
if (url.pathname === "/__vinext/prerender/pages-static-paths") {
|
|
res.statusCode = 500;
|
|
res.setHeader("content-type", "application/json");
|
|
res.end(JSON.stringify({ error: "Error: boom from getStaticPaths" }));
|
|
return;
|
|
}
|
|
res.setHeader("content-type", "text/html");
|
|
res.end("<html><body>ok</body></html>");
|
|
});
|
|
|
|
const port = await listen(server);
|
|
try {
|
|
const { prerenderPages } = await import("../packages/vinext/src/build/prerender.js");
|
|
const { pagesRouter, apiRouter } =
|
|
await import("../packages/vinext/src/routing/pages-router.js");
|
|
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
|
|
const routes = await pagesRouter(pagesDir);
|
|
const apiRoutes = await apiRouter(pagesDir);
|
|
const config = await resolveNextConfig({});
|
|
|
|
const result = await prerenderPages({
|
|
mode: "default",
|
|
routes,
|
|
apiRoutes,
|
|
pagesDir,
|
|
outDir,
|
|
config,
|
|
_prodServer: { server, port },
|
|
});
|
|
|
|
const route = result.routes.find((r) => r.route.includes("posts"));
|
|
// `fatal: true` makes run-prerender fail the build in default mode too. #1982
|
|
expect(route).toMatchObject({ status: "error", fatal: true });
|
|
if (route?.status !== "error") {
|
|
throw new Error("expected the throwing getStaticPaths route to fail prerender");
|
|
}
|
|
expect(route.error).toContain("boom from getStaticPaths");
|
|
} finally {
|
|
await closeServer(server);
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("prerenderApp — cacheComponents PPR fallback-shell artifacts", () => {
|
|
async function prerenderDynamicRootParamRoute(
|
|
cacheComponents: boolean,
|
|
experimentalFallbackShells = false,
|
|
) {
|
|
const root = tmpDir("vinext-prerender-ppr-shell-");
|
|
const outDir = path.join(root, "out");
|
|
const appDir = path.join(root, "app");
|
|
const pageDir = path.join(appDir, "[locale]", "blog", "[slug]");
|
|
fs.mkdirSync(pageDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(appDir, "[locale]", "layout.tsx"),
|
|
"export default function Layout({ children }: { children: React.ReactNode }) { return children; }\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(pageDir, "page.tsx"),
|
|
"export default function Page() { return null; }\n",
|
|
);
|
|
|
|
const renderedPaths: string[] = [];
|
|
const server = createServer((req, res) => {
|
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
if (url.pathname === "/__vinext/prerender/static-params") {
|
|
res.setHeader("content-type", "application/json");
|
|
res.end(JSON.stringify([{ locale: "en", slug: "hello" }]));
|
|
return;
|
|
}
|
|
if (url.pathname === "/__vinext_nonexistent_for_404__") {
|
|
res.statusCode = 404;
|
|
res.end("<html><body>not found</body></html>");
|
|
return;
|
|
}
|
|
|
|
renderedPaths.push(url.pathname);
|
|
res.setHeader("content-type", "text/html");
|
|
res.end(
|
|
"<html><body>" +
|
|
runtimeRscChunkScript(
|
|
`0:["$","div",null,{"children":${JSON.stringify(url.pathname)}}]\n`,
|
|
) +
|
|
runtimeRscDoneScript() +
|
|
"</body></html>",
|
|
);
|
|
});
|
|
|
|
const port = await listen(server);
|
|
try {
|
|
const { prerenderApp } = await import("../packages/vinext/src/build/prerender.js");
|
|
const { appRouter } = await import("../packages/vinext/src/routing/app-router.js");
|
|
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
|
|
const routes = await appRouter(appDir);
|
|
const config = await resolveNextConfig({ cacheComponents });
|
|
|
|
const previousExperimentalFallbackShells =
|
|
process.env.__VINEXT_EXPERIMENTAL_PPR_FALLBACK_SHELLS;
|
|
if (experimentalFallbackShells) {
|
|
process.env.__VINEXT_EXPERIMENTAL_PPR_FALLBACK_SHELLS = "1";
|
|
} else {
|
|
delete process.env.__VINEXT_EXPERIMENTAL_PPR_FALLBACK_SHELLS;
|
|
}
|
|
|
|
let result;
|
|
try {
|
|
result = await prerenderApp({
|
|
mode: "default",
|
|
rscBundlePath: path.join(root, "dist", "server", "index.js"),
|
|
routes,
|
|
outDir,
|
|
config,
|
|
_prodServer: { server, port },
|
|
});
|
|
} finally {
|
|
if (previousExperimentalFallbackShells === undefined) {
|
|
delete process.env.__VINEXT_EXPERIMENTAL_PPR_FALLBACK_SHELLS;
|
|
} else {
|
|
process.env.__VINEXT_EXPERIMENTAL_PPR_FALLBACK_SHELLS =
|
|
previousExperimentalFallbackShells;
|
|
}
|
|
}
|
|
|
|
const fallbackHtmlPath = path.join(outDir, "en", "blog", "[slug].html");
|
|
const fallbackHtml = fs.existsSync(fallbackHtmlPath)
|
|
? fs.readFileSync(fallbackHtmlPath, "utf8")
|
|
: null;
|
|
|
|
return { fallbackHtml, renderedPaths, routes: result.routes };
|
|
} finally {
|
|
await closeServer(server);
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
it("does not queue incomplete fallback-shell artifacts by default", async () => {
|
|
const { renderedPaths, routes } = await prerenderDynamicRootParamRoute(true);
|
|
|
|
expect(findRoute(routes, "/en/blog/hello")).toMatchObject({
|
|
route: "/:locale/blog/:slug",
|
|
path: "/en/blog/hello",
|
|
status: "rendered",
|
|
});
|
|
expect(findRoute(routes, "/en/blog/[slug]")).toBeUndefined();
|
|
expect(renderedPaths).toContain("/en/blog/hello");
|
|
expect(renderedPaths).not.toContain("/en/blog/[slug]");
|
|
});
|
|
|
|
it("queues fallback-shell artifacts only with the internal opt-in", async () => {
|
|
const { fallbackHtml, renderedPaths, routes } = await prerenderDynamicRootParamRoute(
|
|
true,
|
|
true,
|
|
);
|
|
|
|
expect(findRoute(routes, "/en/blog/hello")).toMatchObject({
|
|
route: "/:locale/blog/:slug",
|
|
path: "/en/blog/hello",
|
|
status: "rendered",
|
|
});
|
|
expect(findRoute(routes, "/en/blog/[slug]")).toMatchObject({
|
|
route: "/:locale/blog/:slug",
|
|
path: "/en/blog/[slug]",
|
|
status: "rendered",
|
|
fallback: true,
|
|
});
|
|
expect(renderedPaths).toEqual(expect.arrayContaining(["/en/blog/hello", "/en/blog/[slug]"]));
|
|
expect(fallbackHtml).toContain("<!--vinext-ppr-dynamic-fallback-shell-->");
|
|
});
|
|
|
|
it("does not queue fallback-shell artifacts when cacheComponents is disabled", async () => {
|
|
const { renderedPaths, routes } = await prerenderDynamicRootParamRoute(false);
|
|
|
|
expect(findRoute(routes, "/en/blog/hello")).toMatchObject({
|
|
route: "/:locale/blog/:slug",
|
|
path: "/en/blog/hello",
|
|
status: "rendered",
|
|
});
|
|
expect(findRoute(routes, "/en/blog/[slug]")).toBeUndefined();
|
|
expect(renderedPaths).toContain("/en/blog/hello");
|
|
expect(renderedPaths).not.toContain("/en/blog/[slug]");
|
|
});
|
|
});
|
|
|
|
// ─── runPrerender — output: 'export' wiring ───────────────────────────────────
|
|
|
|
describe("runPrerender — output: 'export' wiring", () => {
|
|
let pagesBundlePath: string;
|
|
let exportNextConfig: Awaited<
|
|
ReturnType<typeof import("../packages/vinext/src/config/next-config.js").resolveNextConfig>
|
|
>;
|
|
|
|
beforeAll(async () => {
|
|
// Build pages-basic to a fresh tmpdir — no fixture copying needed.
|
|
// Pass the bundle path and resolved config to runPrerender so it
|
|
// exercises output: 'export' without touching the real next.config.mjs.
|
|
pagesBundlePath = await buildPagesFixture(PAGES_FIXTURE);
|
|
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
|
|
exportNextConfig = await resolveNextConfig({ output: "export" }, PAGES_FIXTURE);
|
|
}, 120_000);
|
|
|
|
it("throws when next.config output: 'export' and SSR routes exist", async () => {
|
|
const { runPrerender } = await import("../packages/vinext/src/build/run-prerender.js");
|
|
await expect(
|
|
runPrerender({
|
|
root: PAGES_FIXTURE,
|
|
nextConfig: exportNextConfig,
|
|
pagesBundlePath,
|
|
}),
|
|
).rejects.toThrow(/Static export failed/);
|
|
});
|
|
|
|
it("does not reload disk config when the caller supplies resolved config", async () => {
|
|
const configPath = path.join(PAGES_FIXTURE, "next.config.mjs");
|
|
const originalConfig = fs.readFileSync(configPath, "utf-8");
|
|
|
|
try {
|
|
fs.writeFileSync(configPath, 'throw new Error("disk config loaded unexpectedly");\n');
|
|
const [{ runPrerender }, { resolveNextConfig }] = await Promise.all([
|
|
import("../packages/vinext/src/build/run-prerender.js"),
|
|
import("../packages/vinext/src/config/next-config.js"),
|
|
]);
|
|
const nextConfig = await resolveNextConfig({ output: "export" }, PAGES_FIXTURE);
|
|
|
|
await expect(
|
|
runPrerender({
|
|
root: PAGES_FIXTURE,
|
|
nextConfig,
|
|
pagesBundlePath,
|
|
}),
|
|
).rejects.toThrow(/Static export failed/);
|
|
} finally {
|
|
fs.writeFileSync(configPath, originalConfig);
|
|
}
|
|
});
|
|
|
|
it("does not rewrite the Worker entry when prerender validation fails", async () => {
|
|
const workerEntry = path.join(PAGES_FIXTURE, "dist", "server", "index.js");
|
|
const source = 'export default { fetch() { return new Response("unchanged"); } };\n';
|
|
fs.mkdirSync(path.dirname(workerEntry), { recursive: true });
|
|
fs.writeFileSync(workerEntry, source, "utf-8");
|
|
|
|
try {
|
|
const { runPrerender } = await import("../packages/vinext/src/build/run-prerender.js");
|
|
await expect(
|
|
runPrerender({
|
|
root: PAGES_FIXTURE,
|
|
nextConfig: exportNextConfig,
|
|
pagesBundlePath,
|
|
}),
|
|
).rejects.toThrow(/Static export failed/);
|
|
|
|
expect(fs.readFileSync(workerEntry, "utf-8")).toBe(source);
|
|
} finally {
|
|
fs.rmSync(path.join(PAGES_FIXTURE, "dist"), { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("error message names the offending SSR route", async () => {
|
|
const { runPrerender } = await import("../packages/vinext/src/build/run-prerender.js");
|
|
await expect(
|
|
runPrerender({
|
|
root: PAGES_FIXTURE,
|
|
nextConfig: exportNextConfig,
|
|
pagesBundlePath,
|
|
}),
|
|
).rejects.toThrow(/\/ssr/);
|
|
});
|
|
});
|
|
|
|
// ─── run-prerender fatal-route gate (#1982) ───────────────────────────────────
|
|
|
|
describe("assertNoFatalPrerenderRoutes (#1982)", () => {
|
|
it("throws (fails the build in default mode) when a route is flagged fatal", async () => {
|
|
const { assertNoFatalPrerenderRoutes } =
|
|
await import("../packages/vinext/src/build/run-prerender.js");
|
|
expect(() =>
|
|
assertNoFatalPrerenderRoutes([
|
|
{
|
|
route: "/blog/:slug",
|
|
status: "error",
|
|
error: "Failed to call generateStaticParams(): boom",
|
|
fatal: true,
|
|
},
|
|
]),
|
|
).toThrow(/Prerender failed/);
|
|
});
|
|
|
|
it("does not throw for non-fatal errors or skips (default-mode leniency preserved)", async () => {
|
|
const { assertNoFatalPrerenderRoutes } =
|
|
await import("../packages/vinext/src/build/run-prerender.js");
|
|
// A skipped SSR route and a non-fatal error (e.g. a transport failure) must
|
|
// NOT fail the default build — only fatal user-function throws do.
|
|
expect(() =>
|
|
assertNoFatalPrerenderRoutes([
|
|
{ route: "/ssr", status: "skipped", reason: "ssr" },
|
|
{ route: "/render-fail", status: "error", error: "ECONNREFUSED" },
|
|
]),
|
|
).not.toThrow();
|
|
});
|
|
});
|
|
|
|
// ─── App Router — Cloudflare Workers build ────────────────────────────────────
|
|
//
|
|
// Verifies that prerenderApp() works correctly when the production bundle is a
|
|
// Cloudflare Workers build (dist/server/index.js). Prerendering goes through a
|
|
// locally-spawned prod server over HTTP — same path as plain Node builds.
|
|
|
|
// ─── Cloudflare Workers hybrid build (app/ + pages/) ─────────────────────────
|
|
//
|
|
// Verifies that both prerenderApp() and prerenderPages() work correctly when
|
|
// the build is a Cloudflare Workers bundle. Both phases render via HTTP through
|
|
// a shared local prod server started by runPrerender().
|
|
|
|
describe("Cloudflare Workers hybrid build (cf-app-basic)", () => {
|
|
let outDir: string;
|
|
let allResults: PrerenderRouteResult[];
|
|
|
|
beforeAll(async () => {
|
|
const { root, rscBundlePath } = await buildCloudflareAppFixture(CF_FIXTURE);
|
|
outDir = path.join(root, "dist", "server", "prerendered-routes");
|
|
|
|
const { runPrerender } = await import("../packages/vinext/src/build/run-prerender.js");
|
|
|
|
const result = await runPrerender({ root, rscBundlePath });
|
|
allResults = result?.routes ?? [];
|
|
}, 180_000);
|
|
|
|
// ── App Router ──────────────────────────────────────────────────────────────
|
|
|
|
describe("prerenderApp — app router via prod server HTTP", () => {
|
|
it("renders / speculatively", () => {
|
|
const r = findRoute(allResults, "/");
|
|
expect(r).toMatchObject({ route: "/", status: "rendered", revalidate: false });
|
|
if (r?.status === "rendered") {
|
|
expect(r.outputFiles).toContain("index.html");
|
|
expect(r.outputFiles).toContain("index.rsc");
|
|
}
|
|
});
|
|
|
|
it("renders /about speculatively", () => {
|
|
const r = findRoute(allResults, "/about");
|
|
expect(r).toMatchObject({ route: "/about", status: "rendered", revalidate: false });
|
|
if (r?.status === "rendered") {
|
|
expect(r.outputFiles).toContain("about.html");
|
|
}
|
|
});
|
|
|
|
it("renders /blog/[slug] expanded from generateStaticParams", () => {
|
|
for (const slug of ["hello-world", "getting-started"]) {
|
|
const r = findRoute(allResults, `/blog/${slug}`);
|
|
expect(r).toMatchObject({
|
|
route: "/blog/:slug",
|
|
path: `/blog/${slug}`,
|
|
status: "rendered",
|
|
revalidate: false,
|
|
});
|
|
if (r?.status === "rendered") {
|
|
expect(r.outputFiles).toContain(`blog/${slug}.html`);
|
|
expect(r.outputFiles).toContain(`blog/${slug}.rsc`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("skips API routes", () => {
|
|
const apiSkipped = allResults.filter(
|
|
(r) => r.status === "skipped" && "reason" in r && r.reason === "api",
|
|
);
|
|
expect(apiSkipped.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("writes HTML and RSC files to outDir", () => {
|
|
expect(fs.existsSync(path.join(outDir, "index.html"))).toBe(true);
|
|
expect(fs.existsSync(path.join(outDir, "index.rsc"))).toBe(true);
|
|
expect(fs.existsSync(path.join(outDir, "about.html"))).toBe(true);
|
|
expect(fs.existsSync(path.join(outDir, "blog/hello-world.html"))).toBe(true);
|
|
expect(fs.existsSync(path.join(outDir, "blog/hello-world.rsc"))).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── Pages Router ────────────────────────────────────────────────────────────
|
|
|
|
describe("prerenderPages — pages router via prod server HTTP", () => {
|
|
it("renders static Pages home", () => {
|
|
const r = findRoute(allResults, "/pages-home");
|
|
expect(r).toMatchObject({ route: "/pages-home", status: "rendered", revalidate: false });
|
|
if (r?.status === "rendered") {
|
|
expect(r.outputFiles).toContain("pages-home.html");
|
|
}
|
|
});
|
|
|
|
it("renders static Pages about", () => {
|
|
const r = findRoute(allResults, "/pages-about");
|
|
expect(r).toMatchObject({
|
|
route: "/pages-about",
|
|
status: "rendered",
|
|
revalidate: false,
|
|
});
|
|
if (r?.status === "rendered") {
|
|
expect(r.outputFiles).toContain("pages-about.html");
|
|
}
|
|
});
|
|
|
|
it("renders /posts/[id] expanded from getStaticPaths", () => {
|
|
for (const id of ["first", "second"]) {
|
|
const r = findRoute(allResults, `/posts/${id}`);
|
|
expect(r).toMatchObject({
|
|
route: "/posts/:id",
|
|
path: `/posts/${id}`,
|
|
status: "rendered",
|
|
revalidate: false,
|
|
});
|
|
if (r?.status === "rendered") {
|
|
expect(r.outputFiles).toContain(`posts/${id}.html`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("skips API routes", () => {
|
|
const apiSkipped = allResults.filter(
|
|
(r) => r.status === "skipped" && "reason" in r && r.reason === "api",
|
|
);
|
|
expect(apiSkipped.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("writes HTML files to outDir", () => {
|
|
expect(fs.existsSync(path.join(outDir, "posts/first.html"))).toBe(true);
|
|
expect(fs.existsSync(path.join(outDir, "posts/second.html"))).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── resolveParentParams unit tests ─────────────────────────────────────────
|
|
|
|
function mockRoute(pattern: string, opts: { pagePath?: string | null } = {}): AppRoute {
|
|
const parts = pattern.split("/").filter(Boolean);
|
|
return {
|
|
pattern,
|
|
pagePath: opts.pagePath ?? `/app${pattern}/page.tsx`,
|
|
routePath: null,
|
|
layouts: [],
|
|
templates: [],
|
|
parallelSlots: [],
|
|
loadingPath: null,
|
|
errorPath: null,
|
|
layoutErrorPaths: [],
|
|
notFoundPath: null,
|
|
notFoundPaths: [],
|
|
forbiddenPaths: [],
|
|
forbiddenPath: null,
|
|
unauthorizedPaths: [],
|
|
unauthorizedPath: null,
|
|
routeSegments: [],
|
|
layoutTreePositions: [],
|
|
isDynamic: parts.some((p) => p.startsWith(":")),
|
|
params: parts
|
|
.filter((p) => p.startsWith(":"))
|
|
.map((p) => p.replace(/^:/, "").replace(/[+*]$/, "")),
|
|
patternParts: parts,
|
|
siblingIntercepts: [],
|
|
};
|
|
}
|
|
|
|
describe("resolveParentParams", () => {
|
|
it("returns empty array when route has no parent dynamic segments", async () => {
|
|
const route = mockRoute("/blog/:slug");
|
|
const result = await resolveParentParams(route, {});
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("returns empty array when no parent generateStaticParams is registered", async () => {
|
|
const child = mockRoute("/shop/:category/:item");
|
|
const result = await resolveParentParams(child, {});
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("resolves layout-level parent generateStaticParams without requiring a parent page", async () => {
|
|
// Ported from Next.js: test/e2e/app-dir/app-root-params-getters/generate-static-params.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-root-params-getters/generate-static-params.test.ts
|
|
const child = mockRoute("/:lang/:locale/other/:slug");
|
|
const staticParamsMap: StaticParamsMap = {
|
|
"/:lang/:locale": async () => [
|
|
{ lang: "en", locale: "us" },
|
|
{ lang: "es", locale: "es" },
|
|
],
|
|
};
|
|
|
|
const result = await resolveParentParams(child, staticParamsMap);
|
|
|
|
expect(result).toEqual([
|
|
{ lang: "en", locale: "us" },
|
|
{ lang: "es", locale: "es" },
|
|
]);
|
|
});
|
|
|
|
it("returns empty array when parent has no generateStaticParams", async () => {
|
|
const child = mockRoute("/shop/:category/:item");
|
|
const staticParamsMap: StaticParamsMap = {};
|
|
const result = await resolveParentParams(child, staticParamsMap);
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("skips missing parent providers but bails on malformed non-array results", async () => {
|
|
const child = mockRoute("/shop/:category/:item/:slug");
|
|
const calls: Record<string, string | string[]>[] = [];
|
|
const itemGenerateStaticParams = async ({
|
|
params,
|
|
}: {
|
|
params: Record<string, string | string[]>;
|
|
}) => {
|
|
calls.push(params);
|
|
return [{ item: "shoes" }];
|
|
};
|
|
const staticParamsMap: StaticParamsMap = {
|
|
"/shop/:category": async () => null,
|
|
"/shop/:category/:item": itemGenerateStaticParams,
|
|
};
|
|
|
|
const missingProviderResult = await resolveParentParams(child, staticParamsMap);
|
|
|
|
expect(missingProviderResult).toEqual([{ item: "shoes" }]);
|
|
expect(calls).toEqual([{}]);
|
|
|
|
calls.length = 0;
|
|
const malformedProviderResult = await resolveParentParams(child, {
|
|
"/shop/:category": async () => undefined,
|
|
"/shop/:category/:item": itemGenerateStaticParams,
|
|
});
|
|
|
|
expect(malformedProviderResult).toEqual([]);
|
|
expect(calls).toEqual([]);
|
|
});
|
|
|
|
it("resolves single parent dynamic segment", async () => {
|
|
const child = mockRoute("/shop/:category/:item");
|
|
const staticParamsMap: StaticParamsMap = {
|
|
"/shop/:category": async () => [{ category: "electronics" }, { category: "clothing" }],
|
|
};
|
|
const result = await resolveParentParams(child, staticParamsMap);
|
|
expect(result).toEqual([{ category: "electronics" }, { category: "clothing" }]);
|
|
});
|
|
|
|
it("can include a provider for the last dynamic segment", async () => {
|
|
const child = mockRoute("/:category/foo");
|
|
const staticParamsMap: StaticParamsMap = {
|
|
"/:category": async () => [{ category: "news" }],
|
|
};
|
|
|
|
await expect(
|
|
resolveParentParams(child, staticParamsMap, { includeLastDynamicSegment: true }),
|
|
).resolves.toEqual([{ category: "news" }]);
|
|
});
|
|
|
|
it("resolves two levels of parent dynamic segments", async () => {
|
|
const child = mockRoute("/a/:b/c/:d/:e");
|
|
const staticParamsMap: StaticParamsMap = {
|
|
"/a/:b": async () => [{ b: "1" }, { b: "2" }],
|
|
"/a/:b/c/:d": async ({ params }) => {
|
|
if (params.b === "1") return [{ d: "x" }];
|
|
return [{ d: "y" }, { d: "z" }];
|
|
},
|
|
};
|
|
const result = await resolveParentParams(child, staticParamsMap);
|
|
expect(result).toEqual([
|
|
{ b: "1", d: "x" },
|
|
{ b: "2", d: "y" },
|
|
{ b: "2", d: "z" },
|
|
]);
|
|
});
|
|
|
|
it("skips static segments between dynamic parents", async () => {
|
|
const child = mockRoute("/shop/:category/details/:item");
|
|
const staticParamsMap: StaticParamsMap = {
|
|
"/shop/:category": async () => [{ category: "shoes" }],
|
|
};
|
|
const result = await resolveParentParams(child, staticParamsMap);
|
|
expect(result).toEqual([{ category: "shoes" }]);
|
|
});
|
|
|
|
it("returns empty array for a fully static route", async () => {
|
|
const route = mockRoute("/about/contact");
|
|
const result = await resolveParentParams(route, {});
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("returns empty array for a single-segment dynamic route", async () => {
|
|
const route = mockRoute("/:id");
|
|
const result = await resolveParentParams(route, {});
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("resolves parent with catch-all child segment", async () => {
|
|
const child = mockRoute("/shop/:category/:rest+");
|
|
const staticParamsMap: StaticParamsMap = {
|
|
"/shop/:category": async () => [{ category: "electronics" }],
|
|
};
|
|
const result = await resolveParentParams(child, staticParamsMap);
|
|
expect(result).toEqual([{ category: "electronics" }]);
|
|
});
|
|
});
|