Files
cloudflare__vinext/tests/seed-cache.test.ts
T
Nathan Nguyen 930e03c449 fix(ppr): gate fallback shells until request-time resume is supported (#1716)
* 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>
2026-06-14 00:44:54 +01:00

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();
});
});