From 2dfb08a85be2dcfc71619fa318e6db434d77d442 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 9 Jun 2026 08:28:11 -0700 Subject: [PATCH 1/5] refactor(isr): centralise HIT/STALE/MISS + Cache-Control decision in isr-decision.ts Add server/isr-decision.ts as the single owner of the ISR cache policy decision across all four call sites (app-page-cache, app-route-handler, pages-page-data, dev-server). Every cache disposition, background-regen flag, and Cache-Control string now flows through decideIsr() or its MISS helpers. Also eliminate all hardcoded Cache-Control literals outside cache-control.ts (NEVER_CACHE_CONTROL, NO_STORE_CACHE_CONTROL, and raw s-maxage strings in dev-server.ts, pages-page-response.ts, pages-page-handler.ts). One deliberate behaviour change: dev STALE responses now emit s-maxage=0, stale-while-revalidate (matching prod Pages Router) instead of s-maxage=, stale-while-revalidate. This closes a dev/prod parity gap that was masked by the duplication. Closes #1783 --- packages/vinext/src/server/app-page-cache.ts | 29 +- .../src/server/app-route-handler-response.ts | 54 +-- packages/vinext/src/server/dev-server.ts | 33 +- packages/vinext/src/server/isr-decision.ts | 220 ++++++++++++ packages/vinext/src/server/pages-page-data.ts | 23 +- .../vinext/src/server/pages-page-handler.ts | 4 +- .../vinext/src/server/pages-page-response.ts | 16 +- tests/isr-decision.test.ts | 339 ++++++++++++++++++ 8 files changed, 632 insertions(+), 86 deletions(-) create mode 100644 packages/vinext/src/server/isr-decision.ts create mode 100644 tests/isr-decision.test.ts diff --git a/packages/vinext/src/server/app-page-cache.ts b/packages/vinext/src/server/app-page-cache.ts index b75953c26..0c15bc619 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,14 @@ 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({ + hasUsableValue: true, + isStale: options.cacheState === "STALE", + 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..5ffd7bb6e 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,18 @@ 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({ + hasUsableValue: true, + isStale: options.cacheState === "STALE", + 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 +116,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..1794dd18f 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,16 @@ export function createSSRHandler( const cachedHtml = cachedPage.html; const transformedHtml = await server.transformIndexHtml(url, cachedHtml); const revalidateSecs = getRevalidateDuration(cacheKey) ?? 60; + const { cacheControl: hitCacheControl } = decideIsr({ + hasUsableValue: true, + isStale: false, + 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 +1069,19 @@ 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({ + hasUsableValue: true, + isStale: true, + 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 +1398,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..eaada8bcc --- /dev/null +++ b/packages/vinext/src/server/isr-decision.ts @@ -0,0 +1,220 @@ +/** + * Centralised ISR cache-decision module. + * + * The HIT/STALE/MISS disposition, the `scheduleRegeneration` flag, and the + * `Cache-Control` string are all derived here. No caller may produce these + * values independently — every ISR code path (app-page, app-route, pages, + * dev-server) routes through `decideIsr`. + * + * ## Equivalence table + * + * Each call site previously derived its own disposition + Cache-Control. + * This table documents what the migrated path emits vs what it emitted before. + * + * | Call site | Before | After | Changed? | + * |----------------------------------------|--------------------------------------------------|--------------------|----------| + * | app-page HIT | buildCachedRevalidateCacheControl("HIT", r, e) | same | no | + * | app-page STALE (expire known) | buildCachedRevalidateCacheControl("STALE", r, e) | same | no | + * | app-page STALE (no expire) | buildCachedRevalidateCacheControl("STALE", r) | same → s-maxage=0 | no | + * | app-route HIT/STALE (revalidate=0) | NEVER_CACHE_CONTROL | same | no | + * | app-route HIT/STALE (revalidate=∞) | STATIC_CACHE_CONTROL | same | no | + * | app-route HIT/STALE (finite) | buildCachedRevalidateCacheControl(state, r, e) | same | no | + * | pages HIT | buildCachedRevalidateCacheControl("HIT", r, e) | same | no | + * | pages STALE (expire known) | buildCachedRevalidateCacheControl("STALE", r, e) | same | no | + * | pages STALE (no expire) | buildCachedRevalidateCacheControl("STALE", r) | same → s-maxage=0 | no | + * | dev HIT (getStaticProps) | s-maxage=${secs}, stale-while-revalidate | same | no | + * | dev STALE (getStaticProps) | s-maxage=${secs}, stale-while-revalidate | s-maxage=0, ... | **yes** | + * | dev MISS/regen (getStaticProps) | s-maxage=${secs}, stale-while-revalidate | same | no | + * | dev gssp default (no-store) | private, no-cache, no-store, max-age=0... | same (NEVER_CACHE) | no | + * | dev nonce (no-store) | no-store, must-revalidate | same (NO_STORE) | no | + * | pages-page-response scriptNonce | no-store, must-revalidate | same (NO_STORE) | no | + * | pages-page-response gssp default | private, no-cache, no-store... | same (NEVER_CACHE) | no | + * | pages-page-handler _next/data default | private, no-cache, no-store... | same (NEVER_CACHE) | no | + * + * The single deliberate change: dev STALE now emits `s-maxage=0, + * stale-while-revalidate` (matching the prod Pages Router and the canonical + * `buildCachedRevalidateCacheControl` helper) instead of `s-maxage=, + * stale-while-revalidate`. Dev had no CDN in front and was the only path + * treating a stale-served payload as freshly cacheable downstream — a + * dev/prod parity gap, not intentional behaviour. + */ + +import type { CacheControlMetadata } from "vinext/shims/cache"; +import { + buildCachedRevalidateCacheControl, + buildRevalidateCacheControl, + NEVER_CACHE_CONTROL, + NO_STORE_CACHE_CONTROL, + STATIC_CACHE_CONTROL, +} from "./cache-control.js"; + +export 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). + */ +export type IsrPolicyKind = "app-page" | "app-route" | "pages" | "dev"; + +type DecideIsrOptions = { + /** + * True when the cache returned a value that can be forwarded to the client + * (the content guards — kind-mismatch, empty body, query-variant-unproven — + * have already passed). MISS = false; HIT or STALE = true. + */ + hasUsableValue: boolean; + /** + * True when the cache entry is past its TTL and the caller must regenerate. + * Only meaningful when `hasUsableValue` is true. + */ + isStale: boolean; + /** 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); +} + +/** + * Make the ISR cache policy decision. + * + * Returns the disposition, whether the caller must schedule a background + * regeneration, and the exact `Cache-Control` string to apply to the response. + * + * Content guards (kind mismatch, query-variant-unproven, empty body) are the + * caller's responsibility and must happen *before* this call. `hasUsableValue` + * must only be true when those guards have already passed. + */ +export function decideIsr(options: DecideIsrOptions): IsrDecision { + if (!options.hasUsableValue) { + return { disposition: "MISS", scheduleRegeneration: false, cacheControl: "" }; + } + + if (!options.isStale) { + const { effectiveRevalidate, effectiveExpire } = resolveRevalidate(options); + return { + disposition: "HIT", + scheduleRegeneration: false, + cacheControl: buildCacheControl("HIT", options.kind, effectiveRevalidate, effectiveExpire), + }; + } + + // Stale: serve + schedule regen. + const { effectiveRevalidate, effectiveExpire } = resolveRevalidate(options); + 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..88962f274 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,23 @@ 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({ + hasUsableValue: true, + isStale: cacheState === "STALE", + 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..21f411c8d --- /dev/null +++ b/tests/isr-decision.test.ts @@ -0,0 +1,339 @@ +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 hasUsableValue is false", () => { + const d = decideIsr({ + hasUsableValue: false, + isStale: false, + kind: "app-page", + revalidateSeconds: 60, + }); + expect(d.disposition).toBe("MISS"); + expect(d.scheduleRegeneration).toBe(false); + expect(d.cacheControl).toBe(""); + }); + + it("returns MISS even if isStale is true when hasUsableValue is false", () => { + const d = decideIsr({ + hasUsableValue: false, + isStale: true, + 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({ + hasUsableValue: true, + isStale: false, + 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({ + hasUsableValue: true, + isStale: false, + 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({ + hasUsableValue: true, + isStale: false, + 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({ + hasUsableValue: true, + isStale: false, + 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({ + hasUsableValue: true, + isStale: false, + 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({ + hasUsableValue: true, + isStale: false, + 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({ + hasUsableValue: true, + isStale: false, + 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({ + hasUsableValue: true, + isStale: false, + 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({ + hasUsableValue: true, + isStale: false, + 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({ + hasUsableValue: true, + isStale: false, + 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({ + hasUsableValue: true, + isStale: false, + 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({ + hasUsableValue: true, + isStale: false, + 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({ + hasUsableValue: true, + isStale: false, + 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({ + hasUsableValue: true, + isStale: true, + 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({ + hasUsableValue: true, + isStale: true, + 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({ + hasUsableValue: true, + isStale: true, + kind: "app-page", + revalidateSeconds: 60, + }); + expect(d.scheduleRegeneration).toBe(true); + }); + + it("pages STALE without expire: s-maxage=0", () => { + const d = decideIsr({ + hasUsableValue: true, + isStale: true, + 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({ + hasUsableValue: true, + isStale: true, + 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({ + hasUsableValue: true, + isStale: true, + 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({ + hasUsableValue: true, + isStale: true, + 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({ + hasUsableValue: true, + isStale: true, + 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({ + hasUsableValue: true, + isStale: true, + 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"); + }); +}); From 26c2a23ced8bad6bb9d1335b42a220ffd88a539d Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 9 Jun 2026 09:45:33 -0700 Subject: [PATCH 2/5] fix(isr): un-export IsrDisposition and IsrPolicyKind (knip unused-export) --- packages/vinext/src/server/isr-decision.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/server/isr-decision.ts b/packages/vinext/src/server/isr-decision.ts index eaada8bcc..6a282781c 100644 --- a/packages/vinext/src/server/isr-decision.ts +++ b/packages/vinext/src/server/isr-decision.ts @@ -48,7 +48,7 @@ import { STATIC_CACHE_CONTROL, } from "./cache-control.js"; -export type IsrDisposition = "HIT" | "STALE" | "MISS"; +type IsrDisposition = "HIT" | "STALE" | "MISS"; export type IsrDecision = { disposition: IsrDisposition; @@ -67,7 +67,7 @@ export type IsrDecision = { * - `"dev"`: like `"pages"`, but `revalidate=0`/`Infinity` guards are absent * (dev never caches when revalidate=0 and never has Infinity entries in practice). */ -export type IsrPolicyKind = "app-page" | "app-route" | "pages" | "dev"; +type IsrPolicyKind = "app-page" | "app-route" | "pages" | "dev"; type DecideIsrOptions = { /** From 072fe616861cc989c9e6c36bd16779efd121b587 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Wed, 10 Jun 2026 06:29:02 -0700 Subject: [PATCH 3/5] docs(isr): tighten isr-decision docstring and hoist resolveRevalidate --- packages/vinext/src/server/isr-decision.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/vinext/src/server/isr-decision.ts b/packages/vinext/src/server/isr-decision.ts index 6a282781c..03946d63b 100644 --- a/packages/vinext/src/server/isr-decision.ts +++ b/packages/vinext/src/server/isr-decision.ts @@ -1,10 +1,13 @@ /** - * Centralised ISR cache-decision module. + * Centralised ISR `Cache-Control` derivation module. * - * The HIT/STALE/MISS disposition, the `scheduleRegeneration` flag, and the - * `Cache-Control` string are all derived here. No caller may produce these - * values independently — every ISR code path (app-page, app-route, pages, - * dev-server) routes through `decideIsr`. + * `decideIsr` is the single place that maps (router kind, hit/stale, + * 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`. * * ## Equivalence table * @@ -140,10 +143,7 @@ function buildCacheControl( } /** - * Make the ISR cache policy decision. - * - * Returns the disposition, whether the caller must schedule a background - * regeneration, and the exact `Cache-Control` string to apply to the response. + * 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. `hasUsableValue` @@ -154,8 +154,9 @@ export function decideIsr(options: DecideIsrOptions): IsrDecision { return { disposition: "MISS", scheduleRegeneration: false, cacheControl: "" }; } + const { effectiveRevalidate, effectiveExpire } = resolveRevalidate(options); + if (!options.isStale) { - const { effectiveRevalidate, effectiveExpire } = resolveRevalidate(options); return { disposition: "HIT", scheduleRegeneration: false, @@ -164,7 +165,6 @@ export function decideIsr(options: DecideIsrOptions): IsrDecision { } // Stale: serve + schedule regen. - const { effectiveRevalidate, effectiveExpire } = resolveRevalidate(options); return { disposition: "STALE", scheduleRegeneration: true, From a571a1be795a4e6d132468392b66c78563bac49b Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Wed, 10 Jun 2026 06:51:39 -0700 Subject: [PATCH 4/5] refactor(isr): pass cacheState directly to decideIsr, drop hasUsableValue/isStale Replace the two-field hasUsableValue+isStale decomposition with a single cacheState: "HIT" | "STALE" | "MISS" parameter that callers already have. Also remove the before/after equivalence table from the module docstring, which is only relevant during the migration window. All five call sites updated; tsc --noEmit passes with no errors. --- packages/vinext/src/server/app-page-cache.ts | 3 +- .../src/server/app-route-handler-response.ts | 3 +- packages/vinext/src/server/dev-server.ts | 6 +- packages/vinext/src/server/isr-decision.ts | 57 ++++--------------- packages/vinext/src/server/pages-page-data.ts | 3 +- 5 files changed, 15 insertions(+), 57 deletions(-) diff --git a/packages/vinext/src/server/app-page-cache.ts b/packages/vinext/src/server/app-page-cache.ts index 0c15bc619..d25babce7 100644 --- a/packages/vinext/src/server/app-page-cache.ts +++ b/packages/vinext/src/server/app-page-cache.ts @@ -272,8 +272,7 @@ export function buildAppPageCachedResponse( // falsy statuses still fall back to 200 rather than being forwarded through. const status = options.middlewareStatus ?? (cachedValue.status || 200); const { cacheControl } = decideIsr({ - hasUsableValue: true, - isStale: options.cacheState === "STALE", + cacheState: options.cacheState, kind: "app-page", revalidateSeconds: options.revalidateSeconds, expireSeconds: options.expireSeconds, diff --git a/packages/vinext/src/server/app-route-handler-response.ts b/packages/vinext/src/server/app-route-handler-response.ts index 5ffd7bb6e..f7f9b7bc2 100644 --- a/packages/vinext/src/server/app-route-handler-response.ts +++ b/packages/vinext/src/server/app-route-handler-response.ts @@ -93,8 +93,7 @@ export function buildRouteHandlerCachedResponse( // CDN adapter (default: identical single Cache-Control). Edge adapters never // reach this path because their get() returns null. const { cacheControl } = decideIsr({ - hasUsableValue: true, - isStale: options.cacheState === "STALE", + cacheState: options.cacheState, kind: "app-route", revalidateSeconds: options.revalidateSeconds, expireSeconds: options.expireSeconds, diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index 1794dd18f..db164c99b 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -900,8 +900,7 @@ export function createSSRHandler( const transformedHtml = await server.transformIndexHtml(url, cachedHtml); const revalidateSecs = getRevalidateDuration(cacheKey) ?? 60; const { cacheControl: hitCacheControl } = decideIsr({ - hasUsableValue: true, - isStale: false, + cacheState: "HIT", kind: "dev", revalidateSeconds: revalidateSecs, }); @@ -1073,8 +1072,7 @@ export function createSSRHandler( // matching prod Pages Router and the canonical buildCachedRevalidateCacheControl // helper. Previously emitted s-maxage= which was a dev/prod divergence. const { cacheControl: staleCacheControl } = decideIsr({ - hasUsableValue: true, - isStale: true, + cacheState: "STALE", kind: "dev", revalidateSeconds: revalidateSecs, }); diff --git a/packages/vinext/src/server/isr-decision.ts b/packages/vinext/src/server/isr-decision.ts index 03946d63b..c76908873 100644 --- a/packages/vinext/src/server/isr-decision.ts +++ b/packages/vinext/src/server/isr-decision.ts @@ -1,45 +1,13 @@ /** * Centralised ISR `Cache-Control` derivation module. * - * `decideIsr` is the single place that maps (router kind, hit/stale, + * `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`. - * - * ## Equivalence table - * - * Each call site previously derived its own disposition + Cache-Control. - * This table documents what the migrated path emits vs what it emitted before. - * - * | Call site | Before | After | Changed? | - * |----------------------------------------|--------------------------------------------------|--------------------|----------| - * | app-page HIT | buildCachedRevalidateCacheControl("HIT", r, e) | same | no | - * | app-page STALE (expire known) | buildCachedRevalidateCacheControl("STALE", r, e) | same | no | - * | app-page STALE (no expire) | buildCachedRevalidateCacheControl("STALE", r) | same → s-maxage=0 | no | - * | app-route HIT/STALE (revalidate=0) | NEVER_CACHE_CONTROL | same | no | - * | app-route HIT/STALE (revalidate=∞) | STATIC_CACHE_CONTROL | same | no | - * | app-route HIT/STALE (finite) | buildCachedRevalidateCacheControl(state, r, e) | same | no | - * | pages HIT | buildCachedRevalidateCacheControl("HIT", r, e) | same | no | - * | pages STALE (expire known) | buildCachedRevalidateCacheControl("STALE", r, e) | same | no | - * | pages STALE (no expire) | buildCachedRevalidateCacheControl("STALE", r) | same → s-maxage=0 | no | - * | dev HIT (getStaticProps) | s-maxage=${secs}, stale-while-revalidate | same | no | - * | dev STALE (getStaticProps) | s-maxage=${secs}, stale-while-revalidate | s-maxage=0, ... | **yes** | - * | dev MISS/regen (getStaticProps) | s-maxage=${secs}, stale-while-revalidate | same | no | - * | dev gssp default (no-store) | private, no-cache, no-store, max-age=0... | same (NEVER_CACHE) | no | - * | dev nonce (no-store) | no-store, must-revalidate | same (NO_STORE) | no | - * | pages-page-response scriptNonce | no-store, must-revalidate | same (NO_STORE) | no | - * | pages-page-response gssp default | private, no-cache, no-store... | same (NEVER_CACHE) | no | - * | pages-page-handler _next/data default | private, no-cache, no-store... | same (NEVER_CACHE) | no | - * - * The single deliberate change: dev STALE now emits `s-maxage=0, - * stale-while-revalidate` (matching the prod Pages Router and the canonical - * `buildCachedRevalidateCacheControl` helper) instead of `s-maxage=, - * stale-while-revalidate`. Dev had no CDN in front and was the only path - * treating a stale-served payload as freshly cacheable downstream — a - * dev/prod parity gap, not intentional behaviour. */ import type { CacheControlMetadata } from "vinext/shims/cache"; @@ -74,16 +42,11 @@ type IsrPolicyKind = "app-page" | "app-route" | "pages" | "dev"; type DecideIsrOptions = { /** - * True when the cache returned a value that can be forwarded to the client - * (the content guards — kind-mismatch, empty body, query-variant-unproven — - * have already passed). MISS = false; HIT or STALE = true. - */ - hasUsableValue: boolean; - /** - * True when the cache entry is past its TTL and the caller must regenerate. - * Only meaningful when `hasUsableValue` is true. + * The cache state. Content guards (kind-mismatch, empty body, + * query-variant-unproven) must have already passed before passing + * `"HIT"` or `"STALE"` here. */ - isStale: boolean; + cacheState: "HIT" | "STALE" | "MISS"; /** Which router is making the decision. */ kind: IsrPolicyKind; /** @@ -146,17 +109,17 @@ function buildCacheControl( * 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. `hasUsableValue` - * must only be true when those guards have already passed. + * 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.hasUsableValue) { + if (options.cacheState === "MISS") { return { disposition: "MISS", scheduleRegeneration: false, cacheControl: "" }; } const { effectiveRevalidate, effectiveExpire } = resolveRevalidate(options); - if (!options.isStale) { + if (options.cacheState === "HIT") { return { disposition: "HIT", scheduleRegeneration: false, @@ -164,7 +127,7 @@ export function decideIsr(options: DecideIsrOptions): IsrDecision { }; } - // Stale: serve + schedule regen. + // STALE: serve + schedule regen. return { disposition: "STALE", scheduleRegeneration: true, diff --git a/packages/vinext/src/server/pages-page-data.ts b/packages/vinext/src/server/pages-page-data.ts index 88962f274..156ddf648 100644 --- a/packages/vinext/src/server/pages-page-data.ts +++ b/packages/vinext/src/server/pages-page-data.ts @@ -347,8 +347,7 @@ function buildPagesCacheResponse( // CDN adapter (default: identical single Cache-Control). Edge adapters never // reach this path because their get() returns null. const { cacheControl: cacheControlHeader } = decideIsr({ - hasUsableValue: true, - isStale: cacheState === "STALE", + cacheState, kind: "pages", revalidateSeconds: effectiveRevalidateSeconds, expireSeconds, From 78c5a380742e77568e0e73c7029cb348bb2c292a Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Wed, 10 Jun 2026 07:03:57 -0700 Subject: [PATCH 5/5] test(isr): update decideIsr tests for cacheState --- tests/isr-decision.test.ts | 76 +++++++++++++------------------------- 1 file changed, 26 insertions(+), 50 deletions(-) diff --git a/tests/isr-decision.test.ts b/tests/isr-decision.test.ts index 21f411c8d..f17d66def 100644 --- a/tests/isr-decision.test.ts +++ b/tests/isr-decision.test.ts @@ -10,10 +10,9 @@ import { // ─── MISS ──────────────────────────────────────────────────────────────────── describe("decideIsr — MISS", () => { - it("returns MISS when hasUsableValue is false", () => { + it("returns MISS when cacheState is MISS", () => { const d = decideIsr({ - hasUsableValue: false, - isStale: false, + cacheState: "MISS", kind: "app-page", revalidateSeconds: 60, }); @@ -22,10 +21,9 @@ describe("decideIsr — MISS", () => { expect(d.cacheControl).toBe(""); }); - it("returns MISS even if isStale is true when hasUsableValue is false", () => { + it("returns MISS for MISS cacheState regardless of route kind", () => { const d = decideIsr({ - hasUsableValue: false, - isStale: true, + cacheState: "MISS", kind: "pages", revalidateSeconds: 60, }); @@ -39,8 +37,7 @@ describe("decideIsr — MISS", () => { describe("decideIsr — HIT", () => { it("app-page HIT without expire: unbounded SWR", () => { const d = decideIsr({ - hasUsableValue: true, - isStale: false, + cacheState: "HIT", kind: "app-page", revalidateSeconds: 60, }); @@ -53,8 +50,7 @@ describe("decideIsr — HIT", () => { // expireSeconds is only a fallback for cacheControlMeta. Without metadata, // the expire ceiling is unknown and the unbounded form is used. const d = decideIsr({ - hasUsableValue: true, - isStale: false, + cacheState: "HIT", kind: "app-page", revalidateSeconds: 60, expireSeconds: 300, @@ -64,8 +60,7 @@ describe("decideIsr — HIT", () => { it("app-page HIT with cacheControlMeta including expire: finite SWR window", () => { const d = decideIsr({ - hasUsableValue: true, - isStale: false, + cacheState: "HIT", kind: "app-page", revalidateSeconds: 60, expireSeconds: 300, @@ -76,8 +71,7 @@ describe("decideIsr — HIT", () => { it("app-page HIT: prefers cacheControlMeta revalidate over route default", () => { const d = decideIsr({ - hasUsableValue: true, - isStale: false, + cacheState: "HIT", kind: "app-page", revalidateSeconds: 60, cacheControlMeta: { revalidate: 30 }, @@ -87,8 +81,7 @@ describe("decideIsr — HIT", () => { it("app-page HIT: cacheControlMeta expire overrides route expireSeconds", () => { const d = decideIsr({ - hasUsableValue: true, - isStale: false, + cacheState: "HIT", kind: "app-page", revalidateSeconds: 60, expireSeconds: 999, @@ -99,8 +92,7 @@ describe("decideIsr — HIT", () => { it("app-page HIT: cacheControlMeta present but no expire — expireSeconds used as fallback", () => { const d = decideIsr({ - hasUsableValue: true, - isStale: false, + cacheState: "HIT", kind: "app-page", revalidateSeconds: 60, expireSeconds: 300, @@ -111,8 +103,7 @@ describe("decideIsr — HIT", () => { it("pages HIT without cacheControlMeta: unbounded SWR", () => { const d = decideIsr({ - hasUsableValue: true, - isStale: false, + cacheState: "HIT", kind: "pages", revalidateSeconds: 60, }); @@ -121,8 +112,7 @@ describe("decideIsr — HIT", () => { it("pages HIT with cacheControlMeta and expire: finite SWR window", () => { const d = decideIsr({ - hasUsableValue: true, - isStale: false, + cacheState: "HIT", kind: "pages", revalidateSeconds: 60, cacheControlMeta: { revalidate: 60, expire: 300 }, @@ -132,8 +122,7 @@ describe("decideIsr — HIT", () => { it("app-route HIT finite revalidate (no metadata): uses route policy, unbounded SWR", () => { const d = decideIsr({ - hasUsableValue: true, - isStale: false, + cacheState: "HIT", kind: "app-route", revalidateSeconds: 60, }); @@ -142,8 +131,7 @@ describe("decideIsr — HIT", () => { it("app-route HIT revalidate=0: emits NEVER_CACHE_CONTROL", () => { const d = decideIsr({ - hasUsableValue: true, - isStale: false, + cacheState: "HIT", kind: "app-route", revalidateSeconds: 0, }); @@ -152,8 +140,7 @@ describe("decideIsr — HIT", () => { it("app-route HIT revalidate=Infinity: emits STATIC_CACHE_CONTROL", () => { const d = decideIsr({ - hasUsableValue: true, - isStale: false, + cacheState: "HIT", kind: "app-route", revalidateSeconds: Infinity, }); @@ -162,8 +149,7 @@ describe("decideIsr — HIT", () => { it("app-route HIT: cacheControlMeta revalidate=0 wins over route default", () => { const d = decideIsr({ - hasUsableValue: true, - isStale: false, + cacheState: "HIT", kind: "app-route", revalidateSeconds: 60, cacheControlMeta: { revalidate: 0 }, @@ -173,8 +159,7 @@ describe("decideIsr — HIT", () => { it("dev HIT: unbounded SWR (no special gates)", () => { const d = decideIsr({ - hasUsableValue: true, - isStale: false, + cacheState: "HIT", kind: "dev", revalidateSeconds: 60, }); @@ -188,8 +173,7 @@ describe("decideIsr — HIT", () => { describe("decideIsr — STALE", () => { it("app-page STALE without expire: s-maxage=0 (canonical STALE fallback)", () => { const d = decideIsr({ - hasUsableValue: true, - isStale: true, + cacheState: "STALE", kind: "app-page", revalidateSeconds: 60, }); @@ -200,8 +184,7 @@ describe("decideIsr — STALE", () => { it("app-page STALE with cacheControlMeta and expire: uses route policy (same as HIT)", () => { const d = decideIsr({ - hasUsableValue: true, - isStale: true, + cacheState: "STALE", kind: "app-page", revalidateSeconds: 60, cacheControlMeta: { revalidate: 60, expire: 300 }, @@ -211,8 +194,7 @@ describe("decideIsr — STALE", () => { it("app-page STALE: scheduleRegeneration is true", () => { const d = decideIsr({ - hasUsableValue: true, - isStale: true, + cacheState: "STALE", kind: "app-page", revalidateSeconds: 60, }); @@ -221,8 +203,7 @@ describe("decideIsr — STALE", () => { it("pages STALE without expire: s-maxage=0", () => { const d = decideIsr({ - hasUsableValue: true, - isStale: true, + cacheState: "STALE", kind: "pages", revalidateSeconds: 60, }); @@ -231,8 +212,7 @@ describe("decideIsr — STALE", () => { it("pages STALE with cacheControlMeta and expire: finite SWR window", () => { const d = decideIsr({ - hasUsableValue: true, - isStale: true, + cacheState: "STALE", kind: "pages", revalidateSeconds: 15, cacheControlMeta: { revalidate: 15, expire: 300 }, @@ -242,8 +222,7 @@ describe("decideIsr — STALE", () => { it("app-route STALE revalidate=0: NEVER_CACHE_CONTROL", () => { const d = decideIsr({ - hasUsableValue: true, - isStale: true, + cacheState: "STALE", kind: "app-route", revalidateSeconds: 0, }); @@ -252,8 +231,7 @@ describe("decideIsr — STALE", () => { it("app-route STALE revalidate=Infinity: STATIC_CACHE_CONTROL", () => { const d = decideIsr({ - hasUsableValue: true, - isStale: true, + cacheState: "STALE", kind: "app-route", revalidateSeconds: Infinity, }); @@ -262,8 +240,7 @@ describe("decideIsr — STALE", () => { it("app-route STALE finite (no cacheControlMeta): s-maxage=0 (STALE fallback, expire unknown)", () => { const d = decideIsr({ - hasUsableValue: true, - isStale: true, + cacheState: "STALE", kind: "app-route", revalidateSeconds: 60, }); @@ -275,8 +252,7 @@ describe("decideIsr — STALE", () => { // the canonical buildCachedRevalidateCacheControl("STALE", secs) result which // is `s-maxage=0, stale-while-revalidate` when expire is absent, matching prod. const d = decideIsr({ - hasUsableValue: true, - isStale: true, + cacheState: "STALE", kind: "dev", revalidateSeconds: 60, });