mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
1425 lines
44 KiB
TypeScript
1425 lines
44 KiB
TypeScript
import { describe, expect, it, vi } from "vite-plus/test";
|
|
import {
|
|
type AppPageCacheOutcomeMetric,
|
|
buildAppPageCacheTags,
|
|
buildAppPageCachedResponse,
|
|
finalizeAppPageHtmlCacheResponse,
|
|
finalizeAppPageRscCacheResponse,
|
|
readAppPageCacheResponse,
|
|
readAppPageFallbackShellCacheResponse,
|
|
scheduleAppPageRscCacheWrite,
|
|
} from "../packages/vinext/src/server/app-page-cache.js";
|
|
import type { ISRCacheEntry } from "../packages/vinext/src/server/isr-cache.js";
|
|
import {
|
|
VINEXT_RSC_COMPATIBILITY_ID_HEADER,
|
|
VINEXT_RSC_VARY_HEADER,
|
|
} from "../packages/vinext/src/server/app-rsc-cache-busting.js";
|
|
import {
|
|
buildRenderObservation,
|
|
buildRenderRequestApiObservations,
|
|
type RenderObservation,
|
|
} from "../packages/vinext/src/server/cache-proof.js";
|
|
import type { CachedAppPageValue } from "../packages/vinext/src/shims/cache.js";
|
|
import { markAppPprDynamicFallbackShellHtml } from "../packages/vinext/src/server/app-ppr-fallback-shell.js";
|
|
import { withEnvVar } from "./env-test-helpers.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,
|
|
renderObservation?: RenderObservation,
|
|
): CachedAppPageValue {
|
|
const value: CachedAppPageValue = {
|
|
kind: "APP_PAGE",
|
|
html,
|
|
rscData,
|
|
headers: undefined,
|
|
postponed: undefined,
|
|
status,
|
|
};
|
|
if (renderObservation) {
|
|
value.renderObservation = renderObservation;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function buildQueryInvariantRenderObservation(): RenderObservation {
|
|
return buildRenderObservation({
|
|
boundaryOutcome: { kind: "success" },
|
|
cacheability: "public",
|
|
cacheTags: [],
|
|
completeness: "complete",
|
|
dynamicFetches: [],
|
|
output: {
|
|
kind: "app-html",
|
|
renderEpoch: null,
|
|
rootBoundaryId: null,
|
|
routeId: "route:/cached",
|
|
},
|
|
pathTags: [],
|
|
requestApis: buildRenderRequestApiObservations({
|
|
completeness: "complete",
|
|
observed: [],
|
|
}),
|
|
});
|
|
}
|
|
|
|
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");
|
|
// Ported from Next.js: test/e2e/app-dir/app-root-params-getters/generate-static-params.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-root-params-getters/generate-static-params.test.ts
|
|
expect(htmlResponse?.headers.get("x-nextjs-cache")).toBe("HIT");
|
|
await expect(htmlResponse?.text()).resolves.toBe("<h1>cached</h1>");
|
|
|
|
const rscResponse = withEnvVar("__VINEXT_RSC_COMPATIBILITY_ID", "compat-a", () =>
|
|
buildAppPageCachedResponse(cachedValue, {
|
|
cacheState: "STALE",
|
|
expireSeconds: 300,
|
|
isRscRequest: true,
|
|
revalidateSeconds: 60,
|
|
}),
|
|
);
|
|
expect(rscResponse?.headers.get("content-type")).toBe("text/x-component");
|
|
expect(rscResponse?.headers.get("cache-control")).toBe("s-maxage=0, stale-while-revalidate");
|
|
expect(rscResponse?.headers.get(VINEXT_RSC_COMPATIBILITY_ID_HEADER)).toBe("compat-a");
|
|
expect(rscResponse?.headers.get("x-nextjs-cache")).toBe("STALE");
|
|
expect(await rscResponse?.arrayBuffer()).toEqual(rscData);
|
|
});
|
|
|
|
it("merges middleware response headers into cached HTML responses", async () => {
|
|
const middlewareHeaders = new Headers({
|
|
"Cache-Control": "private, no-store",
|
|
"Content-Security-Policy": "frame-ancestors 'none'",
|
|
Vary: "Accept-Encoding",
|
|
"X-Frame-Options": "DENY",
|
|
});
|
|
middlewareHeaders.append("Set-Cookie", "session=abc; Path=/; HttpOnly");
|
|
|
|
const response = buildAppPageCachedResponse(buildCachedAppPageValue("<h1>cached</h1>"), {
|
|
cacheState: "HIT",
|
|
isRscRequest: false,
|
|
middlewareHeaders,
|
|
revalidateSeconds: 60,
|
|
});
|
|
|
|
expect(response?.headers.get("Cache-Control")).toBe("private, no-store");
|
|
expect(response?.headers.get("Content-Security-Policy")).toBe("frame-ancestors 'none'");
|
|
expect(response?.headers.get("Set-Cookie")).toBe("session=abc; Path=/; HttpOnly");
|
|
expect(response?.headers.get("Vary")).toBe(`${VINEXT_RSC_VARY_HEADER}, Accept-Encoding`);
|
|
expect(response?.headers.get("X-Frame-Options")).toBe("DENY");
|
|
expect(response?.headers.get("X-Vinext-Cache")).toBe("HIT");
|
|
});
|
|
|
|
it("replays prerendered Link headers before middleware overrides", () => {
|
|
const cachedValue = buildCachedAppPageValue("<h1>cached</h1>");
|
|
cachedValue.headers = {
|
|
link: "</font.woff2>; rel=preload; as=font",
|
|
};
|
|
|
|
const response = buildAppPageCachedResponse(cachedValue, {
|
|
cacheState: "HIT",
|
|
isRscRequest: false,
|
|
middlewareHeaders: new Headers({ link: "</middleware.css>; rel=preload; as=style" }),
|
|
revalidateSeconds: 60,
|
|
});
|
|
|
|
expect(response?.headers.get("link")).toBe("</middleware.css>; rel=preload; as=style");
|
|
});
|
|
|
|
it("merges middleware response headers into cached RSC responses", async () => {
|
|
const rscData = new TextEncoder().encode("flight").buffer;
|
|
const middlewareHeaders = new Headers({
|
|
"Access-Control-Allow-Origin": "https://example.com",
|
|
[VINEXT_RSC_COMPATIBILITY_ID_HEADER]: "middleware-compat",
|
|
Vary: "Origin",
|
|
});
|
|
|
|
const response = withEnvVar("__VINEXT_RSC_COMPATIBILITY_ID", "framework-compat", () =>
|
|
buildAppPageCachedResponse(buildCachedAppPageValue("", rscData), {
|
|
cacheState: "STALE",
|
|
isRscRequest: true,
|
|
middlewareHeaders,
|
|
revalidateSeconds: 60,
|
|
}),
|
|
);
|
|
|
|
expect(response?.headers.get("Access-Control-Allow-Origin")).toBe("https://example.com");
|
|
expect(response?.headers.get(VINEXT_RSC_COMPATIBILITY_ID_HEADER)).toBe("framework-compat");
|
|
expect(response?.headers.get("Vary")).toBe(`${VINEXT_RSC_VARY_HEADER}, Origin`);
|
|
expect(response?.headers.get("X-Vinext-Cache")).toBe("STALE");
|
|
await expect(response?.arrayBuffer()).resolves.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("emits static cache-control for cached indefinite app pages", async () => {
|
|
const response = buildAppPageCachedResponse(buildCachedAppPageValue("<h1>cached</h1>"), {
|
|
cacheState: "HIT",
|
|
isRscRequest: false,
|
|
revalidateSeconds: Infinity,
|
|
});
|
|
|
|
expect(response?.headers.get("cache-control")).toBe(
|
|
"s-maxage=31536000, stale-while-revalidate",
|
|
);
|
|
});
|
|
|
|
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("uses middleware status for cached responses when middleware continues", () => {
|
|
const response = buildAppPageCachedResponse(
|
|
buildCachedAppPageValue("<h1>cached</h1>", undefined, 201),
|
|
{
|
|
cacheState: "HIT",
|
|
isRscRequest: false,
|
|
middlewareStatus: 202,
|
|
revalidateSeconds: 60,
|
|
},
|
|
);
|
|
|
|
expect(response?.status).toBe(202);
|
|
});
|
|
|
|
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("emits the `x-edge-runtime: 1` marker on cached responses for edge-runtime routes", () => {
|
|
const rscData = new TextEncoder().encode("flight").buffer;
|
|
const cached = buildCachedAppPageValue("<h1>cached</h1>", rscData);
|
|
|
|
const htmlResponse = buildAppPageCachedResponse(cached, {
|
|
cacheState: "HIT",
|
|
isEdgeRuntime: true,
|
|
isRscRequest: false,
|
|
revalidateSeconds: 60,
|
|
});
|
|
expect(htmlResponse?.headers.get("x-edge-runtime")).toBe("1");
|
|
|
|
const rscResponse = buildAppPageCachedResponse(cached, {
|
|
cacheState: "HIT",
|
|
isEdgeRuntime: true,
|
|
isRscRequest: true,
|
|
revalidateSeconds: 60,
|
|
});
|
|
expect(rscResponse?.headers.get("x-edge-runtime")).toBe("1");
|
|
});
|
|
|
|
it("omits the `x-edge-runtime` marker on cached responses for nodejs-runtime routes", () => {
|
|
const rscData = new TextEncoder().encode("flight").buffer;
|
|
const cached = buildCachedAppPageValue("<h1>cached</h1>", rscData);
|
|
|
|
const htmlResponse = buildAppPageCachedResponse(cached, {
|
|
cacheState: "HIT",
|
|
isRscRequest: false,
|
|
revalidateSeconds: 60,
|
|
});
|
|
expect(htmlResponse?.headers.get("x-edge-runtime")).toBeNull();
|
|
|
|
const rscResponse = buildAppPageCachedResponse(cached, {
|
|
cacheState: "HIT",
|
|
isRscRequest: true,
|
|
revalidateSeconds: 60,
|
|
});
|
|
expect(rscResponse?.headers.get("x-edge-runtime")).toBeNull();
|
|
});
|
|
|
|
it("returns cached HIT responses and clears request state", async () => {
|
|
let didClearRequestContext = false;
|
|
const middlewareHeaders = new Headers({ "X-From-Middleware": "hit" });
|
|
const cacheOutcomes: AppPageCacheOutcomeMetric[] = [];
|
|
|
|
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() {},
|
|
middlewareHeaders,
|
|
middlewareStatus: 203,
|
|
recordCacheOutcome(metric) {
|
|
cacheOutcomes.push(metric);
|
|
},
|
|
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");
|
|
expect(response?.headers.get("x-from-middleware")).toBe("hit");
|
|
expect(response?.status).toBe(203);
|
|
await expect(response?.text()).resolves.toBe("<h1>cached</h1>");
|
|
expect(didClearRequestContext).toBe(true);
|
|
expect(cacheOutcomes).toEqual([
|
|
{
|
|
artifact: "html",
|
|
cacheKey: "html:/cached",
|
|
outcome: "hit",
|
|
reason: "served",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("treats unproofed cached HIT responses as misses for query-bearing requests", async () => {
|
|
let didRenderFresh = false;
|
|
const cacheOutcomes: AppPageCacheOutcomeMetric[] = [];
|
|
|
|
const response = await readAppPageCacheResponse({
|
|
cleanPathname: "/cached",
|
|
clearRequestContext() {
|
|
throw new Error("unproofed query cache hit should not clear request context");
|
|
},
|
|
hasRequestSearchParams: true,
|
|
isRscRequest: false,
|
|
async isrGet() {
|
|
return buildISRCacheEntry(buildCachedAppPageValue("<h1>cached empty query</h1>"));
|
|
},
|
|
isrHtmlKey(pathname) {
|
|
return "html:" + pathname;
|
|
},
|
|
isrRscKey(pathname) {
|
|
return "rsc:" + pathname;
|
|
},
|
|
async isrSet() {},
|
|
recordCacheOutcome(metric) {
|
|
cacheOutcomes.push(metric);
|
|
},
|
|
revalidateSeconds: 60,
|
|
async renderFreshPageForCache() {
|
|
didRenderFresh = true;
|
|
return {
|
|
html: "<h1>fresh</h1>",
|
|
rscData: new ArrayBuffer(0),
|
|
tags: [],
|
|
};
|
|
},
|
|
scheduleBackgroundRegeneration() {
|
|
throw new Error("should not schedule regeneration");
|
|
},
|
|
});
|
|
|
|
expect(response).toBeNull();
|
|
expect(didRenderFresh).toBe(false);
|
|
expect(cacheOutcomes).toEqual([
|
|
{
|
|
artifact: "html",
|
|
cacheKey: "html:/cached",
|
|
outcome: "miss",
|
|
reason: "query-variant-unproven",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("serves cached HIT responses for query-bearing requests with negative searchParams proof", async () => {
|
|
let didClearRequestContext = false;
|
|
|
|
const response = await readAppPageCacheResponse({
|
|
cleanPathname: "/cached",
|
|
clearRequestContext() {
|
|
didClearRequestContext = true;
|
|
},
|
|
hasRequestSearchParams: true,
|
|
isRscRequest: false,
|
|
async isrGet() {
|
|
return buildISRCacheEntry(
|
|
buildCachedAppPageValue(
|
|
"<h1>cached</h1>",
|
|
undefined,
|
|
undefined,
|
|
buildQueryInvariantRenderObservation(),
|
|
),
|
|
);
|
|
},
|
|
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("returns cached HIT responses when the cache outcome recorder throws", 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() {},
|
|
recordCacheOutcome() {
|
|
throw new Error("metrics sink unavailable");
|
|
},
|
|
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("bypasses persistent RSC cache reads for mounted-slot variants", async () => {
|
|
const debugCalls: Array<[string, string]> = [];
|
|
const isrGet = vi.fn();
|
|
const isrRscKey = vi.fn();
|
|
|
|
const response = await readAppPageCacheResponse({
|
|
cleanPathname: "/cached",
|
|
clearRequestContext() {},
|
|
isRscRequest: true,
|
|
isrGet,
|
|
isrHtmlKey(pathname) {
|
|
return "html:" + pathname;
|
|
},
|
|
isrRscKey,
|
|
async isrSet() {},
|
|
isrDebug(event, detail) {
|
|
debugCalls.push([event, detail]);
|
|
},
|
|
mountedSlotsHeader: "slot:auth:/",
|
|
revalidateSeconds: 60,
|
|
async renderFreshPageForCache() {
|
|
throw new Error("read helper should not render directly");
|
|
},
|
|
scheduleBackgroundRegeneration() {
|
|
throw new Error("should not schedule regeneration");
|
|
},
|
|
});
|
|
|
|
expect(response).toBeNull();
|
|
expect(isrGet).not.toHaveBeenCalled();
|
|
expect(isrRscKey).not.toHaveBeenCalled();
|
|
expect(debugCalls).toEqual([["MISS (mounted slots RSC variant)", "/cached"]]);
|
|
});
|
|
|
|
it("does not serve or regenerate stale mounted-slot RSC cache entries", async () => {
|
|
const scheduledRegenerations: Array<() => Promise<void>> = [];
|
|
const isrRscKey = vi.fn(
|
|
(pathname: string, mountedSlotsHeader?: string | null) =>
|
|
`rsc:${pathname}:${mountedSlotsHeader ?? "none"}`,
|
|
);
|
|
const isrGet = vi.fn();
|
|
const isrSet = vi.fn();
|
|
|
|
const response = await readAppPageCacheResponse({
|
|
cleanPathname: "/stale",
|
|
clearRequestContext() {},
|
|
isRscRequest: true,
|
|
isrGet,
|
|
isrHtmlKey(pathname) {
|
|
return "html:" + pathname;
|
|
},
|
|
isrRscKey,
|
|
isrSet,
|
|
mountedSlotsHeader: "slot:auth:/",
|
|
expireSeconds: 300,
|
|
revalidateSeconds: 60,
|
|
async renderFreshPageForCache() {
|
|
throw new Error("read helper should not render directly");
|
|
},
|
|
scheduleBackgroundRegeneration(_key, renderFn) {
|
|
scheduledRegenerations.push(renderFn);
|
|
},
|
|
});
|
|
|
|
expect(response).toBeNull();
|
|
expect(isrGet).not.toHaveBeenCalled();
|
|
expect(isrRscKey).not.toHaveBeenCalled();
|
|
expect(isrSet).not.toHaveBeenCalled();
|
|
expect(scheduledRegenerations).toHaveLength(0);
|
|
});
|
|
|
|
it("does not dedup mounted-slot RSC regeneration by a persistent cache key", async () => {
|
|
const scheduledKeys: string[] = [];
|
|
const isrGet = vi.fn();
|
|
|
|
const response = await readAppPageCacheResponse({
|
|
cleanPathname: "/parallel",
|
|
clearRequestContext() {},
|
|
isRscRequest: true,
|
|
isrGet,
|
|
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("read helper should not render directly");
|
|
},
|
|
scheduleBackgroundRegeneration(key) {
|
|
scheduledKeys.push(key);
|
|
},
|
|
});
|
|
|
|
expect(response).toBeNull();
|
|
expect(isrGet).not.toHaveBeenCalled();
|
|
expect(scheduledKeys).toEqual([]);
|
|
});
|
|
|
|
it("serves stale HTML entries and regenerates HTML plus canonical RSC cache keys", async () => {
|
|
const scheduledRegenerations: Array<() => Promise<void>> = [];
|
|
const isrHtmlKey = vi.fn((pathname: string) => "html:" + pathname);
|
|
const isrSetCalls: Array<{
|
|
key: string;
|
|
expireSeconds: number | undefined;
|
|
linkHeader: string | string[] | 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,
|
|
isrRscKey(pathname, mountedSlotsHeader) {
|
|
return `rsc:${pathname}:${mountedSlotsHeader ?? "none"}`;
|
|
},
|
|
async isrSet(key, data, revalidateSeconds, _tags, expireSeconds) {
|
|
isrSetCalls.push({
|
|
key,
|
|
expireSeconds,
|
|
linkHeader: data.headers?.link,
|
|
revalidateSeconds,
|
|
});
|
|
},
|
|
mountedSlotsHeader: "slot:forged:/",
|
|
expireSeconds: 300,
|
|
revalidateSeconds: 60,
|
|
async renderFreshPageForCache() {
|
|
return {
|
|
cacheControl: { revalidate: 10, expire: 20 },
|
|
html: "<h1>fresh</h1>",
|
|
linkHeader: "</fresh.css>; rel=preload; as=style",
|
|
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(isrHtmlKey).toHaveBeenCalledOnce();
|
|
expect(isrSetCalls).toEqual([
|
|
{
|
|
key: "rsc:/stale-html:none",
|
|
expireSeconds: 20,
|
|
linkHeader: undefined,
|
|
revalidateSeconds: 10,
|
|
},
|
|
{
|
|
key: "html:/stale-html",
|
|
expireSeconds: 20,
|
|
linkHeader: "</fresh.css>; rel=preload; as=style",
|
|
revalidateSeconds: 10,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("preserves route-level revalidate when regenerated App page fetches live longer", 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: "/config-and-fetch-revalidate",
|
|
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,
|
|
});
|
|
},
|
|
revalidateSeconds: 3,
|
|
async renderFreshPageForCache() {
|
|
return {
|
|
cacheControl: { revalidate: 9 },
|
|
html: "<h1>fresh</h1>",
|
|
rscData,
|
|
tags: ["/config-and-fetch-revalidate", "_N_T_/config-and-fetch-revalidate"],
|
|
};
|
|
},
|
|
scheduleBackgroundRegeneration(_key, renderFn) {
|
|
scheduledRegenerations.push(renderFn);
|
|
},
|
|
});
|
|
|
|
expect(response?.headers.get("x-vinext-cache")).toBe("STALE");
|
|
await scheduledRegenerations[0]();
|
|
|
|
expect(isrSetCalls).toEqual([
|
|
{
|
|
key: "rsc:/config-and-fetch-revalidate:none",
|
|
expireSeconds: undefined,
|
|
revalidateSeconds: 3,
|
|
},
|
|
{
|
|
key: "html:/config-and-fetch-revalidate",
|
|
expireSeconds: undefined,
|
|
revalidateSeconds: 3,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("serves stale static fallback shells without regenerating the shared shell key", async () => {
|
|
const debugCalls: Array<[string, string]> = [];
|
|
|
|
const response = await readAppPageFallbackShellCacheResponse({
|
|
clearRequestContext() {},
|
|
async isrGet() {
|
|
return buildISRCacheEntry(
|
|
buildCachedAppPageValue("<html><head></head><body>stale shell</body></html>"),
|
|
true,
|
|
{ revalidate: 60, expire: 300 },
|
|
);
|
|
},
|
|
isrDebug(event, detail) {
|
|
debugCalls.push([event, detail]);
|
|
},
|
|
isrHtmlKey(pathname) {
|
|
return "html:" + pathname;
|
|
},
|
|
fallbackPathname: "/en/blog/[slug]",
|
|
expireSeconds: 300,
|
|
middlewareHeaders: new Headers({ "X-From-Middleware": "yes" }),
|
|
revalidateSeconds: 60,
|
|
rewriteHtml(html) {
|
|
return html.replace("stale shell", "rewritten stale shell");
|
|
},
|
|
});
|
|
|
|
expect(response?.headers.get("x-vinext-cache")).toBe("STALE");
|
|
expect(response?.headers.get("x-from-middleware")).toBe("yes");
|
|
await expect(response?.text()).resolves.toContain("rewritten stale shell");
|
|
expect(debugCalls).toContainEqual(["STALE (fallback shell)", "/en/blog/[slug]"]);
|
|
});
|
|
|
|
it("falls through when a cached fallback shell requires request-time resume", async () => {
|
|
const debugCalls: Array<[string, string]> = [];
|
|
|
|
const response = await readAppPageFallbackShellCacheResponse({
|
|
clearRequestContext() {
|
|
throw new Error("should not clear request context when falling through");
|
|
},
|
|
async isrGet() {
|
|
return buildISRCacheEntry(
|
|
buildCachedAppPageValue(
|
|
markAppPprDynamicFallbackShellHtml(
|
|
"<html><head></head><body>dynamic shell</body></html>",
|
|
),
|
|
),
|
|
);
|
|
},
|
|
isrDebug(event, detail) {
|
|
debugCalls.push([event, detail]);
|
|
},
|
|
isrHtmlKey(pathname) {
|
|
return "html:" + pathname;
|
|
},
|
|
fallbackPathname: "/en/blog/[slug]",
|
|
revalidateSeconds: 60,
|
|
rewriteHtml(html) {
|
|
return html;
|
|
},
|
|
});
|
|
|
|
expect(response).toBeNull();
|
|
expect(debugCalls).toContainEqual([
|
|
"MISS (dynamic fallback shell requires resume)",
|
|
"/en/blog/[slug]",
|
|
]);
|
|
});
|
|
|
|
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 cacheOutcomes: AppPageCacheOutcomeMetric[] = [];
|
|
|
|
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() {},
|
|
recordCacheOutcome(metric) {
|
|
cacheOutcomes.push(metric);
|
|
},
|
|
revalidateSeconds: 60,
|
|
async renderFreshPageForCache() {
|
|
throw new Error("should not render");
|
|
},
|
|
scheduleBackgroundRegeneration() {},
|
|
});
|
|
|
|
expect(response).toBeNull();
|
|
expect(cacheOutcomes).toEqual([
|
|
{
|
|
artifact: "html",
|
|
cacheKey: "html:/broken",
|
|
outcome: "miss",
|
|
reason: "read-error",
|
|
},
|
|
]);
|
|
expect(errorSpy).toHaveBeenCalledOnce();
|
|
errorSpy.mockRestore();
|
|
});
|
|
|
|
it("records a miss when a cache key contains a non-app-page value", async () => {
|
|
const cacheOutcomes: AppPageCacheOutcomeMetric[] = [];
|
|
|
|
const response = await readAppPageCacheResponse({
|
|
cleanPathname: "/wrong-kind",
|
|
clearRequestContext() {
|
|
throw new Error("should not clear request context when falling through");
|
|
},
|
|
isRscRequest: false,
|
|
async isrGet() {
|
|
return {
|
|
isStale: false,
|
|
value: {
|
|
lastModified: Date.now(),
|
|
value: {
|
|
kind: "REDIRECT",
|
|
props: {},
|
|
},
|
|
},
|
|
};
|
|
},
|
|
isrHtmlKey(pathname) {
|
|
return "html:" + pathname;
|
|
},
|
|
isrRscKey(pathname) {
|
|
return "rsc:" + pathname;
|
|
},
|
|
async isrSet() {},
|
|
recordCacheOutcome(metric) {
|
|
cacheOutcomes.push(metric);
|
|
},
|
|
revalidateSeconds: 60,
|
|
async renderFreshPageForCache() {
|
|
throw new Error("should not render");
|
|
},
|
|
scheduleBackgroundRegeneration() {
|
|
throw new Error("should not schedule regeneration");
|
|
},
|
|
});
|
|
|
|
expect(response).toBeNull();
|
|
expect(cacheOutcomes).toEqual([
|
|
{
|
|
artifact: "html",
|
|
cacheKey: "html:/wrong-kind",
|
|
outcome: "miss",
|
|
reason: "non-app-page-entry",
|
|
},
|
|
]);
|
|
});
|
|
|
|
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;
|
|
linkHeader: string | string[] | undefined;
|
|
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",
|
|
Link: "</fresh.css>; rel=preload; as=style",
|
|
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),
|
|
linkHeader: data.headers?.link,
|
|
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,
|
|
linkHeader: "</fresh.css>; rel=preload; as=style",
|
|
expireSeconds: 300,
|
|
revalidateSeconds: 60,
|
|
tags: ["/fresh", "_N_T_/fresh"],
|
|
},
|
|
{
|
|
key: "rsc:/fresh",
|
|
html: "",
|
|
hasRscData: true,
|
|
linkHeader: undefined,
|
|
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("skips HTML and RSC cache writes when dynamic usage was captured before context cleanup", async () => {
|
|
const pendingCacheWrites: Promise<void>[] = [];
|
|
const debugCalls: Array<[string, string]> = [];
|
|
const isrSet = vi.fn();
|
|
|
|
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",
|
|
},
|
|
}),
|
|
{
|
|
capturedDynamicUsageBeforeContextCleanup() {
|
|
return true;
|
|
},
|
|
capturedRscDataPromise: Promise.resolve(new TextEncoder().encode("flight").buffer),
|
|
cleanPathname: "/dynamic-html-cleanup",
|
|
consumeDynamicUsage() {
|
|
return false;
|
|
},
|
|
getPageTags() {
|
|
return ["/dynamic-html-cleanup", "_N_T_/dynamic-html-cleanup"];
|
|
},
|
|
isrDebug(event, detail) {
|
|
debugCalls.push([event, detail]);
|
|
},
|
|
isrHtmlKey(pathname) {
|
|
return "html:" + pathname;
|
|
},
|
|
isrRscKey(pathname) {
|
|
return "rsc:" + pathname;
|
|
},
|
|
isrSet,
|
|
revalidateSeconds: 60,
|
|
waitUntil(promise) {
|
|
pendingCacheWrites.push(promise);
|
|
},
|
|
},
|
|
);
|
|
|
|
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-cleanup"],
|
|
]);
|
|
});
|
|
|
|
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("skips persistent RSC cache writes for mounted-slot variants", async () => {
|
|
const pendingCacheWrites: Promise<void>[] = [];
|
|
const isrRscKey = vi.fn();
|
|
const isrSet = vi.fn();
|
|
|
|
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"];
|
|
},
|
|
isrRscKey,
|
|
isrSet,
|
|
mountedSlotsHeader: "slot:auth:/",
|
|
revalidateSeconds: 60,
|
|
waitUntil(promise) {
|
|
pendingCacheWrites.push(promise);
|
|
},
|
|
});
|
|
|
|
expect(didSchedule).toBe(false);
|
|
expect(pendingCacheWrites).toEqual([]);
|
|
expect(isrRscKey).not.toHaveBeenCalled();
|
|
expect(isrSet).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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",
|
|
"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("omits provisional RSC cache state when pending dynamic usage may depend on query params", async () => {
|
|
const pendingCacheWrites: Promise<void>[] = [];
|
|
const isrSetCalls: string[] = [];
|
|
|
|
const response = finalizeAppPageRscCacheResponse(
|
|
new Response("flight", {
|
|
headers: {
|
|
"Content-Type": "text/x-component",
|
|
"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);
|
|
},
|
|
omitPendingDynamicCacheState: true,
|
|
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")).toBeNull();
|
|
expect(response.headers.get("X-Nextjs-Cache")).toBeNull();
|
|
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"],
|
|
]);
|
|
});
|
|
});
|