Files
cloudflare__vinext/tests/app-page-cache.test.ts
T
Nathan Nguyen f45fce00d5 fix(isr): honor route expire ceilings (#961)
* fix(isr): honor route expire ceilings

Track expireAt alongside revalidateAt in the memory and KV cache handlers so ISR entries past their expire ceiling become blocking misses instead of stale responses.

Plumb expireTime and request cacheLife expire values through App Router, Pages Router, prerender seeding, and cache writes while keeping generated entries as thin app-shape wiring over normal server modules.

Match Next.js cache-control semantics for finite stale-while-revalidate windows when an expire value is known.

* fix(isr): address cache life review follow-ups

* chore: address latest review follow-ups

* ci: pin vite-plus setup version

* Revert "ci: pin vite-plus setup version"

This reverts commit ee1d1d4e8db7b7406734414c53fa1b79f955a4d1.

* fix: avoid blocking ISR page streams on cache metadata

* fix: preserve headers for speculative cacheLife probes

* fix: preserve prerender cacheLife metadata

* fix: preserve legacy ISR cache metadata behavior

* fix: preserve prerender seed revalidate context

* fix: harden app page cache policy metadata

* fix: resolve app router prerender conflict
2026-05-02 19:54:25 +01:00

756 lines
23 KiB
TypeScript

import { describe, expect, it, vi } from "vite-plus/test";
import {
buildAppPageCacheTags,
buildAppPageCachedResponse,
finalizeAppPageHtmlCacheResponse,
finalizeAppPageRscCacheResponse,
readAppPageCacheResponse,
scheduleAppPageRscCacheWrite,
} from "../packages/vinext/src/server/app-page-cache.js";
import type { ISRCacheEntry } from "../packages/vinext/src/server/isr-cache.js";
import type { CachedAppPageValue } from "../packages/vinext/src/shims/cache.js";
function buildISRCacheEntry(
value: CachedAppPageValue,
isStale = false,
cacheControl?: { revalidate: number; expire?: number },
): ISRCacheEntry {
return {
isStale,
value: {
cacheControl,
lastModified: Date.now(),
value,
},
};
}
function buildCachedAppPageValue(
html: string,
rscData?: ArrayBuffer,
status?: number,
): CachedAppPageValue {
return {
kind: "APP_PAGE",
html,
rscData,
headers: undefined,
postponed: undefined,
status,
};
}
describe("app page cache helpers", () => {
it("builds implicit page cache tags with unique extra tags", () => {
expect(buildAppPageCacheTags("/blog/hello", ["custom", "_N_T_/blog/layout"])).toEqual([
"/blog/hello",
"_N_T_/blog/hello",
"_N_T_/layout",
"_N_T_/blog/layout",
"_N_T_/blog/hello/layout",
"_N_T_/blog/hello/page",
"custom",
]);
});
it("builds cached HTML and RSC responses", async () => {
const rscData = new TextEncoder().encode("flight").buffer;
const cachedValue = buildCachedAppPageValue("<h1>cached</h1>", rscData, 201);
const htmlResponse = buildAppPageCachedResponse(cachedValue, {
cacheState: "HIT",
expireSeconds: 300,
isRscRequest: false,
revalidateSeconds: 60,
});
expect(htmlResponse?.status).toBe(201);
expect(htmlResponse?.headers.get("content-type")).toBe("text/html; charset=utf-8");
expect(htmlResponse?.headers.get("cache-control")).toBe("s-maxage=60, stale-while-revalidate");
expect(htmlResponse?.headers.get("x-vinext-cache")).toBe("HIT");
await expect(htmlResponse?.text()).resolves.toBe("<h1>cached</h1>");
const rscResponse = buildAppPageCachedResponse(cachedValue, {
cacheState: "STALE",
expireSeconds: 300,
isRscRequest: true,
revalidateSeconds: 60,
});
expect(rscResponse?.headers.get("content-type")).toBe("text/x-component; charset=utf-8");
expect(rscResponse?.headers.get("cache-control")).toBe("s-maxage=0, stale-while-revalidate");
expect(await rscResponse?.arrayBuffer()).toEqual(rscData);
});
it("uses stored cache-control metadata instead of global config for cached HIT responses", async () => {
const cachedValue = buildCachedAppPageValue("<h1>cached</h1>");
const response = await readAppPageCacheResponse({
cleanPathname: "/cached",
clearRequestContext: vi.fn(),
isRscRequest: false,
isrGet: vi.fn(async () =>
buildISRCacheEntry(cachedValue, false, { revalidate: 60, expire: 300 }),
),
isrHtmlKey(pathname) {
return `html:${pathname}`;
},
isrRscKey(pathname) {
return `rsc:${pathname}`;
},
isrSet: vi.fn(async () => {}),
expireSeconds: 31_536_000,
revalidateSeconds: 60,
renderFreshPageForCache: vi.fn(),
scheduleBackgroundRegeneration: vi.fn(),
});
expect(response?.headers.get("cache-control")).toBe("s-maxage=60, stale-while-revalidate=240");
});
it("preserves legacy STALE headers when cached entries lack cache-control metadata", async () => {
const cachedValue = buildCachedAppPageValue("<h1>cached</h1>");
const response = await readAppPageCacheResponse({
cleanPathname: "/cached",
clearRequestContext: vi.fn(),
isRscRequest: false,
isrGet: vi.fn(async () => buildISRCacheEntry(cachedValue, true)),
isrHtmlKey(pathname) {
return `html:${pathname}`;
},
isrRscKey(pathname) {
return `rsc:${pathname}`;
},
isrSet: vi.fn(async () => {}),
expireSeconds: 31_536_000,
revalidateSeconds: 60,
renderFreshPageForCache: vi.fn(async () => ({
html: "<h1>fresh</h1>",
rscData: new ArrayBuffer(0),
tags: [],
})),
scheduleBackgroundRegeneration: vi.fn(),
});
expect(response?.headers.get("cache-control")).toBe("s-maxage=0, stale-while-revalidate");
});
it("falls back to 200 for falsy cached status values", () => {
const response = buildAppPageCachedResponse(
buildCachedAppPageValue("<h1>cached</h1>", undefined, 0),
{
cacheState: "HIT",
isRscRequest: false,
revalidateSeconds: 60,
},
);
expect(response?.status).toBe(200);
});
it("returns null when a cached entry lacks the requested HTML or RSC payload", () => {
const htmlOnly = buildCachedAppPageValue("<h1>cached</h1>");
const rscOnly = buildCachedAppPageValue("", new TextEncoder().encode("flight").buffer);
expect(
buildAppPageCachedResponse(htmlOnly, {
cacheState: "HIT",
isRscRequest: true,
revalidateSeconds: 60,
}),
).toBeNull();
expect(
buildAppPageCachedResponse(rscOnly, {
cacheState: "HIT",
isRscRequest: false,
revalidateSeconds: 60,
}),
).toBeNull();
});
it("returns cached HIT responses and clears request state", async () => {
let didClearRequestContext = false;
const response = await readAppPageCacheResponse({
cleanPathname: "/cached",
clearRequestContext() {
didClearRequestContext = true;
},
isRscRequest: false,
async isrGet() {
return buildISRCacheEntry(buildCachedAppPageValue("<h1>cached</h1>"));
},
isrHtmlKey(pathname) {
return "html:" + pathname;
},
isrRscKey(pathname) {
return "rsc:" + pathname;
},
async isrSet() {},
revalidateSeconds: 60,
async renderFreshPageForCache() {
throw new Error("should not render");
},
scheduleBackgroundRegeneration() {
throw new Error("should not schedule regeneration");
},
});
expect(response?.headers.get("x-vinext-cache")).toBe("HIT");
await expect(response?.text()).resolves.toBe("<h1>cached</h1>");
expect(didClearRequestContext).toBe(true);
});
it("keys RSC cache reads by mounted-slot header and echoes the variant header", async () => {
const response = await readAppPageCacheResponse({
cleanPathname: "/cached",
clearRequestContext() {},
isRscRequest: true,
async isrGet(key) {
expect(key).toBe("rsc:/cached:slot:auth:/");
return buildISRCacheEntry(
buildCachedAppPageValue("", new TextEncoder().encode("flight").buffer),
);
},
isrHtmlKey(pathname) {
return "html:" + pathname;
},
isrRscKey(pathname, mountedSlotsHeader) {
return `rsc:${pathname}:${mountedSlotsHeader ?? "none"}`;
},
async isrSet() {},
mountedSlotsHeader: "slot:auth:/",
revalidateSeconds: 60,
async renderFreshPageForCache() {
throw new Error("should not render");
},
scheduleBackgroundRegeneration() {
throw new Error("should not schedule regeneration");
},
});
expect(response?.headers.get("x-vinext-mounted-slots")).toBe("slot:auth:/");
});
it("serves stale RSC entries and regenerates only the matching RSC cache key", async () => {
const scheduledRegenerations: Array<() => Promise<void>> = [];
const isrSetCalls: Array<{
key: string;
html: string;
hasRscData: boolean;
expireSeconds: number | undefined;
revalidateSeconds: number;
tags: string[];
}> = [];
const rscData = new TextEncoder().encode("fresh-flight").buffer;
const response = await readAppPageCacheResponse({
cleanPathname: "/stale",
clearRequestContext() {},
isRscRequest: true,
async isrGet() {
return buildISRCacheEntry(buildCachedAppPageValue("", rscData), true);
},
isrHtmlKey(pathname) {
return "html:" + pathname;
},
isrRscKey(pathname, mountedSlotsHeader) {
return `rsc:${pathname}:${mountedSlotsHeader ?? "none"}`;
},
async isrSet(key, data, revalidateSeconds, tags, expireSeconds) {
isrSetCalls.push({
key,
html: data.html,
hasRscData: Boolean(data.rscData),
expireSeconds,
revalidateSeconds,
tags,
});
},
mountedSlotsHeader: "slot:auth:/",
expireSeconds: 300,
revalidateSeconds: 60,
async renderFreshPageForCache() {
return {
cacheControl: { revalidate: 10, expire: 20 },
html: "<h1>fresh</h1>",
rscData,
tags: ["/stale", "_N_T_/stale"],
};
},
scheduleBackgroundRegeneration(_key, renderFn) {
scheduledRegenerations.push(renderFn);
},
});
expect(response?.headers.get("x-vinext-cache")).toBe("STALE");
expect(scheduledRegenerations).toHaveLength(1);
await scheduledRegenerations[0]();
expect(isrSetCalls).toEqual([
{
key: "rsc:/stale:slot:auth:/",
html: "",
hasRscData: true,
expireSeconds: 20,
revalidateSeconds: 10,
tags: ["/stale", "_N_T_/stale"],
},
]);
});
it("serves stale HTML entries and regenerates HTML plus canonical RSC cache keys", async () => {
const scheduledRegenerations: Array<() => Promise<void>> = [];
const isrSetCalls: Array<{
key: string;
expireSeconds: number | undefined;
revalidateSeconds: number;
}> = [];
const rscData = new TextEncoder().encode("fresh-flight").buffer;
const response = await readAppPageCacheResponse({
cleanPathname: "/stale-html",
clearRequestContext() {},
isRscRequest: false,
async isrGet() {
return buildISRCacheEntry(buildCachedAppPageValue("<h1>stale</h1>"), true);
},
isrHtmlKey(pathname) {
return "html:" + pathname;
},
isrRscKey(pathname, mountedSlotsHeader) {
return `rsc:${pathname}:${mountedSlotsHeader ?? "none"}`;
},
async isrSet(key, _data, revalidateSeconds, _tags, expireSeconds) {
isrSetCalls.push({ key, expireSeconds, revalidateSeconds });
},
expireSeconds: 300,
revalidateSeconds: 60,
async renderFreshPageForCache() {
return {
cacheControl: { revalidate: 10, expire: 20 },
html: "<h1>fresh</h1>",
rscData,
tags: ["/stale-html", "_N_T_/stale-html"],
};
},
scheduleBackgroundRegeneration(_key, renderFn) {
scheduledRegenerations.push(renderFn);
},
});
expect(response?.headers.get("x-vinext-cache")).toBe("STALE");
await scheduledRegenerations[0]();
expect(isrSetCalls).toEqual([
{ key: "rsc:/stale-html:none", expireSeconds: 20, revalidateSeconds: 10 },
{ key: "html:/stale-html", expireSeconds: 20, revalidateSeconds: 10 },
]);
});
it("still schedules stale regeneration when the stale payload is unusable for this request", async () => {
const debugCalls: Array<[string, string]> = [];
const scheduledRegenerations: Array<() => Promise<void>> = [];
const response = await readAppPageCacheResponse({
cleanPathname: "/stale-html-miss",
clearRequestContext() {
throw new Error("should not clear request context when falling through");
},
isRscRequest: false,
async isrGet() {
return buildISRCacheEntry(
buildCachedAppPageValue("", new TextEncoder().encode("flight").buffer),
true,
);
},
isrDebug(event, detail) {
debugCalls.push([event, detail]);
},
isrHtmlKey(pathname) {
return "html:" + pathname;
},
isrRscKey(pathname) {
return "rsc:" + pathname;
},
async isrSet() {},
revalidateSeconds: 60,
async renderFreshPageForCache() {
return {
html: "<h1>fresh</h1>",
rscData: new TextEncoder().encode("fresh-flight").buffer,
tags: ["/stale-html-miss", "_N_T_/stale-html-miss"],
};
},
scheduleBackgroundRegeneration(_key, renderFn) {
scheduledRegenerations.push(renderFn);
},
});
expect(response).toBeNull();
expect(scheduledRegenerations).toHaveLength(1);
expect(debugCalls).toContainEqual(["STALE MISS (empty stale entry)", "/stale-html-miss"]);
await expect(scheduledRegenerations[0]()).resolves.toBeUndefined();
});
it("falls through and logs on cache read errors", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const response = await readAppPageCacheResponse({
cleanPathname: "/broken",
clearRequestContext() {},
isRscRequest: false,
async isrGet() {
throw new Error("cache failed");
},
isrHtmlKey(pathname) {
return "html:" + pathname;
},
isrRscKey(pathname) {
return "rsc:" + pathname;
},
async isrSet() {},
revalidateSeconds: 60,
async renderFreshPageForCache() {
throw new Error("should not render");
},
scheduleBackgroundRegeneration() {},
});
expect(response).toBeNull();
expect(errorSpy).toHaveBeenCalledOnce();
errorSpy.mockRestore();
});
it("finalizes HTML responses by teeing the stream and writing HTML and RSC cache keys", async () => {
const pendingCacheWrites: Promise<void>[] = [];
const isrSetCalls: Array<{
key: string;
html: string;
hasRscData: boolean;
expireSeconds: number | undefined;
revalidateSeconds: number;
tags: string[];
}> = [];
const debugCalls: Array<[string, string]> = [];
const rscData = new TextEncoder().encode("flight").buffer;
const response = finalizeAppPageHtmlCacheResponse(
new Response("<h1>fresh</h1>", {
status: 201,
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "s-maxage=60, stale-while-revalidate",
Vary: "RSC, Accept",
"X-Vinext-Cache": "MISS",
},
}),
{
capturedRscDataPromise: Promise.resolve(rscData),
cleanPathname: "/fresh",
consumeDynamicUsage() {
return false;
},
getPageTags() {
return ["/fresh", "_N_T_/fresh"];
},
isrDebug(event, detail) {
debugCalls.push([event, detail]);
},
isrHtmlKey(pathname) {
return "html:" + pathname;
},
isrRscKey(pathname) {
return "rsc:" + pathname;
},
async isrSet(key, data, revalidateSeconds, tags, expireSeconds) {
isrSetCalls.push({
key,
html: data.html,
hasRscData: Boolean(data.rscData),
expireSeconds,
revalidateSeconds,
tags,
});
},
expireSeconds: 300,
revalidateSeconds: 60,
waitUntil(promise) {
pendingCacheWrites.push(promise);
},
},
);
expect(response.status).toBe(201);
expect(response.headers.get("Cache-Control")).toBe("no-store, must-revalidate");
expect(response.headers.get("X-Vinext-Cache")).toBe("MISS");
await expect(response.text()).resolves.toBe("<h1>fresh</h1>");
expect(pendingCacheWrites).toHaveLength(1);
await pendingCacheWrites[0];
expect(isrSetCalls).toEqual([
{
key: "html:/fresh",
html: "<h1>fresh</h1>",
hasRscData: false,
expireSeconds: 300,
revalidateSeconds: 60,
tags: ["/fresh", "_N_T_/fresh"],
},
{
key: "rsc:/fresh",
html: "",
hasRscData: true,
expireSeconds: 300,
revalidateSeconds: 60,
tags: ["/fresh", "_N_T_/fresh"],
},
]);
expect(debugCalls).toEqual([["HTML cache written", "html:/fresh"]]);
});
it("skips HTML and RSC cache writes when dynamic usage appears during stream rendering", async () => {
const pendingCacheWrites: Promise<void>[] = [];
const debugCalls: Array<[string, string]> = [];
const isrSet = vi.fn();
const options = {
capturedRscDataPromise: Promise.resolve(new TextEncoder().encode("flight").buffer),
cleanPathname: "/dynamic-html",
consumeDynamicUsage() {
return true;
},
getPageTags() {
return ["/dynamic-html", "_N_T_/dynamic-html"];
},
isrDebug(event: string, detail: string) {
debugCalls.push([event, detail]);
},
isrHtmlKey(pathname: string) {
return "html:" + pathname;
},
isrRscKey(pathname: string) {
return "rsc:" + pathname;
},
isrSet,
revalidateSeconds: 60,
waitUntil(promise: Promise<void>) {
pendingCacheWrites.push(promise);
},
};
const response = finalizeAppPageHtmlCacheResponse(
new Response("<h1>personalized</h1>", {
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "s-maxage=60, stale-while-revalidate",
Vary: "RSC, Accept",
"X-Vinext-Cache": "MISS",
},
}),
options,
);
expect(response.headers.get("Cache-Control")).toBe("no-store, must-revalidate");
expect(response.headers.get("X-Vinext-Cache")).toBe("MISS");
await expect(response.text()).resolves.toBe("<h1>personalized</h1>");
expect(pendingCacheWrites).toHaveLength(1);
await pendingCacheWrites[0];
expect(isrSet).not.toHaveBeenCalled();
expect(debugCalls).toEqual([
["HTML cache write skipped (dynamic usage during render)", "html:/dynamic-html"],
]);
});
it("schedules RSC cache writes when the page stayed static through stream consumption", async () => {
const pendingCacheWrites: Promise<void>[] = [];
const debugCalls: Array<[string, string]> = [];
const isrSetCalls: Array<{
key: string;
html: string;
hasRscData: boolean;
expireSeconds: number | undefined;
revalidateSeconds: number;
tags: string[];
}> = [];
const didSchedule = scheduleAppPageRscCacheWrite({
capturedRscDataPromise: Promise.resolve(new TextEncoder().encode("flight").buffer),
cleanPathname: "/fresh-rsc",
consumeDynamicUsage() {
return false;
},
dynamicUsedDuringBuild: false,
getPageTags() {
return ["/fresh-rsc", "_N_T_/fresh-rsc"];
},
isrDebug(event, detail) {
debugCalls.push([event, detail]);
},
isrRscKey(pathname) {
return "rsc:" + pathname;
},
async isrSet(key, data, revalidateSeconds, tags, expireSeconds) {
isrSetCalls.push({
key,
html: data.html,
hasRscData: Boolean(data.rscData),
expireSeconds,
revalidateSeconds,
tags,
});
},
expireSeconds: 300,
revalidateSeconds: 60,
waitUntil(promise) {
pendingCacheWrites.push(promise);
},
});
expect(didSchedule).toBe(true);
expect(pendingCacheWrites).toHaveLength(1);
await pendingCacheWrites[0];
expect(isrSetCalls).toEqual([
{
key: "rsc:/fresh-rsc",
html: "",
hasRscData: true,
expireSeconds: 300,
revalidateSeconds: 60,
tags: ["/fresh-rsc", "_N_T_/fresh-rsc"],
},
]);
expect(debugCalls).toEqual([["RSC cache written", "rsc:/fresh-rsc"]]);
});
it("marks client-facing RSC cache MISS responses no-store until the stream dynamic check finishes", async () => {
const pendingCacheWrites: Promise<void>[] = [];
const isrSetCalls: string[] = [];
const response = finalizeAppPageRscCacheResponse(
new Response("flight", {
headers: {
"Content-Type": "text/x-component; charset=utf-8",
"Cache-Control": "s-maxage=60, stale-while-revalidate",
"X-Vinext-Cache": "MISS",
},
}),
{
capturedRscDataPromise: Promise.resolve(new TextEncoder().encode("flight").buffer),
cleanPathname: "/fresh-rsc",
consumeDynamicUsage() {
return false;
},
dynamicUsedDuringBuild: false,
getPageTags() {
return ["/fresh-rsc"];
},
isrRscKey(pathname) {
return "rsc:" + pathname;
},
async isrSet(key) {
isrSetCalls.push(key);
},
revalidateSeconds: 60,
waitUntil(promise) {
pendingCacheWrites.push(promise);
},
},
);
expect(response.headers.get("Cache-Control")).toBe("no-store, must-revalidate");
expect(response.headers.get("X-Vinext-Cache")).toBe("MISS");
await expect(response.text()).resolves.toBe("flight");
expect(pendingCacheWrites).toHaveLength(1);
await pendingCacheWrites[0];
expect(isrSetCalls).toEqual(["rsc:/fresh-rsc"]);
});
it("skips RSC cache writes when dynamic usage appears during stream rendering", async () => {
const pendingCacheWrites: Promise<void>[] = [];
const debugCalls: Array<[string, string]> = [];
const isrSet = vi.fn();
const didSchedule = scheduleAppPageRscCacheWrite({
capturedRscDataPromise: Promise.resolve(new TextEncoder().encode("flight").buffer),
cleanPathname: "/dynamic-rsc",
consumeDynamicUsage() {
return true;
},
dynamicUsedDuringBuild: false,
getPageTags() {
return ["/dynamic-rsc", "_N_T_/dynamic-rsc"];
},
isrDebug(event, detail) {
debugCalls.push([event, detail]);
},
isrRscKey(pathname) {
return "rsc:" + pathname;
},
isrSet,
revalidateSeconds: 60,
waitUntil(promise) {
pendingCacheWrites.push(promise);
},
});
expect(didSchedule).toBe(true);
expect(pendingCacheWrites).toHaveLength(1);
await pendingCacheWrites[0];
expect(isrSet).not.toHaveBeenCalled();
expect(debugCalls).toEqual([
["RSC cache write skipped (dynamic usage during render)", "rsc:/dynamic-rsc"],
]);
});
it("skips cache writes when request cacheLife resolves to a non-finite revalidate", async () => {
const pendingCacheWrites: Promise<void>[] = [];
const debugCalls: Array<[string, string]> = [];
const isrSet = vi.fn();
const didSchedule = scheduleAppPageRscCacheWrite({
capturedRscDataPromise: Promise.resolve(new TextEncoder().encode("flight").buffer),
cleanPathname: "/invalid-cache-life",
consumeDynamicUsage() {
return false;
},
dynamicUsedDuringBuild: false,
getPageTags() {
return ["/invalid-cache-life"];
},
getRequestCacheLife() {
return { revalidate: Number.NaN };
},
isrDebug(event, detail) {
debugCalls.push([event, detail]);
},
isrRscKey(pathname) {
return "rsc:" + pathname;
},
isrSet,
revalidateSeconds: null,
waitUntil(promise) {
pendingCacheWrites.push(promise);
},
});
expect(didSchedule).toBe(true);
expect(pendingCacheWrites).toHaveLength(1);
await pendingCacheWrites[0];
expect(isrSet).not.toHaveBeenCalled();
expect(debugCalls).toEqual([
["RSC cache write skipped (no cache policy)", "rsc:/invalid-cache-life"],
]);
});
});