Files
cloudflare__vinext/tests/app-page-cache.test.ts
T
Nathan Nguyen ca0d542947 Fix parallel slot persistence and cache variants (#811)
* Fix parallel slot persistence and cache variants

* Address App Router review follow-ups

* Fix generated mounted-slot regex escaping

* Address review feedback on slot normalization and cache comments

Align generated entry's __normalizeMountedSlotsHeader regex with the
typed module in app-elements.ts (both now use /\s+/). Clarify the
HTML vs RSC cache asymmetry comment in stale regen path.

* Remove redundant .map(trim) and clarify regen slot context

split(/\s+/) never produces tokens with leading/trailing whitespace,
so .map(trim) was a no-op in both normalizeMountedSlotsHeader and its
generated counterpart. Also add a comment clarifying that background
regen intentionally inherits the triggering request's slot context.

* Clarify regen dedup and prefetch slot-mismatch tradeoffs

Add comments explaining that pathname-only regen dedup is intentional
(avoids concurrent regen storms across slot variants) and that
consumePrefetchResponse unconditionally removes the entry before the
slot-mismatch check (simplest correct behavior, prefetch is wasted on
mismatch).

* Document storePrefetchResponse slot-awareness gap

Add JSDoc note that storePrefetchResponse is slot-unaware: the
snapshot's mountedSlotsHeader comes from the response headers rather
than the caller, so consumePrefetchResponse may reject the entry on
slot context mismatch.
2026-04-10 15:29:33 +01:00

510 lines
15 KiB
TypeScript

import { describe, expect, it, vi } from "vite-plus/test";
import {
buildAppPageCachedResponse,
finalizeAppPageHtmlCacheResponse,
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): ISRCacheEntry {
return {
isStale,
value: {
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 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",
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("x-vinext-cache")).toBe("HIT");
await expect(htmlResponse?.text()).resolves.toBe("<h1>cached</h1>");
const rscResponse = buildAppPageCachedResponse(cachedValue, {
cacheState: "STALE",
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("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;
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) {
isrSetCalls.push({
key,
html: data.html,
hasRscData: Boolean(data.rscData),
revalidateSeconds,
tags,
});
},
mountedSlotsHeader: "slot:auth:/",
revalidateSeconds: 60,
async renderFreshPageForCache() {
return {
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,
revalidateSeconds: 60,
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<string> = [];
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) {
isrSetCalls.push(key);
},
revalidateSeconds: 60,
async renderFreshPageForCache() {
return {
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(["rsc:/stale-html:none", "html:/stale-html"]);
});
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;
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",
Vary: "RSC, Accept",
"X-Vinext-Cache": "MISS",
},
}),
{
capturedRscDataPromise: Promise.resolve(rscData),
cleanPathname: "/fresh",
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) {
isrSetCalls.push({
key,
html: data.html,
hasRscData: Boolean(data.rscData),
revalidateSeconds,
tags,
});
},
revalidateSeconds: 60,
waitUntil(promise) {
pendingCacheWrites.push(promise);
},
},
);
expect(response.status).toBe(201);
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,
revalidateSeconds: 60,
tags: ["/fresh", "_N_T_/fresh"],
},
{
key: "rsc:/fresh",
html: "",
hasRscData: true,
revalidateSeconds: 60,
tags: ["/fresh", "_N_T_/fresh"],
},
]);
expect(debugCalls).toEqual([["HTML cache written", "html:/fresh"]]);
});
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;
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) {
isrSetCalls.push({
key,
html: data.html,
hasRscData: Boolean(data.rscData),
revalidateSeconds,
tags,
});
},
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,
revalidateSeconds: 60,
tags: ["/fresh-rsc", "_N_T_/fresh-rsc"],
},
]);
expect(debugCalls).toEqual([["RSC cache written", "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"],
]);
});
});