Files
cloudflare__vinext/tests/seed-cache.test.ts
T
Nathan Nguyen 73f56ef3f8 fix(app-router): honor cacheLife stale on the client router (#2708)
* fix(app-router): honor cacheLife stale on the client router

`cacheLife` profiles carry three independent numbers, and `stale` is the
client-router dimension: how long the browser may reuse cached route output
without asking the server. vinext aggregated it correctly (`resolveCacheLife`
min-reduces all three; the request scope accumulates min-wins) and then
projected it away at every consumer, so the only staleness a browser ever saw
was `dynamicStaleTimeSeconds` from `experimental.staleTimes` — a build-time
constant unrelated to the cached subtrees that produced the render. A subtree
declaring `cacheLife("seconds")` (stale: 30) was held for the full 5-minute
visited-response TTL.

Carry the resolved `stale` to the client on `x-nextjs-stale-time` (matching
Next.js's `NEXT_ROUTER_STALE_TIME_HEADER`) and combine it with the config
value by taking the minimum, so neither min-wins lattice overrides the other.
The 30s prefetch floor applies to the new value too.

Two normalization rules, both deliberate:

- An absent `stale` is never synthesized from `revalidate`/`expire`. The
  `default` profile has no `stale` and an `expire` of ~136 years, so deriving
  one would license session-long reuse without a refresh.
- The three numbers are not assumed to be ordered — `seconds` is
  `{ stale: 30, revalidate: 1, expire: 60 }` — so `revalidate` never
  constrains `stale`. Only the hard `expire` ceiling does.

* fix(app-router): source the client stale time from completed renders

The previous commit derived `x-nextjs-stale-time` from `peekRequestCacheLife()`
at response-construction time. That read happens after `probeAppPageBeforeRender`
but before the RSC stream is consumed, and the probe only awaits the page
component's own async result — it does not render the returned tree. Any
`use cache` scope in a child Server Component registers later, during stream
consumption, so the header described the probe rather than the output.

Because `cacheLife` aggregation is minimum-wins, missing one late scope is
enough to make the value wrong, and "it can only shorten" did not hold: with no
`dynamicStaleTimeSeconds` present (the usual case for `use cache` pages, which
are not dynamic renders), a peeked `stale: 300` widened the prefetch window from
`PREFETCH_CACHE_TTL` (30s) to 300s — 10x longer than today, in the direction the
change set out to fix.

Carry the value on the cache entry instead. The ISR write path reads the
request-scoped accumulation via the consuming `getRequestCacheLife()` inside the
cache-write closure, which runs after the captured stream has drained, so it
observes the completed render's minimum. `CacheControlMetadata` gains `stale`,
`isrSet` persists it, and `buildAppPageCachedResponse` re-advertises it on hits.
Prerender seeds carry it through `VINEXT_PRERENDER_CACHE_LIFE_HEADER` and the
prerender manifest so a seeded entry makes the same claim a runtime render would.

Two links that would otherwise silently drop the claim are closed with it: the
`use cache` entry write now persists `stale`, and `recordRequestScopedCacheControl`
re-registers it on a data-cache hit — without both, a page's advertised freshness
would depend on data-cache temperature rather than on what it declared.

Streaming responses now advertise nothing and leave the client on its configured
`experimental.staleTimes`, which is the honest answer for a value that is not yet
known when headers are committed. Covering fresh streaming renders needs a
render-completion signal the streaming RSC path does not have today; Next.js
solves it by streaming an `AsyncIterable<number>` in the RSC payload
(`app-render.tsx` `baseResponse.s`, closed after the render settles), which is
the natural follow-up.

The client-side combination logic is unchanged: both stale signals are still
min-reduced, absent `stale` is never synthesized from `revalidate`/`expire`,
`revalidate` never clamps `stale`, and `expire` still caps it.

* fix(app-router): deliver the resolved client stale time on fresh renders

The previous round persisted the completed render's cacheLife stale onto
the ISR entry and replayed it on cache hits, which left the value missing
or drifting wherever the entry's lifetime diverged from the render's:

- Fresh streaming renders advertised nothing, so the first visitor of any
  route (and every dev render, since dev writes no ISR entry) stayed on
  the configured staleTimes despite a declared cacheLife. The done-script
  emitted by the RSC embed transform's finalize() runs only after the
  full RSC stream has drained, so it can carry the completed render's
  minimum where streaming headers cannot. Emit the peeked request-scoped
  cacheLife there and seed the hydration visited-response entry from it.
  This also makes the client's min-combination of the config and
  cacheLife signals reachable: a dynamic render with use cache subtrees
  now legitimately carries both.

- The expire clamp bounded the value but not the elapsed window: an
  entry of { stale: 30, expire: 60 } hit at age 59s replayed a 30s reuse
  window reaching 29s past expire. Age the serve-time clamp with the
  entry's lastModified so only the remaining window is advertised.

- The two client caches applied different floors: prefetch entries
  floored a cacheLife stale at 30s while cold navigations honored it
  verbatim, so the same declaration produced two behaviors keyed on
  whether a prefetch fired first. Floor the cacheLife signal once in the
  shared resolver, mirroring Next.js getStaleTimeMs, before the min so
  it can never raise the config-derived bound.

Mechanically, the app-page cache setter's six-position signature
(declared identically in four modules) collapses into one exported
AppPageCacheSetter taking an AppPageCacheWritePolicy object, with
isrSetAppPage adapting to the shared positional isrSet, which stays
unchanged for the Pages Router and route handlers.

* docs(app-router): pin the cold RSC stale contract and expire precedence

The cold RSC fetch gap is a design decision, not a pending follow-up:
Next.js's AsyncIterable stale transport only works under staged rendering
(cacheComponents), where cache scopes settle before the render task queue
drains — in vinext's lazy streaming model the iterable could never close.
Next.js's plain-mode mechanism is a blocking cold render, which #961
deliberately rejected to keep ISR page streams unblocked. Assert the
resulting no-header contract on the streaming-response test.

Also reword the write-policy expire comment: a cacheLife-declared expire
replaces the config expireTime fallback (Next.js precedence), it is not
min-merged with a route-level ceiling — no such ceiling exists outside
cacheLife.

* fix(app-router): carry stale through regen and bound cold responses

Three fixes from review round 3:

Background regeneration dropped the regenerating render's cacheLife stale:
renderAppPageCacheArtifacts returned only { revalidate, expire }, so
resolveRegeneratedAppPageCachePolicy could never receive the stale it was
built to preserve and the first regen silently widened client reuse back
to the configured fallback. The producer now carries it, with a
real-producer regression test the mocked-cacheControl tests could not
provide.

The age-aware expire clamp is removed. Composed with the client's 30s
floor it delivered neither contract (a clamped 1 re-floored to 30), and
cached HTML replayed the unclamped done-script value regardless. Next.js
stores the stale header at generation and replays it verbatim on every
hit; vinext now does the same, keeping expire a serve-side ceiling.

Cold cacheable RSC responses stream before their cacheLife resolves
(#961), which left them on the 300s client fallback — reproducing the
headline bug for the first request of every entry epoch. They now carry
X-Vinext-Stale-Time-Pending, and both client caches bound such responses
at the 30s floor: the unresolved claim, once floored, could never
license less.

* fix(app-router): bound pending-stale responses by the dynamic stale time

The pending marker meant 'capture was attempted', but the client read it
as 'a cacheLife claim exists'. Capture eligibility is decided before the
lazy stream runs, so a late request-API read can make the completed
render dynamic — the finalizer skips the ISR write and no claim ever
exists, yet the marker granted 30 seconds of reuse even under
staleTimes.dynamic: 0.

Pending responses now carry the configured dynamic stale time (including
0) and the client takes the minimum, so an unresolved response never
receives a wider window than the dynamic bound.

* fix(app-router): keep the pending cap independent of staleTimes.dynamic

Pairing the pending marker with the configured dynamic stale time broke
the segment-cache-client-params compat test: dynamic-param routes
prefetch with no minimum TTL, so the paired 0 default made every cold
prefetch entry expire instantly and navigations refetched routes that
Next.js serves entirely from a static prefetch.

A cold stream cannot distinguish a render that will resolve static from
one that turns dynamic mid-stream, and bounding both by staleTimes.dynamic
sacrifices the guaranteed-correct case for the ambiguous one. Pending
responses go back to the 30s floor cap; the late-dynamic exposure
(one epoch-cold response, at most 30s) is documented as the price of
non-blocking cold renders (#961).

* fix: carry the client stale claim through KV, the prerender index, and nested cache hits

- writePrerenderIndex now copies `stale` into vinext-prerender.json so
  seedMemoryCacheFromPrerender actually receives it
- KVCacheHandler.set() and buildPrerenderKVPairs persist cacheControl.stale
  so warm hits on the Cloudflare KV backend replay the producing render's
  claim; validateCacheEntry accepts the field
- a nested use cache HIT pushes its stored lifetime into the enclosing
  cache context's lifeConfigs, mirroring the MISS path, so the outer entry
  keeps the child's stale claim once the outer goes warm

* perf: trim shipped comment bytes in the stale-time plumbing

The added modules ride in every consumer build environment; the review-grade
rationale lives in the PR body, the code keeps one-line constraints.

* refactor(app-router): model the cached client stale claim as one state

CachedRscResponse carried staleTimePending and staleTimeSeconds as two
independent optionals, so the cached form admitted a state the wire never
produces (pending and resolved at once) and every consumer had to encode
the precedence rule. Replace both with a discriminated serverStaleTime
(pending | resolved), collapsed once at the header-parse boundary.

* refactor(isr): give isrSet a cache-metadata write policy

The generic setter had grown to six positional arguments, the last an
App-Router-only `stale` value, with isrSetAppPage as a policy-object
wrapper that unpacked straight back into it. Take { cacheControl, tags }
instead: routers construct the metadata they actually claim, the wrapper
and its type disappear, and the shared isrCacheControl builder replaces
the cache-control literal duplicated across writers.

* fix(app-router): keep the dynamic bound on captured dynamic renders

The done-script metadata reused the RSC header's rule of dropping the
config-derived dynamic stale time while the speculative ISR capture was
armed. That rule only holds on the header path, which substitutes the
pending marker and its 30s floor; the done script emits the resolved
cacheLife instead, so a production render that turned dynamic shipped the
cacheLife claim as its only bound and let the hydration-seeded entry reuse
dynamic output for its full duration. Dev never took the capture path, so
the two diverged.

Also migrates the pages-basic seed fixture, the last positional isrSet
caller, which typecheck does not cover.

* ci: re-run after unrelated dev-overlay canary flake

* fix(app-router): preserve completed client stale metadata

* fix(app-router): strip completion metadata during HMR

* fix(app-router): stream completion metadata safely

* fix(app-router): validate completed stale metadata

* docs(cache): clarify zero stale client claim

---------

Co-authored-by: James <james@eli.cx>
2026-07-28 14:41:47 +01:00

899 lines
28 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("seeds the prerender's client stale time onto both artifacts", async () => {
// A prerendered page's `cacheLife` resolves before the artifact is written,
// so the seeded entry can carry the claim. Without it, a page served from
// the seed would be reusable for the configured staleTimes default while an
// identical runtime-rendered entry honored `cacheLife`.
const writes: { key: string; staleSeconds?: number }[] = [];
setupPrerenderFixture(
serverDir,
{
buildId: "seed-stale-test",
routes: [
{
route: "/isr",
status: "rendered",
revalidate: 60,
expire: 300,
stale: 30,
router: "app",
},
],
},
{
"isr.html": "<html>ISR</html>",
"isr.rsc": "RSC isr",
},
);
await seedMemoryCacheFromPrerender(serverDir, {
async writeAppPageEntry(key, _data, metadata): Promise<void> {
writes.push({ key, staleSeconds: metadata.staleSeconds });
},
});
const baseKey = isrCacheKey("app", "/isr", "seed-stale-test");
expect(writes).toEqual([
{ key: baseKey + ":html", staleSeconds: 30 },
{ key: baseKey + ":rsc", staleSeconds: 30 },
]);
});
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 Promise.resolve(revalidatePath("/posts"));
expect(await getCacheHandler().get(htmlKey)).toBeNull();
expect(await getCacheHandler().get(rscKey)).toBeNull();
});
it("user cache tags from the prerender manifest invalidate seeded entries", async () => {
const buildId = "revalidate-seeded-user-tag-test";
setupPrerenderFixture(
serverDir,
{
buildId,
routes: [
{
route: "/update-tag-test",
status: "rendered",
revalidate: false,
router: "app",
tags: ["test-update-tag"],
},
],
},
{
"update-tag-test.html": "<html>Seeded data</html>",
"update-tag-test.rsc": "RSC seeded data",
},
);
await seedMemoryCacheFromPrerender(serverDir);
const baseKey = isrCacheKey("app", "/update-tag-test", buildId);
const htmlKey = baseKey + ":html";
const rscKey = baseKey + ":rsc";
expect(await getCacheHandler().get(htmlKey)).not.toBeNull();
expect(await getCacheHandler().get(rscKey)).not.toBeNull();
await getCacheHandler().revalidateTag("test-update-tag");
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();
});
});