diff --git a/packages/vinext/src/server/app-page-cache.ts b/packages/vinext/src/server/app-page-cache.ts index b75953c26..d25babce7 100644 --- a/packages/vinext/src/server/app-page-cache.ts +++ b/packages/vinext/src/server/app-page-cache.ts @@ -4,7 +4,8 @@ import { VINEXT_RSC_VARY_HEADER, applyRscCompatibilityIdHeader, } from "./app-rsc-cache-busting.js"; -import { applyCdnResponseHeaders, buildCachedRevalidateCacheControl } from "./cache-control.js"; +import { applyCdnResponseHeaders } from "./cache-control.js"; +import { decideIsr } from "./isr-decision.js"; import { VINEXT_MOUNTED_SLOTS_HEADER } from "./headers.js"; import { applyEdgeRuntimeHeader } from "./app-page-response.js"; import { setCacheStateHeaders } from "./cache-headers.js"; @@ -200,14 +201,6 @@ export function buildAppPageCacheTags(pathname: string, extraTags: readonly stri return tags.map(encodeCacheTag); } -function buildAppPageCacheControl( - cacheState: BuildAppPageCachedResponseOptions["cacheState"], - revalidateSeconds: number, - expireSeconds?: number, -): string { - return buildCachedRevalidateCacheControl(cacheState, revalidateSeconds, expireSeconds); -} - function buildAppPageCachedHeaders(options: { cacheControl: string; cacheState: BuildAppPageCachedResponseOptions["cacheState"]; @@ -278,16 +271,13 @@ export function buildAppPageCachedResponse( // Preserve the legacy fallback semantics from the generated entry: invalid // falsy statuses still fall back to 200 rather than being forwarded through. const status = options.middlewareStatus ?? (cachedValue.status || 200); - const revalidateSeconds = options.cacheControl?.revalidate ?? options.revalidateSeconds; - const expireSeconds = - options.cacheControl === undefined - ? undefined - : (options.cacheControl.expire ?? options.expireSeconds); - const cacheControl = buildAppPageCacheControl( - options.cacheState, - revalidateSeconds, - expireSeconds, - ); + const { cacheControl } = decideIsr({ + cacheState: options.cacheState, + kind: "app-page", + revalidateSeconds: options.revalidateSeconds, + expireSeconds: options.expireSeconds, + cacheControlMeta: options.cacheControl, + }); if (options.isRscRequest) { if (!cachedValue.rscData) { return null; diff --git a/packages/vinext/src/server/app-route-handler-response.ts b/packages/vinext/src/server/app-route-handler-response.ts index f393bcf8e..f7f9b7bc2 100644 --- a/packages/vinext/src/server/app-route-handler-response.ts +++ b/packages/vinext/src/server/app-route-handler-response.ts @@ -1,10 +1,6 @@ import type { CachedRouteValue, CacheControlMetadata } from "vinext/shims/cache"; -import { - applyCdnResponseHeaders, - buildCachedRevalidateCacheControl, - NEVER_CACHE_CONTROL, - STATIC_CACHE_CONTROL, -} from "./cache-control.js"; +import { applyCdnResponseHeaders } from "./cache-control.js"; +import { decideIsr, buildAppRouteMissIsrCacheControl } from "./isr-decision.js"; import { MIDDLEWARE_HEADER_PREFIX, MIDDLEWARE_NEXT_HEADER, @@ -48,28 +44,6 @@ function hasMiddlewareHeader(headers: Headers): boolean { return false; } -function buildRouteHandlerCacheControl( - cacheState: BuildRouteHandlerCachedResponseOptions["cacheState"], - revalidateSeconds: number, - expireSeconds?: number, -): string { - if (revalidateSeconds === 0) { - // A cached response is never produced for revalidate = 0 (the ISR write - // path skips it), so only the HIT/STALE->fresh rewrite can arrive here - // with a 0 value, via applyRouteHandlerRevalidateHeader. In all such - // cases the author opted out of caching entirely. - return NEVER_CACHE_CONTROL; - } - - if (revalidateSeconds === Infinity) { - // revalidate = false / Infinity means "cache indefinitely" — emit the - // same static Cache-Control used by pages, not a dynamic SWR value. - return STATIC_CACHE_CONTROL; - } - - return buildCachedRevalidateCacheControl(cacheState, revalidateSeconds, expireSeconds); -} - export function applyRouteHandlerMiddlewareContext( response: Response, middlewareContext: RouteHandlerMiddlewareContext, @@ -115,21 +89,17 @@ export function buildRouteHandlerCachedResponse( } } setCacheStateHeaders(headers, options.cacheState); - const revalidateSeconds = options.cacheControl?.revalidate ?? options.revalidateSeconds; - const expireSeconds = - options.cacheControl === undefined - ? undefined - : (options.cacheControl.expire ?? options.expireSeconds); // HIT/STALE served from the origin store: route the cache header through the // CDN adapter (default: identical single Cache-Control). Edge adapters never // reach this path because their get() returns null. - applyCdnResponseHeaders(headers, { - cacheControl: buildRouteHandlerCacheControl( - options.cacheState, - revalidateSeconds, - expireSeconds, - ), + const { cacheControl } = decideIsr({ + cacheState: options.cacheState, + kind: "app-route", + revalidateSeconds: options.revalidateSeconds, + expireSeconds: options.expireSeconds, + cacheControlMeta: options.cacheControl, }); + applyCdnResponseHeaders(headers, { cacheControl }); return new Response(options.isHead ? null : cachedValue.body, { status: cachedValue.status, @@ -145,8 +115,11 @@ export function applyRouteHandlerRevalidateHeader( ): void { // Fresh (MISS) response: route through the CDN adapter so edge adapters emit // CDN-Cache-Control + Cache-Tag while the default emits a single Cache-Control. + // Uses buildAppRouteMissIsrCacheControl so the revalidate=0→NEVER and + // Infinity→STATIC gates apply, and expireSeconds is used as the direct route + // config ceiling (not a per-entry metadata fallback). applyCdnResponseHeaders(response.headers, { - cacheControl: buildRouteHandlerCacheControl("HIT", revalidateSeconds, expireSeconds), + cacheControl: buildAppRouteMissIsrCacheControl(revalidateSeconds, expireSeconds), tags, }); } diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index 6c2e90a68..db164c99b 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -7,6 +7,12 @@ import type { ModuleImporter } from "./instrumentation.js"; import { importModule, reportRequestError } from "./instrumentation.js"; import type { NextI18nConfig } from "../config/next-config.js"; import { buildCacheStateHeaders } from "./cache-headers.js"; +import { + decideIsr, + buildMissIsrCacheControl, + ISR_NEVER_CACHE_CONTROL, + ISR_NO_STORE_CACHE_CONTROL, +} from "./isr-decision.js"; import { isrGet, isrSet, @@ -833,8 +839,7 @@ export function createSSRHandler( (k) => k.toLowerCase() === "cache-control", ); if (!hasUserCacheControl) { - gsspExtraHeaders["Cache-Control"] = - "private, no-cache, no-store, max-age=0, must-revalidate"; + gsspExtraHeaders["Cache-Control"] = ISR_NEVER_CACHE_CONTROL; } } // Collect font preloads early so ISR cached responses can include @@ -894,10 +899,15 @@ export function createSSRHandler( const cachedHtml = cachedPage.html; const transformedHtml = await server.transformIndexHtml(url, cachedHtml); const revalidateSecs = getRevalidateDuration(cacheKey) ?? 60; + const { cacheControl: hitCacheControl } = decideIsr({ + cacheState: "HIT", + kind: "dev", + revalidateSeconds: revalidateSecs, + }); const hitHeaders: Record = { "Content-Type": "text/html", ...buildCacheStateHeaders("HIT"), - "Cache-Control": `s-maxage=${revalidateSecs}, stale-while-revalidate`, + "Cache-Control": hitCacheControl, }; if (earlyFontLinkHeader) hitHeaders["Link"] = earlyFontLinkHeader; res.writeHead(200, hitHeaders); @@ -1058,10 +1068,18 @@ export function createSSRHandler( ); const revalidateSecs = getRevalidateDuration(cacheKey) ?? 60; + // Deliberate parity fix: dev STALE now emits s-maxage=0, stale-while-revalidate + // matching prod Pages Router and the canonical buildCachedRevalidateCacheControl + // helper. Previously emitted s-maxage= which was a dev/prod divergence. + const { cacheControl: staleCacheControl } = decideIsr({ + cacheState: "STALE", + kind: "dev", + revalidateSeconds: revalidateSecs, + }); const staleHeaders: Record = { "Content-Type": "text/html", ...buildCacheStateHeaders("STALE"), - "Cache-Control": `s-maxage=${revalidateSecs}, stale-while-revalidate`, + "Cache-Control": staleCacheControl, }; if (earlyFontLinkHeader) staleHeaders["Link"] = earlyFontLinkHeader; res.writeHead(200, staleHeaders); @@ -1378,10 +1396,9 @@ hydrate(); }; if (isrRevalidateSeconds) { if (scriptNonce) { - extraHeaders["Cache-Control"] = "no-store, must-revalidate"; + extraHeaders["Cache-Control"] = ISR_NO_STORE_CACHE_CONTROL; } else { - extraHeaders["Cache-Control"] = - `s-maxage=${isrRevalidateSeconds}, stale-while-revalidate`; + extraHeaders["Cache-Control"] = buildMissIsrCacheControl(isrRevalidateSeconds); Object.assign(extraHeaders, buildCacheStateHeaders("MISS")); } } diff --git a/packages/vinext/src/server/isr-decision.ts b/packages/vinext/src/server/isr-decision.ts new file mode 100644 index 000000000..c76908873 --- /dev/null +++ b/packages/vinext/src/server/isr-decision.ts @@ -0,0 +1,183 @@ +/** + * Centralised ISR `Cache-Control` derivation module. + * + * `decideIsr` is the single place that maps (router kind, cache state, + * revalidate/expire metadata) → the exact `Cache-Control` string to stamp on + * an ISR response. Every ISR code path (app-page, app-route, pages, + * dev-server) routes through it. + * + * `disposition` and `scheduleRegeneration` are informational fields for + * callers that want them; all current callers only read `cacheControl`. + */ + +import type { CacheControlMetadata } from "vinext/shims/cache"; +import { + buildCachedRevalidateCacheControl, + buildRevalidateCacheControl, + NEVER_CACHE_CONTROL, + NO_STORE_CACHE_CONTROL, + STATIC_CACHE_CONTROL, +} from "./cache-control.js"; + +type IsrDisposition = "HIT" | "STALE" | "MISS"; + +export type IsrDecision = { + disposition: IsrDisposition; + /** True when the caller must schedule a background regeneration. */ + scheduleRegeneration: boolean; + /** The `Cache-Control` string to stamp on the response. */ + cacheControl: string; +}; + +/** + * Per-router special-case policies for `Cache-Control`. + * + * - `"app-page"` / `"pages"`: `buildCachedRevalidateCacheControl` for HIT/STALE. + * - `"app-route"`: same, but `revalidateSeconds=0` forces `NEVER_CACHE_CONTROL` + * and `revalidateSeconds=Infinity` forces `STATIC_CACHE_CONTROL`. + * - `"dev"`: like `"pages"`, but `revalidate=0`/`Infinity` guards are absent + * (dev never caches when revalidate=0 and never has Infinity entries in practice). + */ +type IsrPolicyKind = "app-page" | "app-route" | "pages" | "dev"; + +type DecideIsrOptions = { + /** + * The cache state. Content guards (kind-mismatch, empty body, + * query-variant-unproven) must have already passed before passing + * `"HIT"` or `"STALE"` here. + */ + cacheState: "HIT" | "STALE" | "MISS"; + /** Which router is making the decision. */ + kind: IsrPolicyKind; + /** + * The route's configured revalidate window in seconds. Used as the fallback + * when `cacheControlMeta` is absent. + * + * For `"dev"` call sites this is the only source of the revalidate value — + * dev never has metadata attached to a cache entry. + */ + revalidateSeconds: number; + /** + * The expire ceiling (seconds from epoch) read from the route config. + * Absent when the route pre-dates expire metadata support. + */ + expireSeconds?: number; + /** + * Optional per-entry metadata written alongside the cache value. + * When present its `revalidate`/`expire` fields override the route defaults, + * exactly as the call sites do today with `cacheControl?.revalidate ?? revalidateSeconds`. + */ + cacheControlMeta?: CacheControlMetadata; +}; + +/** Resolve effective revalidate/expire, preferring per-entry metadata. */ +function resolveRevalidate(options: DecideIsrOptions): { + effectiveRevalidate: number; + effectiveExpire: number | undefined; +} { + const effectiveRevalidate = options.cacheControlMeta?.revalidate ?? options.revalidateSeconds; + // `expireSeconds` is the route-level config fallback. It is only meaningful + // when per-entry metadata is present — it acts as the fallback for entries + // written before expire support was added. When `cacheControlMeta` is absent + // entirely, the expire ceiling is unknown (undefined), matching the + // original per-call-site logic: + // + // const expire = options.cacheControl === undefined + // ? undefined + // : (options.cacheControl.expire ?? options.expireSeconds); + const effectiveExpire = + options.cacheControlMeta === undefined + ? undefined + : (options.cacheControlMeta.expire ?? options.expireSeconds); + return { effectiveRevalidate, effectiveExpire }; +} + +function buildCacheControl( + disposition: "HIT" | "STALE", + kind: IsrPolicyKind, + revalidate: number, + expire: number | undefined, +): string { + if (kind === "app-route") { + if (revalidate === 0) return NEVER_CACHE_CONTROL; + if (revalidate === Infinity) return STATIC_CACHE_CONTROL; + } + return buildCachedRevalidateCacheControl(disposition, revalidate, expire); +} + +/** + * Derive the `Cache-Control` string for an ISR response. + * + * Content guards (kind mismatch, query-variant-unproven, empty body) are the + * caller's responsibility and must happen *before* this call. `cacheState` + * must only be `"HIT"` or `"STALE"` when those guards have already passed. + */ +export function decideIsr(options: DecideIsrOptions): IsrDecision { + if (options.cacheState === "MISS") { + return { disposition: "MISS", scheduleRegeneration: false, cacheControl: "" }; + } + + const { effectiveRevalidate, effectiveExpire } = resolveRevalidate(options); + + if (options.cacheState === "HIT") { + return { + disposition: "HIT", + scheduleRegeneration: false, + cacheControl: buildCacheControl("HIT", options.kind, effectiveRevalidate, effectiveExpire), + }; + } + + // STALE: serve + schedule regen. + return { + disposition: "STALE", + scheduleRegeneration: true, + cacheControl: buildCacheControl("STALE", options.kind, effectiveRevalidate, effectiveExpire), + }; +} + +/** + * Build the `Cache-Control` string for a fresh MISS response whose ISR policy + * is known (i.e. revalidate is set and > 0). Uses the unbounded SWR form when + * no expire ceiling is available, exactly as `buildRevalidateCacheControl` does. + * + * Separate from `decideIsr` because a MISS doesn't read a cache entry and + * therefore never has `cacheControlMeta`. `expireSeconds` here is the route + * config ceiling passed directly from the caller (not a per-entry fallback). + */ +export function buildMissIsrCacheControl( + revalidateSeconds: number, + expireSeconds?: number, +): string { + return buildRevalidateCacheControl(revalidateSeconds, expireSeconds); +} + +/** + * Build the `Cache-Control` string for a fresh (MISS) app-route response. + * + * Applies the same `revalidateSeconds=0`→NEVER and `Infinity`→STATIC gates + * that `decideIsr` uses for app-route cached responses. `expireSeconds` is + * the route config ceiling passed directly (not per-entry metadata fallback). + * + * Used by `applyRouteHandlerRevalidateHeader` which operates on a fresh + * response that has no per-entry cache metadata. + */ +export function buildAppRouteMissIsrCacheControl( + revalidateSeconds: number, + expireSeconds?: number, +): string { + if (revalidateSeconds === 0) return NEVER_CACHE_CONTROL; + if (revalidateSeconds === Infinity) return STATIC_CACHE_CONTROL; + return buildRevalidateCacheControl(revalidateSeconds, expireSeconds); +} + +/** + * The `Cache-Control` for a response that must never be cached (getServerSideProps + * default, on-demand revalidation, nonce-bearing pages). Matches `NEVER_CACHE_CONTROL`. + */ +export { NEVER_CACHE_CONTROL as ISR_NEVER_CACHE_CONTROL }; + +/** + * The `Cache-Control` for a nonce-bearing ISR response (the page has a + * script nonce, so it must not enter any shared cache). Matches `NO_STORE_CACHE_CONTROL`. + */ +export { NO_STORE_CACHE_CONTROL as ISR_NO_STORE_CACHE_CONTROL }; diff --git a/packages/vinext/src/server/pages-page-data.ts b/packages/vinext/src/server/pages-page-data.ts index 5d2e93c02..156ddf648 100644 --- a/packages/vinext/src/server/pages-page-data.ts +++ b/packages/vinext/src/server/pages-page-data.ts @@ -3,7 +3,8 @@ import type { VinextNextData } from "../client/vinext-next-data.js"; import type { Route } from "../routing/pages-router.js"; import { normalizeStaticPathname } from "../routing/route-pattern.js"; import type { CachedPagesValue, CacheControlMetadata } from "vinext/shims/cache"; -import { applyCdnResponseHeaders, buildCachedRevalidateCacheControl } from "./cache-control.js"; +import { applyCdnResponseHeaders } from "./cache-control.js"; +import { decideIsr } from "./isr-decision.js"; import { buildCacheStateHeaders } from "./cache-headers.js"; import { buildPagesCacheValue, type ISRCacheEntry } from "./isr-cache.js"; import { @@ -341,23 +342,22 @@ function buildPagesCacheResponse( // Legacy cache entries written before cacheControl metadata existed can still // hit this path without a persisted revalidate value; keep the historic // 60-second fallback for that migration window. - const effectiveRevalidateSeconds = cacheControl?.revalidate ?? revalidateSeconds ?? 60; - const effectiveExpireSeconds = - cacheControl === undefined ? undefined : (cacheControl.expire ?? expireSeconds); + const effectiveRevalidateSeconds = revalidateSeconds ?? 60; // HIT/STALE served from the origin store: route the cache header through the // CDN adapter (default: identical single Cache-Control). Edge adapters never // reach this path because their get() returns null. + const { cacheControl: cacheControlHeader } = decideIsr({ + cacheState, + kind: "pages", + revalidateSeconds: effectiveRevalidateSeconds, + expireSeconds, + cacheControlMeta: cacheControl, + }); const headers = new Headers({ "Content-Type": "text/html", ...buildCacheStateHeaders(cacheState), }); - applyCdnResponseHeaders(headers, { - cacheControl: buildCachedRevalidateCacheControl( - cacheState, - effectiveRevalidateSeconds, - effectiveExpireSeconds, - ), - }); + applyCdnResponseHeaders(headers, { cacheControl: cacheControlHeader }); if (fontLinkHeader) { headers.set("Link", fontLinkHeader); diff --git a/packages/vinext/src/server/pages-page-handler.ts b/packages/vinext/src/server/pages-page-handler.ts index a40d2d102..17e8128a4 100644 --- a/packages/vinext/src/server/pages-page-handler.ts +++ b/packages/vinext/src/server/pages-page-handler.ts @@ -44,6 +44,7 @@ import { createRequestContext, runWithRequestContext } from "vinext/shims/unifie import { getRequestExecutionContext } from "vinext/shims/request-context"; import { ensureFetchPatch } from "vinext/shims/fetch-cache"; import { collectAssetTags, resolveClientModuleUrl } from "./pages-asset-tags.js"; +import { ISR_NEVER_CACHE_CONTROL } from "./isr-decision.js"; // --------------------------------------------------------------------------- // Types @@ -606,8 +607,7 @@ export function createPagesPageHandler( } } if (!hasUserCacheControl) { - init.headers["Cache-Control"] = - "private, no-cache, no-store, max-age=0, must-revalidate"; + init.headers["Cache-Control"] = ISR_NEVER_CACHE_CONTROL; } } return buildNextDataJsonResponse(pageProps, safeJsonStringify, init); diff --git a/packages/vinext/src/server/pages-page-response.ts b/packages/vinext/src/server/pages-page-response.ts index f192c8fa3..6da7ecc33 100644 --- a/packages/vinext/src/server/pages-page-response.ts +++ b/packages/vinext/src/server/pages-page-response.ts @@ -3,7 +3,12 @@ import type { VinextNextData } from "../client/vinext-next-data.js"; import type { CachedPagesValue } from "vinext/shims/cache"; import { withScriptNonce } from "vinext/shims/script-nonce-context"; import { getRequestExecutionContext } from "vinext/shims/request-context"; -import { applyCdnResponseHeaders, buildRevalidateCacheControl } from "./cache-control.js"; +import { applyCdnResponseHeaders } from "./cache-control.js"; +import { + buildMissIsrCacheControl, + ISR_NEVER_CACHE_CONTROL, + ISR_NO_STORE_CACHE_CONTROL, +} from "./isr-decision.js"; import { encodeCacheTag } from "../utils/encode-cache-tag.js"; import { setCacheStateHeaders } from "./cache-headers.js"; import { createInlineScriptTag, createNonceAttribute, escapeHtmlAttr } from "./html.js"; @@ -532,7 +537,7 @@ export async function renderPagesPageResponse( const userSetCacheControl = responseHeaders.has("Cache-Control"); if (options.scriptNonce) { - responseHeaders.set("Cache-Control", "no-store, must-revalidate"); + responseHeaders.set("Cache-Control", ISR_NO_STORE_CACHE_CONTROL); } else if (options.isrRevalidateSeconds) { // Fresh ISR (MISS) response: route through the CDN adapter so edge adapters // emit CDN-Cache-Control + a path-based Cache-Tag (matching revalidatePath, @@ -540,10 +545,7 @@ export async function renderPagesPageResponse( const isrPathname = options.routeUrl.split("?")[0]; const stem = isrPathname.endsWith("/") ? isrPathname.slice(0, -1) : isrPathname; applyCdnResponseHeaders(responseHeaders, { - cacheControl: buildRevalidateCacheControl( - options.isrRevalidateSeconds, - options.expireSeconds, - ), + cacheControl: buildMissIsrCacheControl(options.isrRevalidateSeconds, options.expireSeconds), tags: [encodeCacheTag(`_N_T_${stem || "/"}`)], }); setCacheStateHeaders(responseHeaders, "MISS"); @@ -551,7 +553,7 @@ export async function renderPagesPageResponse( // Default for getServerSideProps responses, matching Next.js // pages-handler.ts (revalidate: 0 → getCacheControlHeader). Without this, // CDNs and browsers could cache per-request gssp responses. - responseHeaders.set("Cache-Control", "private, no-cache, no-store, max-age=0, must-revalidate"); + responseHeaders.set("Cache-Control", ISR_NEVER_CACHE_CONTROL); } if (options.fontLinkHeader) { responseHeaders.set("Link", options.fontLinkHeader); diff --git a/tests/isr-decision.test.ts b/tests/isr-decision.test.ts new file mode 100644 index 000000000..f17d66def --- /dev/null +++ b/tests/isr-decision.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + decideIsr, + buildMissIsrCacheControl, + buildAppRouteMissIsrCacheControl, + ISR_NEVER_CACHE_CONTROL, + ISR_NO_STORE_CACHE_CONTROL, +} from "../packages/vinext/src/server/isr-decision.js"; + +// ─── MISS ──────────────────────────────────────────────────────────────────── + +describe("decideIsr — MISS", () => { + it("returns MISS when cacheState is MISS", () => { + const d = decideIsr({ + cacheState: "MISS", + kind: "app-page", + revalidateSeconds: 60, + }); + expect(d.disposition).toBe("MISS"); + expect(d.scheduleRegeneration).toBe(false); + expect(d.cacheControl).toBe(""); + }); + + it("returns MISS for MISS cacheState regardless of route kind", () => { + const d = decideIsr({ + cacheState: "MISS", + kind: "pages", + revalidateSeconds: 60, + }); + expect(d.disposition).toBe("MISS"); + expect(d.scheduleRegeneration).toBe(false); + }); +}); + +// ─── HIT ───────────────────────────────────────────────────────────────────── + +describe("decideIsr — HIT", () => { + it("app-page HIT without expire: unbounded SWR", () => { + const d = decideIsr({ + cacheState: "HIT", + kind: "app-page", + revalidateSeconds: 60, + }); + expect(d.disposition).toBe("HIT"); + expect(d.scheduleRegeneration).toBe(false); + expect(d.cacheControl).toBe("s-maxage=60, stale-while-revalidate"); + }); + + it("app-page HIT with expireSeconds only (no cacheControlMeta): expire is unknown, unbounded SWR", () => { + // expireSeconds is only a fallback for cacheControlMeta. Without metadata, + // the expire ceiling is unknown and the unbounded form is used. + const d = decideIsr({ + cacheState: "HIT", + kind: "app-page", + revalidateSeconds: 60, + expireSeconds: 300, + }); + expect(d.cacheControl).toBe("s-maxage=60, stale-while-revalidate"); + }); + + it("app-page HIT with cacheControlMeta including expire: finite SWR window", () => { + const d = decideIsr({ + cacheState: "HIT", + kind: "app-page", + revalidateSeconds: 60, + expireSeconds: 300, + cacheControlMeta: { revalidate: 60, expire: 300 }, + }); + expect(d.cacheControl).toBe("s-maxage=60, stale-while-revalidate=240"); + }); + + it("app-page HIT: prefers cacheControlMeta revalidate over route default", () => { + const d = decideIsr({ + cacheState: "HIT", + kind: "app-page", + revalidateSeconds: 60, + cacheControlMeta: { revalidate: 30 }, + }); + expect(d.cacheControl).toBe("s-maxage=30, stale-while-revalidate"); + }); + + it("app-page HIT: cacheControlMeta expire overrides route expireSeconds", () => { + const d = decideIsr({ + cacheState: "HIT", + kind: "app-page", + revalidateSeconds: 60, + expireSeconds: 999, + cacheControlMeta: { revalidate: 60, expire: 300 }, + }); + expect(d.cacheControl).toBe("s-maxage=60, stale-while-revalidate=240"); + }); + + it("app-page HIT: cacheControlMeta present but no expire — expireSeconds used as fallback", () => { + const d = decideIsr({ + cacheState: "HIT", + kind: "app-page", + revalidateSeconds: 60, + expireSeconds: 300, + cacheControlMeta: { revalidate: 60 }, + }); + expect(d.cacheControl).toBe("s-maxage=60, stale-while-revalidate=240"); + }); + + it("pages HIT without cacheControlMeta: unbounded SWR", () => { + const d = decideIsr({ + cacheState: "HIT", + kind: "pages", + revalidateSeconds: 60, + }); + expect(d.cacheControl).toBe("s-maxage=60, stale-while-revalidate"); + }); + + it("pages HIT with cacheControlMeta and expire: finite SWR window", () => { + const d = decideIsr({ + cacheState: "HIT", + kind: "pages", + revalidateSeconds: 60, + cacheControlMeta: { revalidate: 60, expire: 300 }, + }); + expect(d.cacheControl).toBe("s-maxage=60, stale-while-revalidate=240"); + }); + + it("app-route HIT finite revalidate (no metadata): uses route policy, unbounded SWR", () => { + const d = decideIsr({ + cacheState: "HIT", + kind: "app-route", + revalidateSeconds: 60, + }); + expect(d.cacheControl).toBe("s-maxage=60, stale-while-revalidate"); + }); + + it("app-route HIT revalidate=0: emits NEVER_CACHE_CONTROL", () => { + const d = decideIsr({ + cacheState: "HIT", + kind: "app-route", + revalidateSeconds: 0, + }); + expect(d.cacheControl).toBe(ISR_NEVER_CACHE_CONTROL); + }); + + it("app-route HIT revalidate=Infinity: emits STATIC_CACHE_CONTROL", () => { + const d = decideIsr({ + cacheState: "HIT", + kind: "app-route", + revalidateSeconds: Infinity, + }); + expect(d.cacheControl).toBe("s-maxage=31536000, stale-while-revalidate"); + }); + + it("app-route HIT: cacheControlMeta revalidate=0 wins over route default", () => { + const d = decideIsr({ + cacheState: "HIT", + kind: "app-route", + revalidateSeconds: 60, + cacheControlMeta: { revalidate: 0 }, + }); + expect(d.cacheControl).toBe(ISR_NEVER_CACHE_CONTROL); + }); + + it("dev HIT: unbounded SWR (no special gates)", () => { + const d = decideIsr({ + cacheState: "HIT", + kind: "dev", + revalidateSeconds: 60, + }); + expect(d.disposition).toBe("HIT"); + expect(d.cacheControl).toBe("s-maxage=60, stale-while-revalidate"); + }); +}); + +// ─── STALE ─────────────────────────────────────────────────────────────────── + +describe("decideIsr — STALE", () => { + it("app-page STALE without expire: s-maxage=0 (canonical STALE fallback)", () => { + const d = decideIsr({ + cacheState: "STALE", + kind: "app-page", + revalidateSeconds: 60, + }); + expect(d.disposition).toBe("STALE"); + expect(d.scheduleRegeneration).toBe(true); + expect(d.cacheControl).toBe("s-maxage=0, stale-while-revalidate"); + }); + + it("app-page STALE with cacheControlMeta and expire: uses route policy (same as HIT)", () => { + const d = decideIsr({ + cacheState: "STALE", + kind: "app-page", + revalidateSeconds: 60, + cacheControlMeta: { revalidate: 60, expire: 300 }, + }); + expect(d.cacheControl).toBe("s-maxage=60, stale-while-revalidate=240"); + }); + + it("app-page STALE: scheduleRegeneration is true", () => { + const d = decideIsr({ + cacheState: "STALE", + kind: "app-page", + revalidateSeconds: 60, + }); + expect(d.scheduleRegeneration).toBe(true); + }); + + it("pages STALE without expire: s-maxage=0", () => { + const d = decideIsr({ + cacheState: "STALE", + kind: "pages", + revalidateSeconds: 60, + }); + expect(d.cacheControl).toBe("s-maxage=0, stale-while-revalidate"); + }); + + it("pages STALE with cacheControlMeta and expire: finite SWR window", () => { + const d = decideIsr({ + cacheState: "STALE", + kind: "pages", + revalidateSeconds: 15, + cacheControlMeta: { revalidate: 15, expire: 300 }, + }); + expect(d.cacheControl).toBe("s-maxage=15, stale-while-revalidate=285"); + }); + + it("app-route STALE revalidate=0: NEVER_CACHE_CONTROL", () => { + const d = decideIsr({ + cacheState: "STALE", + kind: "app-route", + revalidateSeconds: 0, + }); + expect(d.cacheControl).toBe(ISR_NEVER_CACHE_CONTROL); + }); + + it("app-route STALE revalidate=Infinity: STATIC_CACHE_CONTROL", () => { + const d = decideIsr({ + cacheState: "STALE", + kind: "app-route", + revalidateSeconds: Infinity, + }); + expect(d.cacheControl).toBe("s-maxage=31536000, stale-while-revalidate"); + }); + + it("app-route STALE finite (no cacheControlMeta): s-maxage=0 (STALE fallback, expire unknown)", () => { + const d = decideIsr({ + cacheState: "STALE", + kind: "app-route", + revalidateSeconds: 60, + }); + expect(d.cacheControl).toBe("s-maxage=0, stale-while-revalidate"); + }); + + it("dev STALE: s-maxage=0 (deliberate parity fix, now matches prod Pages Router)", () => { + // Previously emitted `s-maxage=, stale-while-revalidate`. Aligned to + // the canonical buildCachedRevalidateCacheControl("STALE", secs) result which + // is `s-maxage=0, stale-while-revalidate` when expire is absent, matching prod. + const d = decideIsr({ + cacheState: "STALE", + kind: "dev", + revalidateSeconds: 60, + }); + expect(d.disposition).toBe("STALE"); + expect(d.scheduleRegeneration).toBe(true); + expect(d.cacheControl).toBe("s-maxage=0, stale-while-revalidate"); + }); +}); + +// ─── buildMissIsrCacheControl ───────────────────────────────────────────────── + +describe("buildMissIsrCacheControl", () => { + it("without expire: unbounded SWR", () => { + expect(buildMissIsrCacheControl(60)).toBe("s-maxage=60, stale-while-revalidate"); + }); + + it("with expire: finite SWR window", () => { + expect(buildMissIsrCacheControl(60, 300)).toBe("s-maxage=60, stale-while-revalidate=240"); + }); + + it("expire <= revalidate: no SWR suffix", () => { + expect(buildMissIsrCacheControl(300, 300)).toBe("s-maxage=300"); + }); +}); + +// ─── buildAppRouteMissIsrCacheControl ───────────────────────────────────────── + +describe("buildAppRouteMissIsrCacheControl", () => { + it("revalidate=0: NEVER_CACHE_CONTROL", () => { + expect(buildAppRouteMissIsrCacheControl(0)).toBe(ISR_NEVER_CACHE_CONTROL); + }); + + it("revalidate=Infinity: STATIC_CACHE_CONTROL", () => { + expect(buildAppRouteMissIsrCacheControl(Infinity)).toBe( + "s-maxage=31536000, stale-while-revalidate", + ); + }); + + it("finite revalidate with expire: finite SWR window", () => { + expect(buildAppRouteMissIsrCacheControl(60, 600)).toBe( + "s-maxage=60, stale-while-revalidate=540", + ); + }); + + it("finite revalidate without expire: unbounded SWR", () => { + expect(buildAppRouteMissIsrCacheControl(60)).toBe("s-maxage=60, stale-while-revalidate"); + }); +}); + +// ─── re-exported constants ──────────────────────────────────────────────────── + +describe("ISR_NEVER_CACHE_CONTROL / ISR_NO_STORE_CACHE_CONTROL", () => { + it("ISR_NEVER_CACHE_CONTROL matches the canonical value", () => { + expect(ISR_NEVER_CACHE_CONTROL).toBe("private, no-cache, no-store, max-age=0, must-revalidate"); + }); + + it("ISR_NO_STORE_CACHE_CONTROL matches the canonical value", () => { + expect(ISR_NO_STORE_CACHE_CONTROL).toBe("no-store, must-revalidate"); + }); +});