mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
930e03c449
* fix(app-router): prerender cacheComponents root-param fallback shells PR 1 of 4: core model + build integration. Model: app-ppr-fallback-shell, pregenerated-concrete-paths, prerender-manifest Build: prerender/run-prerender fallback shell artifact generation Tests: createAppPprFallbackShells, pregenerated-concrete-paths core * feat(ppr): add fallback shell payload identity * feat(ppr): safely serve fallback shell cache entries * refactor(ppr): extract fallback shell render pipeline and dedupe getViteMajorVersion * fix(ppr): restore safe-serving CI checks The safe-serving branch failed CI because a client-side server-only guard rejected valid 'use server' action modules, a fallback-shell dependency cast no longer matched its helper type, apps/web lost the Cloudflare cache adapter source path during typecheck, and Knip could not resolve the documented Cloudflare cache adapter subpaths. The server-only guard now reads the directive prologue and skips top-level 'use server' modules, fallback-shell regeneration passes an explicit typed dependency object, apps/web gets the workspace Cloudflare cache path, the Cloudflare package exports explicit adapter subpaths, Knip uses a relative source import in the dispatch unit test, and the readiness test awaits the actual cache-ready promise. * fix(ppr): guard fallback shell reads for queries * fix(ppr): normalize pregenerated concrete paths * fix(ppr): address review comments on fallback shell serving and react runtime check * fix(ci): restore knip ignores for unlisted binaries and prune dependencies * fix(ppr): restrict bracket check in fallback detection to legacy manifests * fix(ppr): prefer stable ESM react-dom/static.edge before CJS fallback The loadStaticPrerender dev-mode path hard-codes react-dom's internal CJS file layout (cjs/react-dom-server.edge.development.js). A React upgrade could silently break fallback-shell rendering in dev without affecting production because the function never tried the stable ESM entry in development. Change the order so that react-dom/static.edge is attempted first in all environments. Only fall back to the CJS path when the ESM entry does not export prerender() and we are in a development runtime. This lets future React dev builds that expose prerender() from the ESM entry work automatically, and it surfaces the CJS-path failure only when the stable path is genuinely unavailable. Also handle the CJS default-export interop shape that Node.js ESM produces when dynamically importing a CommonJS module. * refactor(ppr): address four review cleanup items 1. Remove redundant double-normalization in seed-cache.ts addPregeneratedConcretePath already normalizes internally, so the call-site normalizePregeneratedPathname() was redundant. Dropping it gives a single source of truth for pathname normalization. 2. Lazily construct FallbackShellRenderDeps in tryServePprFallbackShell Extract the large FallbackShellRenderDeps object into a top-level buildFallbackShellRenderDeps helper. The closure in the probe loop now just delegates, so the heavy object is only created when a STALE entry actually triggers regeneration, not on every HIT probe. 3. Add dev-only warning for legacy bracket heuristic When isFallbackShellArtifactPath falls back to the bracket substring scan (route.fallback === undefined), emit a console.warn in dev so developers know a legacy manifest is being used and concrete URLs with literal brackets may be misclassified. 4. Update pregenerated-concrete-paths.test.ts to colon notation The production manifest writer uses colon notation (/:slug) for the route field, but the test fixtures used bracket notation (/[slug]). Update the fixtures so the test format matches production, exercising the real key format that the safe-serving guard depends on. * test(ppr): compact fallback shell dispatch fixtures * refactor(ppr): name fallback shell render phases * refactor(app-router): deduplicate PPR fallback shell and app page cache render paths * fix(ppr): avoid serving incomplete fallback shells * chore: drop redundant cloudflare cache aliases * fix(ppr): track fallback navigation hooks * test(ppr): render navigation hook probes * test(ppr): type navigation hook probes * refactor(ppr): remove unused shell regeneration path * refactor(ppr): remove unused shell result type * fix(ppr): safely gate fallback shell prerendering * fix(ppr): keep fallback shell gate internal * fix(ppr): mark generated dynamic fallback shells * test(ppr): align fallback fixture and search params --------- Co-authored-by: James <james@eli.cx>
790 lines
25 KiB
TypeScript
790 lines
25 KiB
TypeScript
/**
|
|
* Tests for seeding the memory cache from pre-rendered routes.
|
|
*
|
|
* Verifies that seedMemoryCacheFromPrerender() reads vinext-prerender.json
|
|
* and the corresponding HTML/RSC files from disk, then populates the
|
|
* CacheHandler so pre-rendered pages are served as cache HITs on first request.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from "vite-plus/test";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import os from "node:os";
|
|
import {
|
|
MemoryCacheHandler,
|
|
setCacheHandler,
|
|
getCacheHandler,
|
|
revalidatePath,
|
|
type CacheHandler,
|
|
type CacheHandlerValue,
|
|
type IncrementalCacheValue,
|
|
} from "../packages/vinext/src/shims/cache.js";
|
|
import { isrCacheKey, getRevalidateDuration } from "../packages/vinext/src/server/isr-cache.js";
|
|
import { getRenderedConcreteUrlPathsForRoute } from "../packages/vinext/src/server/pregenerated-concrete-paths.js";
|
|
import { seedMemoryCacheFromPrerender } from "../packages/vinext/src/server/seed-cache.js";
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function createTempServerDir(): string {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), "vinext-seed-cache-"));
|
|
}
|
|
|
|
/**
|
|
* Write a vinext-prerender.json manifest and corresponding pre-rendered files
|
|
* to a temporary directory structure matching the production build layout.
|
|
*/
|
|
function setupPrerenderFixture(
|
|
serverDir: string,
|
|
manifest: { buildId: string; trailingSlash?: boolean; routes: unknown[] },
|
|
files: Record<string, string>,
|
|
): void {
|
|
fs.writeFileSync(
|
|
path.join(serverDir, "vinext-prerender.json"),
|
|
JSON.stringify(manifest, null, 2),
|
|
"utf-8",
|
|
);
|
|
|
|
const prerenderDir = path.join(serverDir, "prerendered-routes");
|
|
for (const [filePath, content] of Object.entries(files)) {
|
|
const fullPath = path.join(prerenderDir, filePath);
|
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
fs.writeFileSync(fullPath, content, "utf-8");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write raw content to vinext-prerender.json (for corrupt manifest tests).
|
|
*/
|
|
function writeRawManifest(serverDir: string, content: string): void {
|
|
fs.writeFileSync(path.join(serverDir, "vinext-prerender.json"), content, "utf-8");
|
|
}
|
|
|
|
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
describe("seedMemoryCacheFromPrerender", () => {
|
|
let serverDir: string;
|
|
|
|
beforeEach(() => {
|
|
serverDir = createTempServerDir();
|
|
setCacheHandler(new MemoryCacheHandler());
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(serverDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// ── App Router ISR routes ─────────────────────────────────────────────────
|
|
|
|
it("seeds App Router ISR routes with HTML and RSC entries", async () => {
|
|
const buildId = "test-build-001";
|
|
setupPrerenderFixture(
|
|
serverDir,
|
|
{
|
|
buildId,
|
|
routes: [{ route: "/about", status: "rendered", revalidate: 60, router: "app" }],
|
|
},
|
|
{
|
|
"about.html": "<html><body>About page</body></html>",
|
|
"about.rsc": "RSC payload for about",
|
|
},
|
|
);
|
|
|
|
await seedMemoryCacheFromPrerender(serverDir);
|
|
|
|
const htmlKey = isrCacheKey("app", "/about", buildId) + ":html";
|
|
const htmlEntry = await getCacheHandler().get(htmlKey);
|
|
expect(htmlEntry).not.toBeNull();
|
|
const htmlValue = htmlEntry?.value;
|
|
expect(htmlValue).not.toBeNull();
|
|
expect(htmlValue?.kind).toBe("APP_PAGE");
|
|
if (htmlValue?.kind === "APP_PAGE") {
|
|
expect(htmlValue.html).toBe("<html><body>About page</body></html>");
|
|
}
|
|
|
|
const rscKey = isrCacheKey("app", "/about", buildId) + ":rsc";
|
|
const rscEntry = await getCacheHandler().get(rscKey);
|
|
expect(rscEntry).not.toBeNull();
|
|
const rscValue = rscEntry?.value;
|
|
expect(rscValue).not.toBeNull();
|
|
expect(rscValue?.kind).toBe("APP_PAGE");
|
|
if (rscValue?.kind === "APP_PAGE") {
|
|
expect(rscValue.rscData).toBeDefined();
|
|
const rscText = new TextDecoder().decode(rscValue.rscData!);
|
|
expect(rscText).toBe("RSC payload for about");
|
|
}
|
|
});
|
|
|
|
it("seeds the index route correctly", async () => {
|
|
const buildId = "test-build-002";
|
|
setupPrerenderFixture(
|
|
serverDir,
|
|
{
|
|
buildId,
|
|
routes: [{ route: "/", status: "rendered", revalidate: 30, router: "app" }],
|
|
},
|
|
{
|
|
"index.html": "<html><body>Home</body></html>",
|
|
"index.rsc": "RSC payload for index",
|
|
},
|
|
);
|
|
|
|
await seedMemoryCacheFromPrerender(serverDir);
|
|
|
|
const htmlKey = isrCacheKey("app", "/", buildId) + ":html";
|
|
const htmlEntry = await getCacheHandler().get(htmlKey);
|
|
expect(htmlEntry).not.toBeNull();
|
|
expect(htmlEntry?.value?.kind).toBe("APP_PAGE");
|
|
});
|
|
|
|
it("seeds dynamic routes using their concrete path", async () => {
|
|
const buildId = "test-build-003";
|
|
setupPrerenderFixture(
|
|
serverDir,
|
|
{
|
|
buildId,
|
|
routes: [
|
|
{
|
|
route: "/blog/:slug",
|
|
status: "rendered",
|
|
revalidate: 120,
|
|
path: "/blog/hello-world",
|
|
router: "app",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"blog/hello-world.html": "<html><body>Blog post</body></html>",
|
|
"blog/hello-world.rsc": "RSC blog payload",
|
|
},
|
|
);
|
|
|
|
await seedMemoryCacheFromPrerender(serverDir);
|
|
|
|
const htmlKey = isrCacheKey("app", "/blog/hello-world", buildId) + ":html";
|
|
const htmlEntry = await getCacheHandler().get(htmlKey);
|
|
expect(htmlEntry).not.toBeNull();
|
|
expect(htmlEntry?.value?.kind).toBe("APP_PAGE");
|
|
});
|
|
|
|
it("seeds encoded dynamic App Router paths under the runtime-normalized cache key", async () => {
|
|
const buildId = "encoded-path-test";
|
|
setupPrerenderFixture(
|
|
serverDir,
|
|
{
|
|
buildId,
|
|
routes: [
|
|
{
|
|
route: "/:id",
|
|
status: "rendered",
|
|
revalidate: false,
|
|
path: "/sticks%20%26%20stones",
|
|
router: "app",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"sticks%20%26%20stones.html":
|
|
"<html><body>params.id is sticks%20%26%20stones</body></html>",
|
|
"sticks%20%26%20stones.rsc": "RSC encoded payload",
|
|
},
|
|
);
|
|
|
|
await seedMemoryCacheFromPrerender(serverDir);
|
|
|
|
const htmlKey = isrCacheKey("app", "/sticks & stones", buildId) + ":html";
|
|
const htmlEntry = await getCacheHandler().get(htmlKey);
|
|
expect(htmlEntry).not.toBeNull();
|
|
if (htmlEntry?.value?.kind === "APP_PAGE") {
|
|
expect(htmlEntry.value.html).toBe(
|
|
"<html><body>params.id is sticks%20%26%20stones</body></html>",
|
|
);
|
|
}
|
|
|
|
const staleEncodedKey = isrCacheKey("app", "/sticks%20%26%20stones", buildId) + ":html";
|
|
expect(await getCacheHandler().get(staleEncodedKey)).toBeNull();
|
|
});
|
|
|
|
// ── Return value ──────────────────────────────────────────────────────────
|
|
|
|
it("returns the number of seeded routes", async () => {
|
|
setupPrerenderFixture(
|
|
serverDir,
|
|
{
|
|
buildId: "count-test",
|
|
routes: [
|
|
{ route: "/a", status: "rendered", revalidate: 60, router: "app" },
|
|
{ route: "/b", status: "rendered", revalidate: 60, router: "app" },
|
|
{ route: "/c", status: "skipped", reason: "ssr" },
|
|
],
|
|
},
|
|
{
|
|
"a.html": "<html>A</html>",
|
|
"a.rsc": "RSC a",
|
|
"b.html": "<html>B</html>",
|
|
"b.rsc": "RSC b",
|
|
},
|
|
);
|
|
|
|
const count = await seedMemoryCacheFromPrerender(serverDir);
|
|
expect(count).toBe(2);
|
|
});
|
|
|
|
it("returns 0 when no manifest exists", async () => {
|
|
const count = await seedMemoryCacheFromPrerender(serverDir);
|
|
expect(count).toBe(0);
|
|
});
|
|
|
|
// ── Revalidate duration tracking ──────────────────────────────────────────
|
|
|
|
it("populates revalidate duration map for ISR routes", async () => {
|
|
const buildId = "reval-duration-test";
|
|
setupPrerenderFixture(
|
|
serverDir,
|
|
{
|
|
buildId,
|
|
routes: [{ route: "/isr", status: "rendered", revalidate: 45, router: "app" }],
|
|
},
|
|
{
|
|
"isr.html": "<html>ISR</html>",
|
|
"isr.rsc": "RSC isr",
|
|
},
|
|
);
|
|
|
|
await seedMemoryCacheFromPrerender(serverDir);
|
|
|
|
const baseKey = isrCacheKey("app", "/isr", buildId);
|
|
expect(getRevalidateDuration(baseKey + ":html")).toBe(45);
|
|
expect(getRevalidateDuration(baseKey + ":rsc")).toBe(45);
|
|
});
|
|
|
|
it("preserves legacy revalidate context while writing cache-control metadata", async () => {
|
|
const contexts: Record<string, unknown>[] = [];
|
|
const handler: CacheHandler = {
|
|
async get(): Promise<CacheHandlerValue | null> {
|
|
return null;
|
|
},
|
|
async set(
|
|
_key: string,
|
|
_data: IncrementalCacheValue | null,
|
|
ctx?: Record<string, unknown>,
|
|
): Promise<void> {
|
|
contexts.push(ctx ?? {});
|
|
},
|
|
async revalidateTag(): Promise<void> {
|
|
// not used by this test
|
|
},
|
|
};
|
|
setCacheHandler(handler);
|
|
|
|
setupPrerenderFixture(
|
|
serverDir,
|
|
{
|
|
buildId: "seed-context-test",
|
|
routes: [{ route: "/isr", status: "rendered", revalidate: 60, expire: 300, router: "app" }],
|
|
},
|
|
{
|
|
"isr.html": "<html>ISR</html>",
|
|
"isr.rsc": "RSC isr",
|
|
},
|
|
);
|
|
|
|
await seedMemoryCacheFromPrerender(serverDir);
|
|
|
|
// Path-derived implicit tags are attached so revalidatePath('/isr') can
|
|
// invalidate seeded entries — see #1486.
|
|
const expectedTags = [
|
|
"/isr",
|
|
"_N_T_/isr",
|
|
"_N_T_/layout",
|
|
"_N_T_/isr/layout",
|
|
"_N_T_/isr/page",
|
|
];
|
|
expect(contexts).toEqual([
|
|
{ cacheControl: { revalidate: 60, expire: 300 }, revalidate: 60, tags: expectedTags },
|
|
{ cacheControl: { revalidate: 60, expire: 300 }, revalidate: 60, tags: expectedTags },
|
|
]);
|
|
});
|
|
|
|
it("can write through an injected app page cache writer", async () => {
|
|
const writes: {
|
|
key: string;
|
|
metadata: { expireSeconds?: number; revalidateSeconds?: number; tags?: string[] };
|
|
valueKind: string;
|
|
}[] = [];
|
|
|
|
setupPrerenderFixture(
|
|
serverDir,
|
|
{
|
|
buildId: "seed-injected-writer-test",
|
|
routes: [{ route: "/isr", status: "rendered", revalidate: 60, expire: 300, router: "app" }],
|
|
},
|
|
{
|
|
"isr.html": "<html>ISR</html>",
|
|
"isr.rsc": "RSC isr",
|
|
},
|
|
);
|
|
|
|
await seedMemoryCacheFromPrerender(serverDir, {
|
|
async writeAppPageEntry(key, data, metadata): Promise<void> {
|
|
writes.push({ key, metadata, valueKind: data.kind });
|
|
},
|
|
});
|
|
|
|
const baseKey = isrCacheKey("app", "/isr", "seed-injected-writer-test");
|
|
// Path-derived implicit tags so revalidatePath('/isr') can invalidate
|
|
// these entries — see #1486.
|
|
const expectedTags = [
|
|
"/isr",
|
|
"_N_T_/isr",
|
|
"_N_T_/layout",
|
|
"_N_T_/isr/layout",
|
|
"_N_T_/isr/page",
|
|
];
|
|
expect(writes).toEqual([
|
|
{
|
|
key: baseKey + ":html",
|
|
metadata: { expireSeconds: 300, revalidateSeconds: 60, tags: expectedTags },
|
|
valueKind: "APP_PAGE",
|
|
},
|
|
{
|
|
key: baseKey + ":rsc",
|
|
metadata: { expireSeconds: 300, revalidateSeconds: 60, tags: expectedTags },
|
|
valueKind: "APP_PAGE",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("can use injected runtime app page cache key builders", async () => {
|
|
const keys: string[] = [];
|
|
|
|
setupPrerenderFixture(
|
|
serverDir,
|
|
{
|
|
buildId: "manifest-build-id",
|
|
routes: [{ route: "/isr", status: "rendered", revalidate: 60, router: "app" }],
|
|
},
|
|
{
|
|
"isr.html": "<html>ISR</html>",
|
|
"isr.rsc": "RSC isr",
|
|
},
|
|
);
|
|
|
|
await seedMemoryCacheFromPrerender(serverDir, {
|
|
buildAppPageHtmlKey(pathname) {
|
|
return `runtime-build:${pathname}:html`;
|
|
},
|
|
buildAppPageRscKey(pathname) {
|
|
return `runtime-build:${pathname}:rsc`;
|
|
},
|
|
async writeAppPageEntry(key): Promise<void> {
|
|
keys.push(key);
|
|
},
|
|
});
|
|
|
|
expect(keys).toEqual(["runtime-build:/isr:html", "runtime-build:/isr:rsc"]);
|
|
});
|
|
|
|
it("does not set revalidate duration for static routes", async () => {
|
|
const buildId = "static-duration-test";
|
|
setupPrerenderFixture(
|
|
serverDir,
|
|
{
|
|
buildId,
|
|
routes: [{ route: "/static", status: "rendered", revalidate: false, router: "app" }],
|
|
},
|
|
{
|
|
"static.html": "<html>Static</html>",
|
|
"static.rsc": "RSC static",
|
|
},
|
|
);
|
|
|
|
await seedMemoryCacheFromPrerender(serverDir);
|
|
|
|
const baseKey = isrCacheKey("app", "/static", buildId);
|
|
expect(getRevalidateDuration(baseKey + ":html")).toBeUndefined();
|
|
expect(getRevalidateDuration(baseKey + ":rsc")).toBeUndefined();
|
|
});
|
|
|
|
// ── revalidatePath invalidation of seeded entries (#1486) ─────────────────
|
|
|
|
it("revalidatePath invalidates seeded HTML and RSC entries", async () => {
|
|
const buildId = "revalidate-seeded-test";
|
|
setupPrerenderFixture(
|
|
serverDir,
|
|
{
|
|
buildId,
|
|
routes: [{ route: "/posts", status: "rendered", revalidate: 60, router: "app" }],
|
|
},
|
|
{
|
|
"posts.html": "<html>Posts (seeded)</html>",
|
|
"posts.rsc": "RSC posts seeded",
|
|
},
|
|
);
|
|
|
|
await seedMemoryCacheFromPrerender(serverDir);
|
|
|
|
const baseKey = isrCacheKey("app", "/posts", buildId);
|
|
const htmlKey = baseKey + ":html";
|
|
const rscKey = baseKey + ":rsc";
|
|
|
|
// Sanity: both seeded entries are present before revalidation.
|
|
expect(await getCacheHandler().get(htmlKey)).not.toBeNull();
|
|
expect(await getCacheHandler().get(rscKey)).not.toBeNull();
|
|
|
|
// revalidatePath should invalidate both seeded artifacts.
|
|
await revalidatePath("/posts");
|
|
|
|
expect(await getCacheHandler().get(htmlKey)).toBeNull();
|
|
expect(await getCacheHandler().get(rscKey)).toBeNull();
|
|
});
|
|
|
|
// ── Static routes (revalidate: false) ─────────────────────────────────────
|
|
|
|
it("seeds static routes (revalidate: false) with no expiry", async () => {
|
|
const buildId = "test-build-004";
|
|
setupPrerenderFixture(
|
|
serverDir,
|
|
{
|
|
buildId,
|
|
routes: [{ route: "/static", status: "rendered", revalidate: false, router: "app" }],
|
|
},
|
|
{
|
|
"static.html": "<html><body>Static page</body></html>",
|
|
"static.rsc": "RSC static payload",
|
|
},
|
|
);
|
|
|
|
await seedMemoryCacheFromPrerender(serverDir);
|
|
|
|
const htmlKey = isrCacheKey("app", "/static", buildId) + ":html";
|
|
const htmlEntry = await getCacheHandler().get(htmlKey);
|
|
expect(htmlEntry).not.toBeNull();
|
|
expect(htmlEntry?.cacheState).toBeUndefined();
|
|
});
|
|
|
|
// ── Skipped and errored routes ────────────────────────────────────────────
|
|
|
|
it("does not seed skipped routes", async () => {
|
|
const buildId = "test-build-005";
|
|
setupPrerenderFixture(
|
|
serverDir,
|
|
{
|
|
buildId,
|
|
routes: [
|
|
{ route: "/ssr-page", status: "skipped", reason: "ssr" },
|
|
{ route: "/about", status: "rendered", revalidate: 60, router: "app" },
|
|
],
|
|
},
|
|
{
|
|
"about.html": "<html><body>About</body></html>",
|
|
"about.rsc": "RSC about",
|
|
},
|
|
);
|
|
|
|
await seedMemoryCacheFromPrerender(serverDir);
|
|
|
|
const skippedKey = isrCacheKey("app", "/ssr-page", buildId) + ":html";
|
|
expect(await getCacheHandler().get(skippedKey)).toBeNull();
|
|
|
|
const aboutKey = isrCacheKey("app", "/about", buildId) + ":html";
|
|
expect(await getCacheHandler().get(aboutKey)).not.toBeNull();
|
|
});
|
|
|
|
it("does not seed errored routes", async () => {
|
|
const buildId = "test-build-006";
|
|
setupPrerenderFixture(
|
|
serverDir,
|
|
{
|
|
buildId,
|
|
routes: [{ route: "/broken", status: "error", error: "render failed" }],
|
|
},
|
|
{},
|
|
);
|
|
|
|
const count = await seedMemoryCacheFromPrerender(serverDir);
|
|
expect(count).toBe(0);
|
|
});
|
|
|
|
// ── Multiple routes ───────────────────────────────────────────────────────
|
|
|
|
it("seeds multiple routes in one pass", async () => {
|
|
const buildId = "test-build-007";
|
|
setupPrerenderFixture(
|
|
serverDir,
|
|
{
|
|
buildId,
|
|
routes: [
|
|
{ route: "/", status: "rendered", revalidate: 30, router: "app" },
|
|
{ route: "/about", status: "rendered", revalidate: 60, router: "app" },
|
|
{
|
|
route: "/blog/:slug",
|
|
status: "rendered",
|
|
revalidate: 120,
|
|
path: "/blog/post-1",
|
|
router: "app",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"index.html": "<html>Home</html>",
|
|
"index.rsc": "RSC home",
|
|
"about.html": "<html>About</html>",
|
|
"about.rsc": "RSC about",
|
|
"blog/post-1.html": "<html>Blog Post 1</html>",
|
|
"blog/post-1.rsc": "RSC blog 1",
|
|
},
|
|
);
|
|
|
|
const count = await seedMemoryCacheFromPrerender(serverDir);
|
|
expect(count).toBe(3);
|
|
|
|
for (const pathname of ["/", "/about", "/blog/post-1"]) {
|
|
const htmlKey = isrCacheKey("app", pathname, buildId) + ":html";
|
|
expect(
|
|
await getCacheHandler().get(htmlKey),
|
|
`expected cache entry for ${pathname}`,
|
|
).not.toBeNull();
|
|
}
|
|
});
|
|
|
|
// ── Graceful degradation ──────────────────────────────────────────────────
|
|
|
|
it("is a no-op when vinext-prerender.json does not exist", async () => {
|
|
const count = await seedMemoryCacheFromPrerender(serverDir);
|
|
expect(count).toBe(0);
|
|
});
|
|
|
|
it("skips routes whose HTML files are missing from disk", async () => {
|
|
const buildId = "test-build-008";
|
|
setupPrerenderFixture(
|
|
serverDir,
|
|
{
|
|
buildId,
|
|
routes: [{ route: "/missing", status: "rendered", revalidate: 60, router: "app" }],
|
|
},
|
|
{},
|
|
);
|
|
|
|
const count = await seedMemoryCacheFromPrerender(serverDir);
|
|
expect(count).toBe(0);
|
|
});
|
|
|
|
it("returns 0 and warns on corrupt manifest JSON", async () => {
|
|
writeRawManifest(serverDir, "{ this is not valid json !!!");
|
|
|
|
const count = await seedMemoryCacheFromPrerender(serverDir);
|
|
expect(count).toBe(0);
|
|
});
|
|
|
|
it("returns 0 when manifest has no buildId", async () => {
|
|
writeRawManifest(serverDir, JSON.stringify({ routes: [] }));
|
|
|
|
const count = await seedMemoryCacheFromPrerender(serverDir);
|
|
expect(count).toBe(0);
|
|
});
|
|
|
|
// ── trailingSlash ──────────────────────────────────────────────────────────
|
|
|
|
it("reads files from trailingSlash directory layout", async () => {
|
|
const buildId = "test-build-010";
|
|
setupPrerenderFixture(
|
|
serverDir,
|
|
{
|
|
buildId,
|
|
trailingSlash: true,
|
|
routes: [{ route: "/about", status: "rendered", revalidate: 60, router: "app" }],
|
|
},
|
|
{
|
|
"about/index.html": "<html><body>About (trailing slash)</body></html>",
|
|
"about.rsc": "RSC about",
|
|
},
|
|
);
|
|
|
|
await seedMemoryCacheFromPrerender(serverDir);
|
|
|
|
const htmlKey = isrCacheKey("app", "/about", buildId) + ":html";
|
|
const htmlEntry = await getCacheHandler().get(htmlKey);
|
|
expect(htmlEntry).not.toBeNull();
|
|
if (htmlEntry?.value?.kind === "APP_PAGE") {
|
|
expect(htmlEntry.value.html).toBe("<html><body>About (trailing slash)</body></html>");
|
|
}
|
|
});
|
|
|
|
// ── RSC file optional ─────────────────────────────────────────────────────
|
|
|
|
it("seeds HTML even when RSC file is missing", async () => {
|
|
const buildId = "test-build-009";
|
|
setupPrerenderFixture(
|
|
serverDir,
|
|
{
|
|
buildId,
|
|
routes: [{ route: "/html-only", status: "rendered", revalidate: 60, router: "app" }],
|
|
},
|
|
{
|
|
"html-only.html": "<html><body>HTML only</body></html>",
|
|
},
|
|
);
|
|
|
|
const count = await seedMemoryCacheFromPrerender(serverDir);
|
|
expect(count).toBe(1);
|
|
|
|
const htmlKey = isrCacheKey("app", "/html-only", buildId) + ":html";
|
|
expect(await getCacheHandler().get(htmlKey)).not.toBeNull();
|
|
|
|
const rscKey = isrCacheKey("app", "/html-only", buildId) + ":rsc";
|
|
expect(await getCacheHandler().get(rscKey)).toBeNull();
|
|
});
|
|
|
|
// ── Long pathnames (FNV hash path) ────────────────────────────────────────
|
|
|
|
it("seeds routes with very long pathnames that hit the hash path", async () => {
|
|
const buildId = "hash-test";
|
|
const longSlug = "a".repeat(200);
|
|
const longPath = `/blog/${longSlug}`;
|
|
setupPrerenderFixture(
|
|
serverDir,
|
|
{
|
|
buildId,
|
|
routes: [
|
|
{
|
|
route: "/blog/:slug",
|
|
status: "rendered",
|
|
revalidate: 60,
|
|
path: longPath,
|
|
router: "app",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
[`blog/${longSlug}.html`]: "<html>Long path</html>",
|
|
[`blog/${longSlug}.rsc`]: "RSC long",
|
|
},
|
|
);
|
|
|
|
const count = await seedMemoryCacheFromPrerender(serverDir);
|
|
expect(count).toBe(1);
|
|
|
|
// Verify the hashed key matches what isrCacheKey produces
|
|
const htmlKey = isrCacheKey("app", longPath, buildId) + ":html";
|
|
expect(htmlKey).toContain("__hash:");
|
|
expect(await getCacheHandler().get(htmlKey)).not.toBeNull();
|
|
});
|
|
|
|
it("clears pregenerated concrete paths from a previous build and repopulates from the current manifest", async () => {
|
|
setupPrerenderFixture(
|
|
serverDir,
|
|
{
|
|
buildId: "build-a",
|
|
routes: [
|
|
{
|
|
route: "/en/blog/:slug",
|
|
status: "rendered",
|
|
router: "app",
|
|
path: "/en/blog/known-post",
|
|
revalidate: 60,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"en/blog/known-post.html": "<html>build A</html>",
|
|
"en/blog/known-post.rsc": "build-a-flight",
|
|
},
|
|
);
|
|
await seedMemoryCacheFromPrerender(serverDir);
|
|
|
|
const buildAPaths = getRenderedConcreteUrlPathsForRoute("/en/blog/:slug");
|
|
expect(buildAPaths).toBeDefined();
|
|
expect(buildAPaths!.has("/en/blog/known-post")).toBe(true);
|
|
|
|
setupPrerenderFixture(
|
|
serverDir,
|
|
{
|
|
buildId: "build-b",
|
|
routes: [
|
|
{
|
|
route: "/en/blog/:slug",
|
|
status: "rendered",
|
|
router: "app",
|
|
path: "/en/blog/new-post",
|
|
revalidate: 60,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"en/blog/new-post.html": "<html>build B</html>",
|
|
"en/blog/new-post.rsc": "build-b-flight",
|
|
},
|
|
);
|
|
await seedMemoryCacheFromPrerender(serverDir);
|
|
|
|
const buildBPaths = getRenderedConcreteUrlPathsForRoute("/en/blog/:slug");
|
|
expect(buildBPaths).toBeDefined();
|
|
expect(buildBPaths!.has("/en/blog/known-post")).toBe(false);
|
|
expect(buildBPaths!.has("/en/blog/new-post")).toBe(true);
|
|
expect(buildBPaths!.size).toBe(1);
|
|
});
|
|
|
|
it("excludes fallback-shell placeholder paths from concrete path registry", async () => {
|
|
const buildId = "fallback-shell-test";
|
|
setupPrerenderFixture(
|
|
serverDir,
|
|
{
|
|
buildId,
|
|
routes: [
|
|
{
|
|
route: "/en/blog/:slug",
|
|
status: "rendered",
|
|
router: "app",
|
|
path: "/en/blog/known-post",
|
|
revalidate: 60,
|
|
},
|
|
{
|
|
route: "/en/blog/:slug",
|
|
status: "rendered",
|
|
router: "app",
|
|
path: "/en/blog/[slug]",
|
|
revalidate: 60,
|
|
fallback: true,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"en/blog/known-post.html": "<html>known post</html>",
|
|
"en/blog/known-post.rsc": "flight-data",
|
|
},
|
|
);
|
|
await seedMemoryCacheFromPrerender(serverDir);
|
|
|
|
const paths = getRenderedConcreteUrlPathsForRoute("/en/blog/:slug");
|
|
expect(paths).toBeDefined();
|
|
expect(paths!.has("/en/blog/known-post")).toBe(true);
|
|
expect(paths!.has("/en/blog/[slug]")).toBe(false);
|
|
expect(paths!.size).toBe(1);
|
|
});
|
|
|
|
it("clears pregenerated concrete paths when manifest is absent from a subsequent build", async () => {
|
|
setupPrerenderFixture(
|
|
serverDir,
|
|
{
|
|
buildId: "build-a",
|
|
routes: [
|
|
{
|
|
route: "/en/blog/:slug",
|
|
status: "rendered",
|
|
router: "app",
|
|
path: "/en/blog/known-post",
|
|
revalidate: 60,
|
|
},
|
|
],
|
|
},
|
|
{ "en/blog/known-post.html": "<html>A</html>" },
|
|
);
|
|
await seedMemoryCacheFromPrerender(serverDir);
|
|
expect(getRenderedConcreteUrlPathsForRoute("/en/blog/:slug")).toBeDefined();
|
|
|
|
fs.rmSync(path.join(serverDir, "vinext-prerender.json"));
|
|
await seedMemoryCacheFromPrerender(serverDir);
|
|
|
|
expect(getRenderedConcreteUrlPathsForRoute("/en/blog/:slug")).toBeUndefined();
|
|
});
|
|
});
|