mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
0c18c9f20a
* fix(app-router): replay prerendered preload headers * fix(app-router): retain preload headers during ISR * fix(app-router): reuse ISR font preload snapshot
822 lines
26 KiB
TypeScript
822 lines
26 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("replays prerendered App Router Link headers", async () => {
|
|
const buildId = "test-build-link-header";
|
|
setupPrerenderFixture(
|
|
serverDir,
|
|
{
|
|
buildId,
|
|
routes: [
|
|
{
|
|
route: "/preloads",
|
|
status: "rendered",
|
|
revalidate: false,
|
|
router: "app",
|
|
headers: { link: "</font.woff2>; rel=preload; as=font" },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"preloads.html": "<html><body>Preloads</body></html>",
|
|
"preloads.rsc": "RSC payload",
|
|
},
|
|
);
|
|
|
|
await seedMemoryCacheFromPrerender(serverDir);
|
|
|
|
const htmlKey = isrCacheKey("app", "/preloads", buildId) + ":html";
|
|
const htmlEntry = await getCacheHandler().get(htmlKey);
|
|
expect(htmlEntry?.value).toMatchObject({
|
|
kind: "APP_PAGE",
|
|
headers: { link: "</font.woff2>; rel=preload; as=font" },
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|