mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
ca0d542947
* 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.
510 lines
15 KiB
TypeScript
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"],
|
|
]);
|
|
});
|
|
});
|