diff --git a/packages/cloudflare/src/cache/kv-data-adapter.runtime.ts b/packages/cloudflare/src/cache/kv-data-adapter.runtime.ts index 3afc4bacd..10729e53f 100644 --- a/packages/cloudflare/src/cache/kv-data-adapter.runtime.ts +++ b/packages/cloudflare/src/cache/kv-data-adapter.runtime.ts @@ -366,6 +366,7 @@ export class KVCacheHandler implements CacheHandler { let effectiveExpire: number | undefined; effectiveRevalidate = readCacheControlNumberField(ctx, "revalidate"); effectiveExpire = readCacheControlNumberField(ctx, "expire"); + const effectiveStale = readCacheControlNumberField(ctx, "stale"); if (data && "revalidate" in data && typeof data.revalidate === "number") { effectiveRevalidate = data.revalidate; } @@ -380,11 +381,15 @@ export class KVCacheHandler implements CacheHandler { typeof effectiveExpire === "number" && effectiveExpire > 0 ? now + effectiveExpire * 1000 : null; - const cacheControl = + const cacheControl: CacheControlMetadata | undefined = typeof effectiveRevalidate === "number" - ? effectiveExpire === undefined - ? { revalidate: effectiveRevalidate } - : { revalidate: effectiveRevalidate, expire: effectiveExpire } + ? { + revalidate: effectiveRevalidate, + ...(effectiveExpire === undefined ? {} : { expire: effectiveExpire }), + // Client-router reuse bound — must survive KV so warm hits replay + // the producing render's claim (see CacheControlMetadata.stale). + ...(effectiveStale === undefined ? {} : { stale: effectiveStale }), + } : undefined; // Prepare entry — convert ArrayBuffers to base64 for JSON storage @@ -575,6 +580,9 @@ function validateCacheEntry(raw: unknown): KVCacheEntry | null { if (obj.cacheControl.expire !== undefined && typeof obj.cacheControl.expire !== "number") { return null; } + if (obj.cacheControl.stale !== undefined && typeof obj.cacheControl.stale !== "number") { + return null; + } } // value must be null or a valid cache value object with a known kind diff --git a/packages/cloudflare/src/prerender-kv-populate.ts b/packages/cloudflare/src/prerender-kv-populate.ts index f9df2bff0..7c0268aac 100644 --- a/packages/cloudflare/src/prerender-kv-populate.ts +++ b/packages/cloudflare/src/prerender-kv-populate.ts @@ -32,6 +32,7 @@ export type KVBulkPair = { type CacheControlMetadata = { revalidate: number; expire?: number; + stale?: number; }; function resolveContainedFile(rootDir: string, relativePath: string): string { @@ -55,13 +56,16 @@ function buildCacheEntry( now: number, revalidateSeconds: number | undefined, expireSeconds: number | undefined, + staleSeconds: number | undefined, ): string { const cacheControl: CacheControlMetadata | undefined = revalidateSeconds === undefined ? undefined - : expireSeconds === undefined - ? { revalidate: revalidateSeconds } - : { revalidate: revalidateSeconds, expire: expireSeconds }; + : { + revalidate: revalidateSeconds, + ...(expireSeconds === undefined ? {} : { expire: expireSeconds }), + ...(staleSeconds === undefined ? {} : { stale: staleSeconds }), + }; return JSON.stringify({ value, @@ -123,6 +127,8 @@ export function buildPrerenderKVPairs( if (typeof route.revalidate === "number" && route.revalidate <= 0) continue; const revalidateSeconds = typeof route.revalidate === "number" ? route.revalidate : undefined; const expireSeconds = typeof route.expire === "number" ? route.expire : undefined; + const staleSeconds = + typeof route.stale === "number" && route.stale >= 0 ? route.stale : undefined; const expirationTtl = revalidateSeconds === undefined ? undefined : ttlSeconds; const tags = buildAppPageCacheTags(cachePathname, route.tags ?? []); const metadata = buildMetadata(tags); @@ -141,6 +147,7 @@ export function buildPrerenderKVPairs( now, revalidateSeconds, expireSeconds, + staleSeconds, ), ...(expirationTtl !== undefined ? { expiration_ttl: expirationTtl } : {}), ...(metadata ? { metadata } : {}), @@ -160,6 +167,7 @@ export function buildPrerenderKVPairs( now, revalidateSeconds, expireSeconds, + staleSeconds, ), ...(expirationTtl !== undefined ? { expiration_ttl: expirationTtl } : {}), ...(metadata ? { metadata } : {}), diff --git a/packages/vinext/src/build/prerender.ts b/packages/vinext/src/build/prerender.ts index 1ba831ae9..d8a73d741 100644 --- a/packages/vinext/src/build/prerender.ts +++ b/packages/vinext/src/build/prerender.ts @@ -59,6 +59,7 @@ import { } from "./prerender-server-pool.js"; import { readPrerenderSecret } from "./server-manifest.js"; import { getOutputPath, getRscOutputPath } from "../utils/prerender-output-paths.js"; +import { resolveClientStaleTimeSeconds } from "../utils/cache-control-metadata.js"; import type { MetadataFileRoute } from "../server/metadata-routes.js"; import { createAppPprFallbackShells, @@ -144,6 +145,8 @@ export type PrerenderRouteResult = outputFiles: string[]; revalidate: number | false; expire?: number; + /** Client-router reuse bound resolved from the prerender's `cacheLife`. */ + stale?: number; /** * The concrete prerendered URL path, e.g. `/blog/hello-world`. * Only present when the route is dynamic and `path` differs from `route`. @@ -1571,6 +1574,9 @@ export async function prerenderApp({ : Math.min(revalidate, renderedCacheControl.revalidate) : (renderedCacheControl.revalidate ?? revalidate); + // Seed the same unclamped claim the runtime write path persists. + const renderedStale = resolveClientStaleTimeSeconds(htmlRender.requestCacheLife); + return { route: routePattern, status: "rendered", @@ -1579,6 +1585,7 @@ export async function prerenderApp({ ...(typeof renderedRevalidate === "number" ? { expire: renderedCacheControl.expire } : {}), + ...(renderedStale === undefined ? {} : { stale: renderedStale }), router: "app", ...(htmlRender.tags.length > 0 ? { tags: htmlRender.tags } : {}), ...(htmlRender.linkHeader ? { headers: { link: htmlRender.linkHeader } } : {}), @@ -1686,8 +1693,11 @@ export async function prerenderApp({ } } +/** Cache life recovered from a prerendered response; `stale` seeds the ISR entry. */ +type PrerenderCacheLife = { expire?: number; revalidate?: number; stale?: number }; + function resolveRenderedCacheControl( - requestCacheLife: { expire?: number; revalidate?: number }, + requestCacheLife: PrerenderCacheLife, cacheControl: string, fallbackExpireSeconds: number, ): { expire: number; revalidate?: number } { @@ -1707,22 +1717,31 @@ function resolveRenderedCacheControl( }; } -function readPrerenderCacheLifeHeader( - headers: Headers, -): { expire?: number; revalidate?: number } | null { +function readPrerenderCacheLifeHeader(headers: Headers): PrerenderCacheLife | null { const value = headers.get(VINEXT_PRERENDER_CACHE_LIFE_HEADER); if (!value) return null; try { - const parsed = JSON.parse(value) as { expire?: unknown; revalidate?: unknown }; - const cacheLife: { expire?: number; revalidate?: number } = {}; + const parsed = JSON.parse(value) as { + expire?: unknown; + revalidate?: unknown; + stale?: unknown; + }; + const cacheLife: PrerenderCacheLife = {}; if (typeof parsed.revalidate === "number" && Number.isFinite(parsed.revalidate)) { cacheLife.revalidate = parsed.revalidate; } if (typeof parsed.expire === "number" && Number.isFinite(parsed.expire)) { cacheLife.expire = parsed.expire; } - return cacheLife.revalidate === undefined && cacheLife.expire === undefined ? null : cacheLife; + if (typeof parsed.stale === "number" && Number.isFinite(parsed.stale) && parsed.stale >= 0) { + cacheLife.stale = parsed.stale; + } + return cacheLife.revalidate === undefined && + cacheLife.expire === undefined && + cacheLife.stale === undefined + ? null + : cacheLife; } catch { return null; } @@ -1785,6 +1804,7 @@ export function writePrerenderIndex( status: r.status, revalidate: r.revalidate, ...(typeof r.revalidate === "number" ? { expire: r.expire } : {}), + ...(typeof r.stale === "number" ? { stale: r.stale } : {}), router: r.router, ...(r.tags && r.tags.length > 0 ? { tags: r.tags } : {}), ...(r.headers ? { headers: r.headers } : {}), diff --git a/packages/vinext/src/client/navigation-runtime.ts b/packages/vinext/src/client/navigation-runtime.ts index c74f2e82d..7580b0656 100644 --- a/packages/vinext/src/client/navigation-runtime.ts +++ b/packages/vinext/src/client/navigation-runtime.ts @@ -16,6 +16,12 @@ export type NavigationRuntimeRscBootstrap = { nav?: NavigationRuntimeSnapshot; params?: Record; rsc: NavigationRuntimeRscChunk[]; + /** + * Client reuse bound in seconds resolved from the initial render's completed + * `cacheLife` — the done-script sibling of `dynamicStaleTimeSeconds`, which + * carries the `experimental.staleTimes` config value instead. + */ + staleTimeSeconds?: number; }; type NavigationRuntimeKind = "navigate" | "traverse" | "refresh"; @@ -170,25 +176,28 @@ function isNavigationRuntimeRscBootstrap(value: unknown): value is NavigationRun const nav = Reflect.get(value, "nav"); const params = Reflect.get(value, "params"); const rsc = Reflect.get(value, "rsc"); + const staleTimeSeconds = Reflect.get(value, "staleTimeSeconds"); // getNavigationRuntime() runs at bootstrap/read boundaries, not per chunk. // Keep full validation here so malformed ambient state is rejected before // hydration consumes it instead of caching a stale validation result. return ( (done === undefined || typeof done === "boolean") && - (dynamicStaleTimeSeconds === undefined || - (typeof dynamicStaleTimeSeconds === "number" && - Number.isFinite(dynamicStaleTimeSeconds) && - dynamicStaleTimeSeconds >= 0)) && + isOptionalStaleTimeSeconds(dynamicStaleTimeSeconds) && (initialCacheKind === undefined || initialCacheKind === "dynamic" || initialCacheKind === "static") && (nav === undefined || isNavigationRuntimeSnapshot(nav)) && (params === undefined || isNavigationRuntimeParams(params)) && Array.isArray(rsc) && - rsc.every(isNavigationRuntimeRscChunk) + rsc.every(isNavigationRuntimeRscChunk) && + isOptionalStaleTimeSeconds(staleTimeSeconds) ); } +function isOptionalStaleTimeSeconds(value: unknown): boolean { + return value === undefined || (typeof value === "number" && Number.isFinite(value) && value >= 0); +} + function isReadonlyStringArray(value: unknown): value is readonly string[] { return Array.isArray(value) && value.every((entry) => typeof entry === "string"); } diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index acd390bd2..71d7fc984 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -400,6 +400,7 @@ import { appIsrRouteKey as __isrRouteKey, isrGet as __isrGet, isrSet as __isrSet, + isrSetAppPage as __isrSetAppPage, isrSetPrerenderedAppPage as __isrSetPrerenderedAppPage, isOnDemandRevalidateRequest as __isOnDemandRevalidateRequest, triggerBackgroundRegeneration as __triggerBackgroundRegeneration, @@ -869,7 +870,7 @@ export default createAppRscHandler({ isrGet: __isrGet, isrHtmlKey: __isrHtmlKey, isrRscKey: __isrRscKey, - isrSet: __isrSet, + isrSet: __isrSetAppPage, loadSsrHandler() { return import.meta.viteRsc.loadModule("ssr", "index"); }, diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index ba334ea96..641fb8640 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -1535,6 +1535,11 @@ function bootstrapHydration( ...(initialRscBootstrap?.dynamicStaleTimeSeconds !== undefined ? { dynamicStaleTimeSeconds: initialRscBootstrap.dynamicStaleTimeSeconds } : {}), + // The done-script's completed-render cacheLife claim; bounds this + // visited-response entry exactly like the header does for RSC replies. + ...(initialRscBootstrap?.staleTimeSeconds !== undefined + ? { staleTimeSeconds: initialRscBootstrap.staleTimeSeconds } + : {}), mountedSlotsHeader, paramsHeader: encodeURIComponent(JSON.stringify(initialParams)), renderedPathAndSearch: null, diff --git a/packages/vinext/src/server/app-page-cache-finalizer.ts b/packages/vinext/src/server/app-page-cache-finalizer.ts index b2563cc2e..d1413be79 100644 --- a/packages/vinext/src/server/app-page-cache-finalizer.ts +++ b/packages/vinext/src/server/app-page-cache-finalizer.ts @@ -1,4 +1,3 @@ -import type { CachedAppPageValue } from "vinext/shims/cache"; import type { AppRscRenderMode } from "./app-rsc-render-mode.js"; import { applyCdnResponseHeaders } from "./cache-control.js"; import { setCacheStateHeaders } from "./cache-headers.js"; @@ -7,18 +6,12 @@ import { createEmptyAppPageRenderObservationState, type AppPageRenderObservationState, } from "./app-page-render-observation.js"; -import { buildAppPageCacheValue } from "./isr-cache.js"; +import { buildAppPageCacheValue, type AppPageCacheSetter } from "./isr-cache.js"; import type { RenderObservation } from "./cache-proof.js"; +import { resolveClientStaleTimeSeconds } from "../utils/cache-control-metadata.js"; import { readStreamAsText } from "../utils/text-stream.js"; type AppPageDebugLogger = (event: string, detail: string) => void; -type AppPageCacheSetter = ( - key: string, - data: CachedAppPageValue, - revalidateSeconds: number, - tags: string[], - expireSeconds?: number, -) => Promise; type AppPageRscCacheKeyBuilder = ( pathname: string, mountedSlotsHeader?: string | null, @@ -28,6 +21,7 @@ type AppPageRscCacheKeyBuilder = ( type AppPageRequestCacheLife = { revalidate?: number; expire?: number; + stale?: number; }; type BuildAppPageCacheRenderObservation = (input: { cacheTags: readonly string[]; @@ -97,7 +91,7 @@ function resolveAppPageCacheWritePolicy(options: { expireSeconds?: number; requestCacheLife?: AppPageRequestCacheLife | null; revalidateSeconds: number | null; -}): { expireSeconds?: number; revalidateSeconds: number } | null { +}): { expireSeconds?: number; revalidateSeconds: number; staleSeconds?: number } | null { let revalidateSeconds = options.revalidateSeconds; let expireSeconds = options.expireSeconds; const requestCacheLife = options.requestCacheLife; @@ -116,7 +110,13 @@ function resolveAppPageCacheWritePolicy(options: { return null; } - return { expireSeconds, revalidateSeconds }; + // Callers reach this only after the render's stream drained, so the + // request-scoped accumulation is the completed render's minimum. + return { + expireSeconds, + revalidateSeconds, + staleSeconds: resolveClientStaleTimeSeconds(requestCacheLife), + }; } export function finalizeAppPageHtmlCacheResponse( @@ -186,22 +186,17 @@ export function finalizeAppPageHtmlCacheResponse( htmlRenderObservation, linkHeader ? { link: linkHeader } : undefined, ), - cachePolicy.revalidateSeconds, - pageTags, - cachePolicy.expireSeconds, + { ...cachePolicy, tags: pageTags }, ), ]; if (options.capturedRscDataPromise) { writes.push( options.capturedRscDataPromise.then((rscData) => - options.isrSet( - rscKey, - buildAppPageCacheValue("", rscData, 200, rscRenderObservation), - cachePolicy.revalidateSeconds, - pageTags, - cachePolicy.expireSeconds, - ), + options.isrSet(rscKey, buildAppPageCacheValue("", rscData, 200, rscRenderObservation), { + ...cachePolicy, + tags: pageTags, + }), ), ); } @@ -287,13 +282,10 @@ export function scheduleAppPageRscCacheWrite( cacheTags: pageTags, state: observationState, }); - await options.isrSet( - rscKey, - buildAppPageCacheValue("", rscData, 200, rscRenderObservation), - cachePolicy.revalidateSeconds, - pageTags, - cachePolicy.expireSeconds, - ); + await options.isrSet(rscKey, buildAppPageCacheValue("", rscData, 200, rscRenderObservation), { + ...cachePolicy, + tags: pageTags, + }); options.isrDebug?.("RSC cache written", rscKey); } catch (cacheError) { console.error("[vinext] ISR RSC cache write error:", cacheError); diff --git a/packages/vinext/src/server/app-page-cache-render.ts b/packages/vinext/src/server/app-page-cache-render.ts index fad2570df..4818fe9fe 100644 --- a/packages/vinext/src/server/app-page-cache-render.ts +++ b/packages/vinext/src/server/app-page-cache-render.ts @@ -155,7 +155,11 @@ export async function renderAppPageCacheArtifacts( tags, cacheControl: typeof cacheLife?.revalidate === "number" - ? { revalidate: cacheLife.revalidate, expire: cacheLife.expire } + ? // `stale` must survive regeneration: this producer feeds + // resolveRegeneratedAppPageCachePolicy, and dropping it here would + // widen client reuse back to the configured fallback after the first + // background regen. + { revalidate: cacheLife.revalidate, expire: cacheLife.expire, stale: cacheLife.stale } : undefined, }; diff --git a/packages/vinext/src/server/app-page-cache.ts b/packages/vinext/src/server/app-page-cache.ts index dad67887b..d752a0329 100644 --- a/packages/vinext/src/server/app-page-cache.ts +++ b/packages/vinext/src/server/app-page-cache.ts @@ -8,9 +8,14 @@ import { 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 { applyClientStaleTimeHeader, applyEdgeRuntimeHeader } from "./app-page-response.js"; +import { resolveClientStaleTimeSeconds } from "../utils/cache-control-metadata.js"; import { setCacheStateHeaders } from "./cache-headers.js"; -import { buildAppPageCacheValue, type ISRCacheEntry } from "./isr-cache.js"; +import { + buildAppPageCacheValue, + type AppPageCacheSetter, + type ISRCacheEntry, +} from "./isr-cache.js"; import { mergeMiddlewareResponseHeaders } from "./middleware-response-headers.js"; import { encodeCacheTag } from "../utils/encode-cache-tag.js"; import type { AppRscRenderMode } from "./app-rsc-render-mode.js"; @@ -24,13 +29,6 @@ export { type AppPageDebugLogger = (event: string, detail: string) => void; type AppPageCacheGetter = (key: string) => Promise; -type AppPageCacheSetter = ( - key: string, - data: CachedAppPageValue, - revalidateSeconds: number, - tags: string[], - expireSeconds?: number, -) => Promise; type AppPageBackgroundRegenerator = (key: string, renderFn: () => Promise) => void; type AppPageRscCacheKeyBuilder = ( pathname: string, @@ -163,6 +161,7 @@ function buildAppPageCachedHeaders(options: { isEdgeRuntime?: boolean; middlewareHeaders?: Headers | null; mountedSlotsHeader?: string | null; + staleTimeSeconds?: number; }): Headers { const headers = new Headers({ "Content-Type": options.contentType, @@ -186,6 +185,8 @@ function buildAppPageCachedHeaders(options: { headers.set(VINEXT_MOUNTED_SLOTS_HEADER, options.mountedSlotsHeader); } + applyClientStaleTimeHeader(headers, options.staleTimeSeconds); + mergeMiddlewareResponseHeaders(headers, options.middlewareHeaders ?? null); return headers; } @@ -205,7 +206,7 @@ function resolveRegeneratedAppPageCachePolicy(options: { expireSeconds?: number; renderCacheControl?: CacheControlMetadata; routeRevalidateSeconds: number; -}): { expireSeconds?: number; revalidateSeconds: number } { +}): { expireSeconds?: number; revalidateSeconds: number; staleSeconds?: number } { let revalidateSeconds = options.routeRevalidateSeconds; const renderRevalidateSeconds = options.renderCacheControl?.revalidate; // An indefinite nested cache lifetime does not tighten the route's own @@ -217,9 +218,13 @@ function resolveRegeneratedAppPageCachePolicy(options: { : renderRevalidateSeconds; } + const expireSeconds = options.renderCacheControl?.expire ?? options.expireSeconds; return { - expireSeconds: options.renderCacheControl?.expire ?? options.expireSeconds, + expireSeconds, revalidateSeconds, + // Carry the regenerating render's own claim onto the refreshed entry, so a + // background regen does not quietly drop it and widen client reuse. + staleSeconds: resolveClientStaleTimeSeconds(options.renderCacheControl), }; } @@ -237,6 +242,10 @@ export function buildAppPageCachedResponse( expireSeconds: options.expireSeconds, cacheControlMeta: options.cacheControl, }); + // Replay the producing render's claim verbatim, not aged — Next.js re-emits + // the stored header unchanged on every hit, and the cached HTML body embeds + // the same original value in its done-script. + const staleTimeSeconds = resolveClientStaleTimeSeconds(options.cacheControl); if (options.isRscRequest) { if (!cachedValue.rscData) { return null; @@ -249,6 +258,7 @@ export function buildAppPageCachedResponse( isEdgeRuntime: options.isEdgeRuntime, middlewareHeaders: options.middlewareHeaders, mountedSlotsHeader: options.mountedSlotsHeader, + staleTimeSeconds, }); applyRscCompatibilityIdHeader(rscHeaders); applyRscDeploymentIdHeader(rscHeaders); @@ -270,6 +280,7 @@ export function buildAppPageCachedResponse( isEdgeRuntime: options.isEdgeRuntime, linkHeader: cachedValue.headers?.link, middlewareHeaders: options.middlewareHeaders, + staleTimeSeconds, }); return new Response(cachedValue.html, { @@ -465,9 +476,7 @@ export async function readAppPageCacheResponse( 200, revalidatedPage.rscRenderObservation, ), - cachePolicy.revalidateSeconds, - revalidatedPage.tags, - cachePolicy.expireSeconds, + { ...cachePolicy, tags: revalidatedPage.tags }, ), ]; @@ -486,9 +495,7 @@ export async function readAppPageCacheResponse( revalidatedPage.htmlRenderObservation, revalidatedPage.linkHeader ? { link: revalidatedPage.linkHeader } : undefined, ), - cachePolicy.revalidateSeconds, - revalidatedPage.tags, - cachePolicy.expireSeconds, + { ...cachePolicy, tags: revalidatedPage.tags }, ), ); } diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index 549880fdf..8aa54ced0 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -6,7 +6,6 @@ import { _consumeRequestScopedCacheLife, _peekRequestScopedCacheLife, } from "vinext/shims/cache-request-state"; -import type { CachedAppPageValue } from "vinext/shims/cache-handler"; import type { RootParams } from "vinext/shims/root-params"; import type { PprFallbackShellState } from "vinext/shims/ppr-fallback-shell"; import { @@ -89,7 +88,7 @@ import type { AppPageSsrHandler } from "./app-page-stream.js"; import { VINEXT_PRERENDER_SPECULATIVE_HEADER } from "./headers.js"; import type { ClientReuseManifestParseResult } from "./client-reuse-manifest.js"; import { buildAppPageTags } from "./implicit-tags.js"; -import type { ISRCacheEntry } from "./isr-cache.js"; +import type { AppPageCacheSetter, ISRCacheEntry } from "./isr-cache.js"; import { createAppLayoutParamAccessTracker, isAppLayoutObservationUnsafeForStaticReuse, @@ -105,13 +104,6 @@ export type AppPageBoundaryOnError = ( errorContext: unknown, ) => unknown; type AppPageDebugLogger = (event: string, detail: string) => void; -type AppPageCacheSetter = ( - key: string, - data: CachedAppPageValue, - revalidateSeconds: number, - tags: string[], - expireSeconds?: number, -) => Promise; type AppPageCacheGetter = (key: string) => Promise; type AppPageBackgroundRegenerationErrorContext = { routerKind: "App Router"; diff --git a/packages/vinext/src/server/app-page-render.ts b/packages/vinext/src/server/app-page-render.ts index 31107fc84..ec8b74eba 100644 --- a/packages/vinext/src/server/app-page-render.ts +++ b/packages/vinext/src/server/app-page-render.ts @@ -1,9 +1,10 @@ import type { ReactNode } from "react"; import type { ReactFormState } from "react-dom/client"; import type { NavigationContext } from "vinext/shims/navigation"; -import type { CachedAppPageValue } from "vinext/shims/cache-handler"; +import type { AppPageCacheSetter } from "./isr-cache.js"; import type { RootParams } from "vinext/shims/root-params"; import { runWithFetchDedupe } from "vinext/shims/fetch-cache"; +import { resolveClientStaleTimeSeconds } from "../utils/cache-control-metadata.js"; import { AppElementsWire, isAppElementsRecord, type AppOutgoingElements } from "./app-elements.js"; import { hasDigest } from "./app-rsc-errors.js"; import { @@ -89,17 +90,11 @@ type AppPageBoundaryOnError = ( errorContext: unknown, ) => unknown; type AppPageDebugLogger = (event: string, detail: string) => void; -type AppPageCacheSetter = ( - key: string, - data: CachedAppPageValue, - revalidateSeconds: number, - tags: string[], - expireSeconds?: number, -) => Promise; type AppPageRequestCacheLife = { revalidate?: number; expire?: number; + stale?: number; }; type RenderAppPageLifecycleOptions = { @@ -280,6 +275,9 @@ function applyRequestCacheLife(options: { expireSeconds = requestCacheLife.expire; } + // `stale` is deliberately absent: it is the client-router dimension and must + // not leak into `Cache-Control`. It travels on the cache entry instead (see + // resolveAppPageCacheWritePolicy). return { expireSeconds, revalidateSeconds }; } @@ -838,13 +836,17 @@ export async function renderAppPageLifecycle( options.isPrerender !== true && !options.isForceStatic && (dynamicUsedDuringBuild || options.isForceDynamic || !shouldCaptureRscForCacheMetadata); + // The response streams before the captured render resolves its cacheLife + // (#961) — mark the claim pending so the client bounds reuse. + const staleTimePending = options.isPrerender !== true && shouldCaptureRscForCacheMetadata; const rscResponse = buildAppPageRscResponse(rscForResponse, { cacheTags: options.isPrerender === true ? options.getPageTags() : undefined, - // Only emit on dynamic renders — Next.js gates on !workStore.isStaticGeneration (line 2223). - // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/app-render/app-render.tsx#L2223-L2229 - // shouldCaptureRscForCacheMetadata is the runtime analog of isStaticGeneration: a render - // written to the ISR cache (incl. production ISR, where isPrerender is false at runtime) - // must not emit the authoritative per-page stale time. + staleTimePending, + // Only on dynamic renders — the Next.js !isStaticGeneration gate + // (app-render.tsx:2223). NOT paired with the pending marker: that would + // kill prefetch reuse of statically-prefetchable dynamic-param routes + // (segment-cache-client-params compat test); a late-dynamic response is + // capped at the 30s pending floor instead. dynamicStaleTimeSeconds: shouldEmitDynamicStaleTime ? dynamicStaleTimeSeconds : undefined, isEdgeRuntime: options.isEdgeRuntime, middlewareContext: options.middlewareContext, @@ -963,6 +965,11 @@ export async function renderAppPageLifecycle( ? "dynamic" : "static"; } + // Runs after the RSC embed drains, so this peek observes the + // completed render's minimum. Peek, not consume — the cache-write + // closure owns the consuming read. + const requestCacheLife = options.peekRequestCacheLife?.(); + const staleTimeSeconds = resolveClientStaleTimeSeconds(requestCacheLife); return { kind, ...(kind === "dynamic" && @@ -971,6 +978,9 @@ export async function renderAppPageLifecycle( !shouldCaptureRscForCacheMetadata ? { dynamicStaleTimeSeconds } : {}), + ...(staleTimeSeconds === undefined + ? {} + : { staleTimeSeconds: Math.floor(staleTimeSeconds) }), }; }, fontData, diff --git a/packages/vinext/src/server/app-page-response.ts b/packages/vinext/src/server/app-page-response.ts index b530d6b4b..8dada9644 100644 --- a/packages/vinext/src/server/app-page-response.ts +++ b/packages/vinext/src/server/app-page-response.ts @@ -5,15 +5,18 @@ import { } from "./cache-control.js"; import { NEXT_CACHE_TAGS_HEADER, + NEXT_ROUTER_STALE_TIME_HEADER, VINEXT_DYNAMIC_STALE_TIME_HEADER, VINEXT_MOUNTED_SLOTS_HEADER, VINEXT_PARAMS_HEADER, VINEXT_PRERENDER_CACHE_LIFE_HEADER, VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, + VINEXT_STALE_TIME_PENDING_HEADER, VINEXT_TIMING_HEADER, } from "./headers.js"; import { setCacheStateHeaders } from "./cache-headers.js"; import { mergeMiddlewareResponseHeaders } from "./middleware-response-headers.js"; +import { resolveClientStaleTimeSeconds } from "../utils/cache-control-metadata.js"; import { VINEXT_RSC_CONTENT_TYPE, VINEXT_RSC_VARY_HEADER, @@ -41,6 +44,8 @@ type AppPageResponsePolicy = { type AppPagePrerenderCacheLife = { expire?: number; revalidate?: number; + /** Client-router dimension — see `resolveClientStaleTimeSeconds`. */ + stale?: number; }; type ResolveAppPageResponsePolicyBaseOptions = { @@ -70,6 +75,8 @@ type AppPageHtmlResponsePolicy = { type BuildAppPageRscResponseOptions = { cacheTags?: readonly string[]; dynamicStaleTimeSeconds?: number; + /** The render is being captured for a cache write but streams before its cacheLife resolves. */ + staleTimePending?: boolean; isEdgeRuntime?: boolean; middlewareContext: AppPageMiddlewareContext; mountedSlotsHeader?: string | null; @@ -120,11 +127,26 @@ function applyDynamicStaleTimeHeader(headers: Headers, dynamicStaleTimeSeconds?: } } +/** + * Only ever set from a *completed* render's cacheLife (cache replay or + * prerender seed) — `use cache` scopes keep resolving after headers commit, + * so a streaming response can never carry it. + */ +export function applyClientStaleTimeHeader( + headers: Headers, + staleTimeSeconds: number | undefined, +): void { + if (staleTimeSeconds === undefined) return; + headers.set(NEXT_ROUTER_STALE_TIME_HEADER, String(Math.floor(staleTimeSeconds))); +} + function applyPrerenderCacheLifeHeader( headers: Headers, requestCacheLife: AppPagePrerenderCacheLife | null | undefined, ): void { if (!requestCacheLife) return; + // Build-internal channel: build/prerender.ts turns this into ISR seed + // metadata; these responses never reach a browser. const payload: AppPagePrerenderCacheLife = {}; if ( typeof requestCacheLife.revalidate === "number" && @@ -135,7 +157,17 @@ function applyPrerenderCacheLifeHeader( if (typeof requestCacheLife.expire === "number" && Number.isFinite(requestCacheLife.expire)) { payload.expire = requestCacheLife.expire; } - if (payload.revalidate === undefined && payload.expire === undefined) return; + const stale = resolveClientStaleTimeSeconds(requestCacheLife); + if (stale !== undefined) { + payload.stale = stale; + } + if ( + payload.revalidate === undefined && + payload.expire === undefined && + payload.stale === undefined + ) { + return; + } headers.set(VINEXT_PRERENDER_CACHE_LIFE_HEADER, JSON.stringify(payload)); } @@ -311,6 +343,9 @@ export function buildAppPageRscResponse( headers.set(VINEXT_MOUNTED_SLOTS_HEADER, options.mountedSlotsHeader); } applyDynamicStaleTimeHeader(headers, options.dynamicStaleTimeSeconds); + if (options.staleTimePending) { + headers.set(VINEXT_STALE_TIME_PENDING_HEADER, "1"); + } if (options.policy.cacheControl) { headers.set("Cache-Control", options.policy.cacheControl); } diff --git a/packages/vinext/src/server/app-ssr-stream.ts b/packages/vinext/src/server/app-ssr-stream.ts index bc712fe07..703b5b085 100644 --- a/packages/vinext/src/server/app-ssr-stream.ts +++ b/packages/vinext/src/server/app-ssr-stream.ts @@ -24,6 +24,11 @@ type InlineCssManifest = Record; export type InitialNavigationCacheMetadata = { kind: "dynamic" | "static"; dynamicStaleTimeSeconds?: number; + /** + * Client reuse bound from the completed render's `cacheLife` — trustworthy + * because the done-script is emitted only after the full RSC stream drains. + */ + staleTimeSeconds?: number; }; type InlineCssRewriteResult = { html: string; @@ -81,6 +86,9 @@ function createNavigationRuntimeRscDoneScript(metadata?: InitialNavigationCacheM ...(metadata.dynamicStaleTimeSeconds === undefined ? {} : { dynamicStaleTimeSeconds: metadata.dynamicStaleTimeSeconds }), + ...(metadata.staleTimeSeconds === undefined + ? {} + : { staleTimeSeconds: metadata.staleTimeSeconds }), }) + ");") + bootstrap + diff --git a/packages/vinext/src/server/headers.ts b/packages/vinext/src/server/headers.ts index f75a139bc..b9f350908 100644 --- a/packages/vinext/src/server/headers.ts +++ b/packages/vinext/src/server/headers.ts @@ -118,6 +118,20 @@ export const NEXT_ACTION_HEADER = "next-action"; /** Next.js action-not-found indicator (value "1"). */ export const NEXTJS_ACTION_NOT_FOUND_HEADER = "x-nextjs-action-not-found"; +/** + * Seconds the client router may reuse this response, resolved from the + * render's `cacheLife`. Mirrors Next.js's `NEXT_ROUTER_STALE_TIME_HEADER`; + * kept out of `Cache-Control`, which owns the shared-cache dimensions. + */ +export const NEXT_ROUTER_STALE_TIME_HEADER = "x-nextjs-stale-time"; + +/** + * Marks a streamed cacheable RSC response whose `cacheLife` claim had not + * resolved at header time (value "1"). The client bounds such a response at + * min(30s floor, dynamic bound) instead of its fallback TTL. + */ +export const VINEXT_STALE_TIME_PENDING_HEADER = "X-Vinext-Stale-Time-Pending"; + /** * Deployment ID header used by the Pages Router for deployment-skew * protection. Set on every `/_next/data/` response so the client can detect diff --git a/packages/vinext/src/server/isr-cache.ts b/packages/vinext/src/server/isr-cache.ts index 9458688b8..7081df30a 100644 --- a/packages/vinext/src/server/isr-cache.ts +++ b/packages/vinext/src/server/isr-cache.ts @@ -14,6 +14,7 @@ */ import { + type CacheControlMetadata, type CacheHandlerValue, type IncrementalCacheValue, type CachedPagesValue, @@ -187,25 +188,66 @@ export async function isrSet( revalidateSeconds: number | false, tags?: string[], expireSeconds?: number, + staleSeconds?: number, ): Promise { await getCdnCacheAdapter().set(key, data, { - cacheControl: - expireSeconds === undefined - ? { revalidate: revalidateSeconds } - : { revalidate: revalidateSeconds, expire: expireSeconds }, + cacheControl: { + revalidate: revalidateSeconds, + ...(expireSeconds === undefined ? {} : { expire: expireSeconds }), + ...(staleSeconds === undefined ? {} : { stale: staleSeconds }), + } satisfies CacheControlMetadata, // `revalidate` is the legacy vinext CacheHandler context field. `expire` - // is new metadata and intentionally only lives inside cacheControl. + // and `stale` are newer metadata and intentionally only live inside + // cacheControl. revalidate: revalidateSeconds, tags: tags ?? [], }); } +/** + * Write policy for one App Router page cache entry: shared-cache dimensions + * plus the client-router `stale` bound (see `CacheControlMetadata.stale`). + */ +export type AppPageCacheWritePolicy = { + revalidateSeconds: number; + expireSeconds?: number; + staleSeconds?: number; + tags: string[]; +}; + +export type AppPageCacheSetter = ( + key: string, + data: CachedAppPageValue, + policy: AppPageCacheWritePolicy, +) => Promise; + +/** + * App Router page variant of {@link isrSet}. The positional form stays for the + * Pages Router and route handlers, which never carry `stale`. + */ +export async function isrSetAppPage( + key: string, + data: CachedAppPageValue, + policy: AppPageCacheWritePolicy, +): Promise { + await isrSet( + key, + data, + policy.revalidateSeconds, + policy.tags, + policy.expireSeconds, + policy.staleSeconds, + ); +} + export async function isrSetPrerenderedAppPage( key: string, data: CachedAppPageValue, metadata: { expireSeconds?: number; revalidateSeconds?: number; + /** Client reuse bound from the prerender's `cacheLife`. */ + staleSeconds?: number; /** * Implicit/path tags to attach to the seeded entry. Required so that * `revalidatePath()` (and `revalidateTag()`) can invalidate prerender-seeded @@ -228,10 +270,11 @@ export async function isrSetPrerenderedAppPage( const ctx: Record = {}; if (revalidateSeconds !== undefined) { ctx.revalidate = revalidateSeconds; - ctx.cacheControl = - metadata.expireSeconds === undefined - ? { revalidate: revalidateSeconds } - : { revalidate: revalidateSeconds, expire: metadata.expireSeconds }; + ctx.cacheControl = { + revalidate: revalidateSeconds, + ...(metadata.expireSeconds === undefined ? {} : { expire: metadata.expireSeconds }), + ...(metadata.staleSeconds === undefined ? {} : { stale: metadata.staleSeconds }), + } satisfies CacheControlMetadata; } if (tags && tags.length > 0) { ctx.tags = tags; diff --git a/packages/vinext/src/server/prerender-manifest.ts b/packages/vinext/src/server/prerender-manifest.ts index ebd0b0470..498ef7921 100644 --- a/packages/vinext/src/server/prerender-manifest.ts +++ b/packages/vinext/src/server/prerender-manifest.ts @@ -5,6 +5,12 @@ export type PrerenderManifestRoute = { status?: string; revalidate?: number | false; expire?: number; + /** + * Client-router reuse bound resolved by the prerender's `cacheLife`. Absent on + * manifests written by older builds, which seed entries without a + * client-freshness claim and leave the client on its configured staleTimes. + */ + stale?: number; path?: string; router?: string; fallback?: boolean; diff --git a/packages/vinext/src/server/seed-cache.ts b/packages/vinext/src/server/seed-cache.ts index 771e90954..47c8ca0df 100644 --- a/packages/vinext/src/server/seed-cache.ts +++ b/packages/vinext/src/server/seed-cache.ts @@ -49,6 +49,8 @@ import { type PrerenderCacheSeedMetadata = { expireSeconds?: number; revalidateSeconds?: number; + /** Client reuse bound resolved by the prerender, replayed on cache hits. */ + staleSeconds?: number; /** * Path-derived implicit tags (`/foo`, `_N_T_/foo`, `_N_T_/foo/page`, ...) * required for `revalidatePath()` to invalidate the seeded entry. See #1486. @@ -117,6 +119,8 @@ export async function seedMemoryCacheFromPrerender( const rscKey = options?.buildAppPageRscKey?.(cachePathname) ?? baseKey + ":rsc"; const revalidateSeconds = typeof route.revalidate === "number" ? route.revalidate : undefined; const expireSeconds = typeof route.expire === "number" ? route.expire : undefined; + const staleSeconds = + typeof route.stale === "number" && route.stale >= 0 ? route.stale : undefined; // Preserve both path-derived implicit tags and user tags collected during // prerender so revalidatePath()/revalidateTag() can invalidate the seeded @@ -133,6 +137,7 @@ export async function seedMemoryCacheFromPrerender( route.headers, revalidateSeconds, expireSeconds, + staleSeconds, tags, ) ) { @@ -143,6 +148,7 @@ export async function seedMemoryCacheFromPrerender( artifactPathname, revalidateSeconds, expireSeconds, + staleSeconds, tags, ); seeded++; @@ -173,6 +179,7 @@ async function seedHtml( headers: Record | undefined, revalidateSeconds: number | undefined, expireSeconds: number | undefined, + staleSeconds: number | undefined, tags: string[] | undefined, ): Promise { const relPath = getOutputPath(pathname, trailingSlash); @@ -188,7 +195,7 @@ async function seedHtml( status: undefined, }; - await writeAppPageEntry(key, htmlValue, { expireSeconds, revalidateSeconds, tags }); + await writeAppPageEntry(key, htmlValue, { expireSeconds, revalidateSeconds, staleSeconds, tags }); return true; } @@ -204,6 +211,7 @@ async function seedRsc( pathname: string, revalidateSeconds: number | undefined, expireSeconds: number | undefined, + staleSeconds: number | undefined, tags: string[] | undefined, ): Promise { const relPath = getRscOutputPath(pathname); @@ -223,5 +231,5 @@ async function seedRsc( status: undefined, }; - await writeAppPageEntry(key, rscValue, { expireSeconds, revalidateSeconds, tags }); + await writeAppPageEntry(key, rscValue, { expireSeconds, revalidateSeconds, staleSeconds, tags }); } diff --git a/packages/vinext/src/shims/cache-handler.ts b/packages/vinext/src/shims/cache-handler.ts index 971fe20ae..56195a12c 100644 --- a/packages/vinext/src/shims/cache-handler.ts +++ b/packages/vinext/src/shims/cache-handler.ts @@ -15,6 +15,12 @@ export type CacheHandlerValue = { export type CacheControlMetadata = { revalidate: number | false; expire?: number; + /** + * Client-router reuse bound from the render's resolved `cacheLife`, + * persisted so warm hits replay the producing render's claim. Independent + * of `revalidate`/`expire`; absent means no claim was made. + */ + stale?: number; }; export type IncrementalCacheValue = @@ -297,6 +303,7 @@ export class MemoryCacheHandler implements CacheHandler { let effectiveRevalidate = readCacheControlRevalidateField(ctx); const effectiveExpire = readCacheControlNumberField(ctx, "expire"); + const effectiveStale = readCacheControlNumberField(ctx, "stale"); if (data && "revalidate" in data && typeof data.revalidate === "number") { effectiveRevalidate = data.revalidate; } else if (data && "revalidate" in data && data.revalidate === false) { @@ -315,11 +322,16 @@ export class MemoryCacheHandler implements CacheHandler { typeof effectiveExpire === "number" && effectiveExpire > 0 ? now + effectiveExpire * 1000 : null; - const cacheControl = + // Absent fields stay absent rather than becoming explicit `undefined`, so a + // round trip through a serializing cache adapter cannot turn "no claim" + // into a key that later reads as present. + const cacheControl: CacheControlMetadata | undefined = typeof effectiveRevalidate === "number" || effectiveRevalidate === false - ? effectiveExpire === undefined - ? { revalidate: effectiveRevalidate } - : { revalidate: effectiveRevalidate, expire: effectiveExpire } + ? { + revalidate: effectiveRevalidate, + ...(effectiveExpire === undefined ? {} : { expire: effectiveExpire }), + ...(effectiveStale === undefined ? {} : { stale: effectiveStale }), + } : undefined; if (this.maxMemoryCacheSize === 0) return; diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 13adb09ef..65581e32b 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -646,6 +646,9 @@ export function registerCachedFunction( cacheControl: { revalidate: revalidateSeconds, expire: effectiveLife.expire, + // Persisted so a later hit re-registers the same claim; otherwise + // the enclosing render's minimum depends on cache temperature. + stale: effectiveLife.stale, }, }); } catch { @@ -694,12 +697,19 @@ function throwPrivateUseCacheInsidePublicUseCacheError(): never { function recordRequestScopedCacheControl(cacheControl: CacheControlMetadata | undefined): void { if (cacheControl === undefined) return; - _setRequestScopedCacheLife({ - // `false` is an indefinite lifetime and therefore does not constrain an - // enclosing cache scope's finite revalidation window. + // A hit must contribute the same claim its producing execution did — both to + // the request scope and, when nested, to the enclosing cache scope (like the + // MISS path's `parentCtx.lifeConfigs.push`); otherwise the inner claim + // vanishes once the outer entry goes warm. + const life: CacheLifeConfig = { + // `false` is an indefinite lifetime and does not constrain the enclosing + // scope's finite revalidation window. revalidate: cacheControl.revalidate === false ? undefined : cacheControl.revalidate, expire: cacheControl.expire, - }); + stale: cacheControl.stale, + }; + cacheContextStorage.getStore()?.lifeConfigs.push(life); + _setRequestScopedCacheLife(life); } function recordRequestScopedCacheLife(cacheLife: CacheLifeConfig): void { diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 3676d3fa8..bc92993d8 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -39,10 +39,12 @@ import { } from "../server/app-rsc-cache-busting.js"; import { hasPendingAppRouterPageRedirect } from "../server/app-browser-mpa-navigation.js"; import { + NEXT_ROUTER_STALE_TIME_HEADER, VINEXT_DYNAMIC_STALE_TIME_HEADER, VINEXT_MOUNTED_SLOTS_HEADER, VINEXT_PARAMS_HEADER, VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, + VINEXT_STALE_TIME_PENDING_HEADER, } from "../server/headers.js"; import { toBrowserNavigationHref, toSameOriginAppPath, withBasePath } from "./url-utils.js"; import { navigationPlanner } from "../server/navigation-planner.js"; @@ -264,7 +266,13 @@ export const PREFETCH_CACHE_TTL = resolveClientRouterStaleTime( process.env.__NEXT_CLIENT_ROUTER_STATIC_STALETIME, 30_000, ); -const MIN_PREFETCH_STALE_TIME_MS = 30_000; +/** + * Floor for any server-declared `cacheLife` stale time, mirroring Next.js's + * `getStaleTimeMs` (`Math.max(staleTimeSeconds, 30) * 1000`). One rule for + * both client caches, so behavior does not depend on which cache a route hit. + */ +const MIN_SERVER_STALE_TIME_SECONDS = 30; +const MIN_PREFETCH_STALE_TIME_MS = MIN_SERVER_STALE_TIME_SECONDS * 1000; /** A buffered RSC response stored as an ArrayBuffer for replay. */ export type CachedRscResponse = { @@ -277,6 +285,16 @@ export type CachedRscResponse = { paramsHeader: string | null; preparedElements?: AppElements; renderedPathAndSearch: string | null; + /** + * Cacheable render streamed before its `cacheLife` resolved + * (`VINEXT_STALE_TIME_PENDING_HEADER`); reuse is bounded at the 30s floor. + */ + staleTimePending?: boolean; + /** + * Reuse bound from the render's `cacheLife` (`NEXT_ROUTER_STALE_TIME_HEADER`). + * Min-combined with the config-derived `dynamicStaleTimeSeconds`. + */ + staleTimeSeconds?: number; url: string; }; @@ -378,7 +396,7 @@ export function getPrefetchedUrls(): Set { return window.__VINEXT_RSC_PREFETCHED_URLS__; } -function isDynamicStaleTimeSeconds(value: unknown): value is number { +function isStaleTimeSeconds(value: unknown): value is number { return ( typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 0 ); @@ -388,18 +406,54 @@ function isCacheExpiresAt(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0; } -function parseDynamicStaleTimeSeconds(value: string | null): number | undefined { +function parseStaleTimeSecondsHeader(value: string | null): number | undefined { if (value === null || value === "") return undefined; const seconds = Number(value); - return isDynamicStaleTimeSeconds(seconds) ? seconds : undefined; + return isStaleTimeSeconds(seconds) ? seconds : undefined; +} + +/** + * Min-combine the two independent staleness lattices a response can carry: + * `dynamicStaleTimeSeconds` (from `experimental.staleTimes` config) and + * `staleTimeSeconds` (from the render's `cacheLife`) — neither may override + * the other. The cacheLife value is floored *before* the min so the floor + * never raises the config bound. A pending claim substitutes the floor, the + * least any resolution of it could have licensed. Undefined = no signal; + * the caller's fallback TTL stays in force. + */ +function resolveRscResponseStaleTimeSeconds( + cached: Pick< + CachedRscResponse, + "dynamicStaleTimeSeconds" | "staleTimePending" | "staleTimeSeconds" + >, +): number | undefined { + const dynamic = cached.dynamicStaleTimeSeconds; + const resolved = cached.staleTimeSeconds; + const hasDynamic = isStaleTimeSeconds(dynamic); + const floored = isStaleTimeSeconds(resolved) + ? Math.max(resolved, MIN_SERVER_STALE_TIME_SECONDS) + : undefined; + let combined: number | undefined; + if (hasDynamic && floored !== undefined) combined = Math.min(dynamic, floored); + else if (hasDynamic) combined = dynamic; + else combined = floored; + if (cached.staleTimePending === true) { + return combined === undefined + ? MIN_SERVER_STALE_TIME_SECONDS + : Math.min(combined, MIN_SERVER_STALE_TIME_SECONDS); + } + return combined; } export function resolveCachedRscResponseTtlMs( - cached: Pick, + cached: Pick< + CachedRscResponse, + "dynamicStaleTimeSeconds" | "staleTimePending" | "staleTimeSeconds" + >, fallbackTtlMs: number, ): number { - const seconds = cached.dynamicStaleTimeSeconds; - if (!isDynamicStaleTimeSeconds(seconds)) { + const seconds = resolveRscResponseStaleTimeSeconds(cached); + if (seconds === undefined) { return fallbackTtlMs; } return seconds * 1000; @@ -407,7 +461,10 @@ export function resolveCachedRscResponseTtlMs( export function resolveCachedRscResponseExpiresAt( timestamp: number, - cached: Pick, + cached: Pick< + CachedRscResponse, + "dynamicStaleTimeSeconds" | "expiresAt" | "staleTimePending" | "staleTimeSeconds" + >, fallbackTtlMs: number, ): number { if (isCacheExpiresAt(cached.expiresAt)) { @@ -418,17 +475,22 @@ export function resolveCachedRscResponseExpiresAt( function resolvePrefetchedRscResponseExpiresAt( timestamp: number, - cached: Pick, + cached: Pick< + CachedRscResponse, + "dynamicStaleTimeSeconds" | "expiresAt" | "staleTimePending" | "staleTimeSeconds" + >, fallbackTtlMs: number, minimumTtlMs: number = MIN_PREFETCH_STALE_TIME_MS, ): number { if (isCacheExpiresAt(cached.expiresAt)) { return cached.expiresAt; } - const seconds = cached.dynamicStaleTimeSeconds; - if (!isDynamicStaleTimeSeconds(seconds)) { + const seconds = resolveRscResponseStaleTimeSeconds(cached); + if (seconds === undefined) { return timestamp + Math.max(fallbackTtlMs, minimumTtlMs); } + // The cacheLife signal is floored inside the resolver; this outer floor + // covers the prefetch-only dimensions (config bound and fallback TTL). return timestamp + Math.max(seconds * 1000, minimumTtlMs); } @@ -933,9 +995,13 @@ export function createCachedRscResponseSnapshot( buffer: ArrayBuffer, responseUrl: string | null = null, ): CachedRscResponse { - const dynamicStaleTimeSeconds = parseDynamicStaleTimeSeconds( + const dynamicStaleTimeSeconds = parseStaleTimeSecondsHeader( response.headers.get(VINEXT_DYNAMIC_STALE_TIME_HEADER), ); + const staleTimeSeconds = parseStaleTimeSecondsHeader( + response.headers.get(NEXT_ROUTER_STALE_TIME_HEADER), + ); + const staleTimePending = response.headers.get(VINEXT_STALE_TIME_PENDING_HEADER) === "1"; return { compatibilityIdHeader: response.headers.get(VINEXT_RSC_COMPATIBILITY_ID_HEADER), buffer, @@ -946,6 +1012,8 @@ export function createCachedRscResponseSnapshot( renderedPathAndSearch: parseRenderedPathAndSearchHeader( response.headers.get(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER), ), + ...(staleTimePending ? { staleTimePending: true } : {}), + ...(staleTimeSeconds !== undefined ? { staleTimeSeconds } : {}), url: responseUrl ?? response.url, }; } @@ -995,9 +1063,15 @@ export function restoreRscResponse(cached: CachedRscResponse, copy = true): Resp if (cached.compatibilityIdHeader != null) { headers.set(VINEXT_RSC_COMPATIBILITY_ID_HEADER, cached.compatibilityIdHeader); } - if (isDynamicStaleTimeSeconds(cached.dynamicStaleTimeSeconds)) { + if (isStaleTimeSeconds(cached.dynamicStaleTimeSeconds)) { headers.set(VINEXT_DYNAMIC_STALE_TIME_HEADER, String(cached.dynamicStaleTimeSeconds)); } + if (isStaleTimeSeconds(cached.staleTimeSeconds)) { + headers.set(NEXT_ROUTER_STALE_TIME_HEADER, String(cached.staleTimeSeconds)); + } + if (cached.staleTimePending === true) { + headers.set(VINEXT_STALE_TIME_PENDING_HEADER, "1"); + } if (cached.paramsHeader != null) { headers.set(VINEXT_PARAMS_HEADER, cached.paramsHeader); } diff --git a/packages/vinext/src/utils/cache-control-metadata.ts b/packages/vinext/src/utils/cache-control-metadata.ts index 2c7e7f68f..14e6fe02e 100644 --- a/packages/vinext/src/utils/cache-control-metadata.ts +++ b/packages/vinext/src/utils/cache-control-metadata.ts @@ -24,3 +24,22 @@ export function readCacheControlRevalidateField( const value = cacheControl?.revalidate ?? ctx?.revalidate; return typeof value === "number" || value === false ? value : undefined; } + +function isFiniteNonNegative(value: number | undefined): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +/** + * Client-reuse seconds from a resolved `cacheLife`. Shared by every emitter + * (ISR write, hit replay, prerender seed, done-script) so warm hits claim what + * the producing render claimed. Two rules: never synthesize `stale` from + * `revalidate`/`expire` (the `default` profile would license ~136y of reuse), + * and never clamp it by them (Next.js replays the stored value verbatim; + * `expire` is a serve-side ceiling, not a client bound). + */ +export function resolveClientStaleTimeSeconds( + cacheLife: { stale?: number } | null | undefined, +): number | undefined { + const stale = cacheLife?.stale; + return isFiniteNonNegative(stale) ? stale : undefined; +} diff --git a/tests/app-page-cache-render.test.ts b/tests/app-page-cache-render.test.ts new file mode 100644 index 000000000..a316b83ce --- /dev/null +++ b/tests/app-page-cache-render.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vite-plus/test"; +import React from "react"; +import { renderAppPageCacheArtifacts } from "../packages/vinext/src/server/app-page-cache-render.js"; +import { _setRequestScopedCacheLife } from "../packages/vinext/src/shims/cache-request-state.js"; + +function createStream(chunks: string[]): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + }, + }); +} + +describe("renderAppPageCacheArtifacts", () => { + it("carries the consumed cacheLife stale onto the regenerated cacheControl", async () => { + // Regression: background regeneration goes through this producer, and its + // cacheControl feeds resolveRegeneratedAppPageCachePolicy. Dropping stale + // here silently widened client reuse back to the configured fallback after + // the first regen — the mocked-cacheControl regen tests never caught it + // because they supplied a `stale` this producer could not emit. + const result = await renderAppPageCacheArtifacts({ + captureRscData: false, + cleanPathname: "/posts/post", + element: React.createElement("div", null, "page"), + getFontLinks: () => [], + getFontPreloads: () => [], + getFontStyles: () => [], + getNavigationContext: () => null, + loadSsrHandler: async () => ({ + async handleSsr() { + // A `use cache` scope resolving during the render. + _setRequestScopedCacheLife({ stale: 30, revalidate: 1, expire: 60 }); + return createStream(["page"]); + }, + }), + navigationParams: {}, + onError: () => undefined, + renderToReadableStream: () => createStream(["flight-data"]), + route: { pattern: "/posts/[slug]", routeSegments: [] }, + }); + + expect(result.cacheControl).toEqual({ revalidate: 1, expire: 60, stale: 30 }); + expect(result.html).toBe("page"); + }); +}); diff --git a/tests/app-page-cache.test.ts b/tests/app-page-cache.test.ts index 0201555e0..86d1379ef 100644 --- a/tests/app-page-cache.test.ts +++ b/tests/app-page-cache.test.ts @@ -21,12 +21,13 @@ import { } 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 { NEXT_ROUTER_STALE_TIME_HEADER } from "../packages/vinext/src/server/headers.js"; import { withEnvVar } from "./env-test-helpers.js"; function buildISRCacheEntry( value: CachedAppPageValue, isStale = false, - cacheControl?: { revalidate: number; expire?: number }, + cacheControl?: { revalidate: number; expire?: number; stale?: number }, ): ISRCacheEntry { return { isStale, @@ -126,6 +127,70 @@ describe("app page cache helpers", () => { expect(await rscResponse?.arrayBuffer()).toEqual(rscData); }); + it("replays the entry's client stale time on cache hits", async () => { + // The claim was resolved by the render that produced these bytes and + // persisted onto the entry. Replaying it is what keeps a warm hit from + // serving identical output under a wider client-reuse window than the + // fresh render that produced it. + const rscData = new TextEncoder().encode("flight").buffer; + const cachedValue = buildCachedAppPageValue("

cached

", rscData); + + const htmlResponse = buildAppPageCachedResponse(cachedValue, { + cacheControl: { revalidate: 1, expire: 60, stale: 30 }, + cacheState: "HIT", + isRscRequest: false, + revalidateSeconds: 60, + }); + // `stale` exceeds `revalidate` in the `seconds` profile by design, so the + // shared-cache window must not clamp the client-router bound. + expect(htmlResponse?.headers.get(NEXT_ROUTER_STALE_TIME_HEADER)).toBe("30"); + + const rscResponse = buildAppPageCachedResponse(cachedValue, { + cacheControl: { revalidate: 1, expire: 60, stale: 30 }, + cacheState: "HIT", + isRscRequest: true, + revalidateSeconds: 60, + }); + expect(rscResponse?.headers.get(NEXT_ROUTER_STALE_TIME_HEADER)).toBe("30"); + // `stale` governs client-router reuse only; shared caches keep following + // revalidate/expire, so it must not appear in Cache-Control. + expect(htmlResponse?.headers.get("cache-control")).toBe( + "s-maxage=1, stale-while-revalidate=59", + ); + }); + + it("replays the client stale time verbatim, unclamped by expire and entry age", () => { + // Deliberate Next.js parity: the stored header is re-emitted unchanged on + // every hit (app-page-runtime.ts replays cached headers verbatim), and the + // cached HTML body embeds the same original value in its done-script, so + // aging or clamping only this header would make one entry's two artifacts + // disagree. `expire` is enforced server-side instead — entries past it are + // blocking misses, never replayed. + const response = buildAppPageCachedResponse(buildCachedAppPageValue("

cached

"), { + cacheControl: { revalidate: 60, expire: 45, stale: 300 }, + cacheState: "STALE", + isRscRequest: false, + revalidateSeconds: 60, + }); + + expect(response?.headers.get(NEXT_ROUTER_STALE_TIME_HEADER)).toBe("300"); + }); + + it("advertises no client stale time when the entry carries no claim", () => { + // The `default` profile is { revalidate: 900, expire: 4294967294 } with no + // `stale`. Synthesizing one from those would license ~136 years of client + // reuse without a refresh; omitting the header leaves the client on its + // configured experimental.staleTimes value. + const response = buildAppPageCachedResponse(buildCachedAppPageValue("

cached

"), { + cacheControl: { revalidate: 900, expire: 4294967294 }, + cacheState: "HIT", + isRscRequest: false, + revalidateSeconds: 900, + }); + + expect(response?.headers.get(NEXT_ROUTER_STALE_TIME_HEADER)).toBeNull(); + }); + it("merges middleware response headers into cached HTML responses", async () => { const middlewareHeaders = new Headers({ "Cache-Control": "private, no-store", @@ -684,12 +749,12 @@ describe("app page cache helpers", () => { isrRscKey(pathname, mountedSlotsHeader) { return `rsc:${pathname}:${mountedSlotsHeader ?? "none"}`; }, - async isrSet(key, data, revalidateSeconds, _tags, expireSeconds) { + async isrSet(key, data, policy) { isrSetCalls.push({ key, - expireSeconds, + expireSeconds: policy.expireSeconds, linkHeader: data.headers?.link, - revalidateSeconds, + revalidateSeconds: policy.revalidateSeconds, }); }, mountedSlotsHeader: "slot:forged:/", @@ -750,11 +815,11 @@ describe("app page cache helpers", () => { isrRscKey(pathname, mountedSlotsHeader) { return `rsc:${pathname}:${mountedSlotsHeader ?? "none"}`; }, - async isrSet(key, _data, revalidateSeconds, _tags, expireSeconds) { + async isrSet(key, _data, policy) { isrSetCalls.push({ key, - expireSeconds, - revalidateSeconds, + expireSeconds: policy.expireSeconds, + revalidateSeconds: policy.revalidateSeconds, }); }, revalidateSeconds: 3, @@ -1065,15 +1130,15 @@ describe("app page cache helpers", () => { isrRscKey(pathname) { return "rsc:" + pathname; }, - async isrSet(key, data, revalidateSeconds, tags, expireSeconds) { + async isrSet(key, data, policy) { isrSetCalls.push({ key, html: data.html, hasRscData: Boolean(data.rscData), linkHeader: data.headers?.link, - expireSeconds, - revalidateSeconds, - tags, + expireSeconds: policy.expireSeconds, + revalidateSeconds: policy.revalidateSeconds, + tags: policy.tags, }); }, expireSeconds: 300, @@ -1251,14 +1316,14 @@ describe("app page cache helpers", () => { isrRscKey(pathname) { return "rsc:" + pathname; }, - async isrSet(key, data, revalidateSeconds, tags, expireSeconds) { + async isrSet(key, data, policy) { isrSetCalls.push({ key, html: data.html, hasRscData: Boolean(data.rscData), - expireSeconds, - revalidateSeconds, - tags, + expireSeconds: policy.expireSeconds, + revalidateSeconds: policy.revalidateSeconds, + tags: policy.tags, }); }, expireSeconds: 300, diff --git a/tests/app-page-dispatch.test.ts b/tests/app-page-dispatch.test.ts index 1a18ea45b..70a0cc20d 100644 --- a/tests/app-page-dispatch.test.ts +++ b/tests/app-page-dispatch.test.ts @@ -776,7 +776,7 @@ describe("app page dispatch", () => { await expect(response.text()).resolves.toBe("page"); await Promise.all(waitUntilPromises.splice(0)); expect(isrSet).toHaveBeenCalledTimes(1); - const [cacheKey, cacheValue, revalidateSeconds, tags, expireSeconds] = isrSet.mock.calls[0]!; + const [cacheKey, cacheValue, cachePolicy] = isrSet.mock.calls[0]!; expect(cacheKey).toBe("html:/posts/hello"); expect(cacheValue).toMatchObject({ kind: "APP_PAGE", @@ -784,9 +784,9 @@ describe("app page dispatch", () => { requestApis: expect.arrayContaining([{ kind: "searchParams", status: "notObserved" }]), }, }); - expect(revalidateSeconds).toBe(60); - expect(tags).toEqual(expect.arrayContaining(["_N_T_/posts/hello"])); - expect(expireSeconds).toBeUndefined(); + expect(cachePolicy.revalidateSeconds).toBe(60); + expect(cachePolicy.tags).toEqual(expect.arrayContaining(["_N_T_/posts/hello"])); + expect(cachePolicy.expireSeconds).toBeUndefined(); }); it("does not reuse queryless HTML when the page reads searchParams", async () => { diff --git a/tests/app-page-render.test.ts b/tests/app-page-render.test.ts index 08b94da47..f4e03f277 100644 --- a/tests/app-page-render.test.ts +++ b/tests/app-page-render.test.ts @@ -29,10 +29,14 @@ import { } from "../packages/vinext/src/server/client-reuse-manifest.js"; import { NEXT_CACHE_TAGS_HEADER, + NEXT_ROUTER_STALE_TIME_HEADER, VINEXT_DYNAMIC_STALE_TIME_HEADER, VINEXT_PRERENDER_CACHE_LIFE_HEADER, + VINEXT_STALE_TIME_PENDING_HEADER, } from "../packages/vinext/src/server/headers.js"; import type { CachedAppPageValue } from "../packages/vinext/src/shims/cache.js"; +import type { AppPageCacheWritePolicy } from "../packages/vinext/src/server/isr-cache.js"; +import type { InitialNavigationCacheMetadata } from "../packages/vinext/src/server/app-ssr-stream.js"; import { DefaultCdnCacheAdapter, setCdnCacheAdapter, @@ -119,13 +123,7 @@ function createCommonOptions() { }), ); const isrSet = vi.fn( - async ( - _key: string, - _data: CachedAppPageValue, - _revalidateSeconds: number, - _tags: string[], - _expireSeconds?: number, - ) => {}, + async (_key: string, _data: CachedAppPageValue, _policy: AppPageCacheWritePolicy) => {}, ); return { @@ -555,9 +553,7 @@ describe("app page render lifecycle", () => { expect(common.isrSet).toHaveBeenCalledWith( "rsc:/posts/post", expect.objectContaining({ kind: "APP_PAGE" }), - 60, - ["_N_T_/posts/post"], - undefined, + { revalidateSeconds: 60, tags: ["_N_T_/posts/post"] }, ); const cachedValue = common.isrSet.mock.calls[0]?.[1]; expect(cachedValue?.renderObservation).toMatchObject({ @@ -616,6 +612,12 @@ describe("app page render lifecycle", () => { expect(response.status).toBe(200); expect(response.headers.get("cache-control")).toBe("no-store, must-revalidate"); expect(response.headers.get("x-vinext-cache")).toBeNull(); + // The pending marker went out before the render turned dynamic; the client + // caps this response at the 30s pending floor. Known limit: a cold stream + // cannot know its outcome, and bounding every pending response by + // staleTimes.dynamic would break static prefetch reuse instead. + expect(response.headers.get(VINEXT_STALE_TIME_PENDING_HEADER)).toBe("1"); + expect(response.headers.get(VINEXT_DYNAMIC_STALE_TIME_HEADER)).toBeNull(); expect(common.waitUntilPromises).toHaveLength(1); streamGate.resolve(); @@ -670,6 +672,59 @@ describe("app page render lifecycle", () => { ); }); + it("persists the completed render's client stale time onto the cache entry", async () => { + // getRequestCacheLife is the consuming read the cache-write path owns, and + // it runs after the captured RSC stream has drained — so unlike a value read + // at header time, this one reflects every `use cache` scope the render + // touched. That is what makes it safe to advertise on replay. + const common = createCommonOptions(); + + const response = await renderAppPageLifecycle({ + ...common.options, + getRequestCacheLife() { + return { revalidate: 1, expire: 60, stale: 30 }; + }, + isProduction: true, + isRscRequest: true, + revalidateSeconds: null, + }); + + await expect(response.text()).resolves.toBe("flight-data"); + await Promise.all(common.waitUntilPromises); + + expect(common.isrSet).toHaveBeenCalledWith( + "rsc:/posts/post", + expect.objectContaining({ kind: "APP_PAGE" }), + { revalidateSeconds: 1, expireSeconds: 60, staleSeconds: 30, tags: ["_N_T_/posts/post"] }, + ); + }); + + it("persists the declared stale unclamped by the entry's expire", async () => { + // Next.js parity: `expire` is a serve-side ceiling (expired entries are + // blocking misses), never a client-reuse clamp — see + // resolveClientStaleTimeSeconds. + const common = createCommonOptions(); + + const response = await renderAppPageLifecycle({ + ...common.options, + getRequestCacheLife() { + return { revalidate: 10, expire: 45, stale: 300 }; + }, + isProduction: true, + isRscRequest: true, + revalidateSeconds: null, + }); + + await expect(response.text()).resolves.toBe("flight-data"); + await Promise.all(common.waitUntilPromises); + + expect(common.isrSet).toHaveBeenCalledWith( + "rsc:/posts/post", + expect.objectContaining({ kind: "APP_PAGE" }), + { revalidateSeconds: 10, expireSeconds: 45, staleSeconds: 300, tags: ["_N_T_/posts/post"] }, + ); + }); + it("does not wait for the full captured RSC payload before returning production RSC responses", async () => { const common = createCommonOptions(); const releaseRsc = createDeferred(); @@ -707,15 +762,18 @@ describe("app page render lifecycle", () => { expect(response.headers.get("x-vinext-cache")).toBeNull(); expect(common.waitUntilPromises).toHaveLength(1); + // Cold responses stream before the cacheLife resolves (#961), so no stale + // header — the pending marker rides instead. + expect(response.headers.get(NEXT_ROUTER_STALE_TIME_HEADER)).toBeNull(); + expect(response.headers.get(VINEXT_STALE_TIME_PENDING_HEADER)).toBe("1"); + releaseRsc.resolve(); await expect(response.text()).resolves.toBe("flight"); await Promise.all(common.waitUntilPromises); expect(common.isrSet).toHaveBeenCalledWith( "rsc:/posts/post", expect.objectContaining({ kind: "APP_PAGE" }), - 7, - ["_N_T_/posts/post"], - 11, + { revalidateSeconds: 7, expireSeconds: 11, tags: ["_N_T_/posts/post"] }, ); }); @@ -802,17 +860,13 @@ describe("app page render lifecycle", () => { 1, "html:/posts/post", expect.objectContaining({ kind: "APP_PAGE" }), - 30, - ["_N_T_/posts/post"], - undefined, + { revalidateSeconds: 30, tags: ["_N_T_/posts/post"] }, ); expect(common.isrSet).toHaveBeenNthCalledWith( 2, "rsc:/posts/post", expect.objectContaining({ kind: "APP_PAGE" }), - 30, - ["_N_T_/posts/post"], - undefined, + { revalidateSeconds: 30, tags: ["_N_T_/posts/post"] }, ); }); @@ -859,17 +913,13 @@ describe("app page render lifecycle", () => { 1, "html:/posts/post", expect.objectContaining({ kind: "APP_PAGE" }), - 5, - ["_N_T_/posts/post"], - 9, + { revalidateSeconds: 5, expireSeconds: 9, tags: ["_N_T_/posts/post"] }, ); expect(common.isrSet).toHaveBeenNthCalledWith( 2, "rsc:/posts/post", expect.objectContaining({ kind: "APP_PAGE" }), - 5, - ["_N_T_/posts/post"], - 9, + { revalidateSeconds: 5, expireSeconds: 9, tags: ["_N_T_/posts/post"] }, ); }); @@ -1296,10 +1346,8 @@ describe("app page render lifecycle", () => { }); it("omits the dynamic stale time header on production ISR renders captured into the cache", async () => { - // Production ISR (revalidate > 0, not force-static, not a build prerender) - // satisfies shouldCaptureRscForCacheMetadata, so the render feeds the ISR - // cache. Like Next.js's !workStore.isStaticGeneration guard, the - // authoritative per-page stale time must not be emitted on such responses. + // The Next.js !isStaticGeneration gate: a render written to the ISR cache + // must not emit the authoritative per-page stale time. const common = createCommonOptions(); const response = await renderAppPageLifecycle({ ...common.options, @@ -1309,6 +1357,7 @@ describe("app page render lifecycle", () => { revalidateSeconds: 60, }); expect(response.headers.get(VINEXT_DYNAMIC_STALE_TIME_HEADER)).toBeNull(); + expect(response.headers.get(VINEXT_STALE_TIME_PENDING_HEADER)).toBe("1"); await expect(response.text()).resolves.toBe("flight-data"); }); @@ -1342,6 +1391,9 @@ describe("app page render lifecycle", () => { revalidateSeconds: null, }); expect(response.headers.get(VINEXT_DYNAMIC_STALE_TIME_HEADER)).toBe("0"); + // Force-dynamic renders are never captured for a cache write, so there is + // no pending cacheLife claim to advertise. + expect(response.headers.get(VINEXT_STALE_TIME_PENDING_HEADER)).toBeNull(); await expect(response.text()).resolves.toBe("flight-data"); }); @@ -1356,9 +1408,111 @@ describe("app page render lifecycle", () => { revalidateSeconds: null, }); expect(response.headers.get(VINEXT_DYNAMIC_STALE_TIME_HEADER)).toBeNull(); + expect(response.headers.get(VINEXT_STALE_TIME_PENDING_HEADER)).toBe("1"); await expect(response.text()).resolves.toBe("flight-data"); }); + it("never advertises a client stale time on a streaming render", async () => { + // Regression guard for the shape this feature must not take. `use cache` + // scopes below the page keep resolving while the RSC stream is consumed, + // long after headers are committed, so any value read here describes the + // probe rather than the output. A page-level scope declaring `stale: 30` + // does not license advertising 30s: an unobserved nested scope could still + // lower the render's minimum, and `cacheLife` aggregation is minimum-wins. + // + // The claim is instead persisted onto the cache entry by the write path, + // which runs after the stream drains, and replayed on cache hits. + const common = createCommonOptions(); + const response = await renderAppPageLifecycle({ + ...common.options, + dynamicStaleTimeSeconds: 300, + isRscRequest: true, + peekRequestCacheLife() { + return { stale: 30, revalidate: 1, expire: 60 }; + }, + }); + + expect(response.headers.get(NEXT_ROUTER_STALE_TIME_HEADER)).toBeNull(); + // The configured client stale time is a build-time constant and still ships. + expect(response.headers.get(VINEXT_DYNAMIC_STALE_TIME_HEADER)).toBe("300"); + // Dev never captures for cache metadata, so there is no pending claim to + // advertise — matching Next.js dev, which emits no stale signal at all. + expect(response.headers.get(VINEXT_STALE_TIME_PENDING_HEADER)).toBeNull(); + await expect(response.text()).resolves.toBe("flight-data"); + }); + + it("advertises the completed render's cacheLife stale in the initial-HTML navigation metadata", async () => { + // The channel streaming headers cannot be: the done-script metadata getter + // is invoked by the embed transform's finalize(), which runs only after the + // RSC stream has fully drained, so the peeked request-scoped cacheLife is + // the completed render's minimum. This is what closes the first-visit gap — + // dev and prod, cached or not, no ISR entry required. + const common = createCommonOptions(); + let capturedMetadataGetter: (() => InitialNavigationCacheMetadata) | undefined; + + const response = await renderAppPageLifecycle({ + ...common.options, + loadSsrHandler: async () => ({ + async handleSsr( + _rscStream: ReadableStream, + _navContext: unknown, + _fontData: unknown, + options?: { + getInitialNavigationCacheMetadata?: () => InitialNavigationCacheMetadata; + }, + ) { + capturedMetadataGetter = options?.getInitialNavigationCacheMetadata; + return createStream(["page"]); + }, + }), + peekRequestCacheLife() { + return { stale: 30, revalidate: 1, expire: 60 }; + }, + }); + + await expect(response.text()).resolves.toBe("page"); + expect(capturedMetadataGetter?.()).toEqual({ kind: "static", staleTimeSeconds: 30 }); + }); + + it("carries both the configured and the cacheLife stale time on a dynamic initial HTML render", async () => { + // A page that reads request state at the top level but wraps data in + // `use cache` subtrees produces both signals at once: the config-derived + // dynamic stale time and the completed render's cacheLife claim. The + // client combines them by minimum (see resolveRscResponseStaleTimeSeconds), + // and this is the state that makes that combination rule reachable. + const common = createCommonOptions(); + let capturedMetadataGetter: (() => InitialNavigationCacheMetadata) | undefined; + + const response = await renderAppPageLifecycle({ + ...common.options, + dynamicStaleTimeSeconds: 300, + isForceDynamic: true, + loadSsrHandler: async () => ({ + async handleSsr( + _rscStream: ReadableStream, + _navContext: unknown, + _fontData: unknown, + options?: { + getInitialNavigationCacheMetadata?: () => InitialNavigationCacheMetadata; + }, + ) { + capturedMetadataGetter = options?.getInitialNavigationCacheMetadata; + return createStream(["page"]); + }, + }), + peekRequestCacheLife() { + return { stale: 30, revalidate: 1, expire: 60 }; + }, + }); + + await expect(response.text()).resolves.toBe("page"); + expect(capturedMetadataGetter?.()).toEqual({ + kind: "dynamic", + dynamicStaleTimeSeconds: 300, + staleTimeSeconds: 30, + }); + }); + it("streams runtime HTML responses progressively without buffering the body", async () => { const common = createCommonOptions(); const releaseSsr = createDeferred(); diff --git a/tests/app-ssr-stream.test.ts b/tests/app-ssr-stream.test.ts index 6cda68f03..c30548958 100644 --- a/tests/app-ssr-stream.test.ts +++ b/tests/app-ssr-stream.test.ts @@ -129,6 +129,25 @@ describe("createRscEmbedTransform raw buffer (#981)", () => { expect(finalScripts).toContain('"initialCacheKind":"static"'); expect(finalScripts).not.toContain("dynamicStaleTimeSeconds"); + expect(finalScripts).not.toContain("staleTimeSeconds"); + }); + + it("emits the completed render's cacheLife stale time in the done script", async () => { + // The getter runs at finalize() time — after the RSC stream drains — so a + // cacheLife claim registered mid-stream still reaches the done script. + let staleTimeSeconds: number | undefined; + const transform = createRscEmbedTransform(createTextStream(["chunk"]), undefined, () => ({ + kind: "static", + ...(staleTimeSeconds === undefined ? {} : { staleTimeSeconds }), + })); + + staleTimeSeconds = 30; + const finalScripts = await transform.finalize(); + + expect(finalScripts).toContain('"staleTimeSeconds":30'); + expect(finalScripts.indexOf("staleTimeSeconds")).toBeLessThan( + finalScripts.indexOf(".done=true"), + ); }); it("rejects getRawBuffer when the stream errors (#1002)", async () => { diff --git a/tests/app-visited-response-cache.test.ts b/tests/app-visited-response-cache.test.ts index 2d7e24d72..27d3147c5 100644 --- a/tests/app-visited-response-cache.test.ts +++ b/tests/app-visited-response-cache.test.ts @@ -75,6 +75,109 @@ describe("visited response cache freshness", () => { ); }); + it("bounds reuse by the resolved cacheLife stale time from the server", () => { + // A `use cache` subtree declaring cacheLife("seconds") resolves stale: 30. + // Without honoring it the browser would hold this response for the full + // 5-minute visited-response TTL — 10x too long. + const now = 1_000_000; + const entry = createVisitedResponseCacheEntry({ + now, + params: {}, + response: createCachedResponse({ staleTimeSeconds: 30 }), + }); + + expect(entry.expiresAt).toBe(now + 30_000); + expect( + isVisitedResponseCacheEntryFresh(entry, { navigationKind: "navigate", now: now + 29_999 }), + ).toBe(true); + expect( + isVisitedResponseCacheEntryFresh(entry, { navigationKind: "navigate", now: now + 30_000 }), + ).toBe(false); + }); + + it("bounds a pending-stale cold navigation at the floor instead of the visited TTL", () => { + // A pending claim floors to at least 30s, so 30s is the conservative bound. + // The server always pairs the marker with a dynamic bound (next test); + // marker-only is the defensive fallback. + const now = 1_000_000; + const entry = createVisitedResponseCacheEntry({ + now, + params: {}, + response: createCachedResponse({ staleTimePending: true }), + }); + + expect(entry.expiresAt).toBe(now + 30_000); + }); + + it("applies the dynamic bound over the pending cap when both signals are present", () => { + // Resolver contract: the min always wins, including a dynamic bound of 0. + const now = 1_000_000; + const entry = createVisitedResponseCacheEntry({ + now, + params: {}, + response: createCachedResponse({ dynamicStaleTimeSeconds: 0, staleTimePending: true }), + }); + + expect(entry.expiresAt).toBe(now); + expect(isVisitedResponseCacheEntryFresh(entry, { navigationKind: "navigate", now })).toBe( + false, + ); + }); + + it("floors a shorter-than-minimum cacheLife stale time like the prefetch cache does", () => { + // One rule for both client caches, mirroring Next.js's getStaleTimeMs + // (max(stale, 30s) across the whole segment cache): cacheLife({ stale: 5 }) + // is held 30s whether the route arrived via a prefetch snapshot or a cold + // navigation. Without the shared floor the same declaration produced two + // behaviors keyed on whether a prefetch happened to fire first. + const now = 1_000_000; + const entry = createVisitedResponseCacheEntry({ + now, + params: {}, + response: createCachedResponse({ staleTimeSeconds: 5 }), + }); + + expect(entry.expiresAt).toBe(now + 30_000); + }); + + it("takes the minimum of the staleTimes config and the resolved cacheLife stale time", () => { + // Two independent min-wins lattices: experimental.staleTimes (reduced across + // segments by unstable_dynamicStaleTime) and cacheLife (reduced across + // `use cache` scopes). Neither may override the other, in either direction. + const now = 1_000_000; + + expect( + createVisitedResponseCacheEntry({ + now, + params: {}, + response: createCachedResponse({ dynamicStaleTimeSeconds: 120, staleTimeSeconds: 30 }), + }).expiresAt, + ).toBe(now + 30_000); + + expect( + createVisitedResponseCacheEntry({ + now, + params: {}, + response: createCachedResponse({ dynamicStaleTimeSeconds: 10, staleTimeSeconds: 300 }), + }).expiresAt, + ).toBe(now + 10_000); + }); + + it("keeps the configured fallback when the resolved cacheLife declares no stale time", () => { + // The `default` cacheLife profile has no `stale` and an `expire` of + // ~136 years. The server advertises nothing in that case, so the entry must + // stay on the configured visited-response TTL rather than being extended + // toward revalidate/expire. + const now = 1_000_000; + const entry = createVisitedResponseCacheEntry({ + now, + params: {}, + response: createCachedResponse(), + }); + + expect(entry.expiresAt).toBe(now + VISITED_RESPONSE_CACHE_TTL); + }); + it("inherits the expiry carried by a consumed prefetch snapshot", () => { const now = 1_000_000; const prefetchedExpiresAt = now + 30_000; diff --git a/tests/kv-cache-handler.test.ts b/tests/kv-cache-handler.test.ts index a99a4239c..410832705 100644 --- a/tests/kv-cache-handler.test.ts +++ b/tests/kv-cache-handler.test.ts @@ -555,6 +555,24 @@ describe("KVCacheHandler", () => { expect(kv.delete).toHaveBeenCalledWith("cache:expire-test"); }); + it("round-trips the client stale claim through stored cacheControl", async () => { + await handler.set( + "stale-round-trip", + { + kind: "APP_PAGE", + html: "
hi
", + rscData: undefined, + headers: undefined, + postponed: undefined, + status: 200, + }, + { cacheControl: { revalidate: 60, expire: 300, stale: 30 } }, + ); + + const hit = await handler.get("stale-round-trip"); + expect(hit?.cacheControl).toEqual({ revalidate: 60, expire: 300, stale: 30 }); + }); + it("serves stale when a shorter read-time revalidate has elapsed", async () => { vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(1_000); diff --git a/tests/prefetch-cache.test.ts b/tests/prefetch-cache.test.ts index 979b35a7e..cb0915faa 100644 --- a/tests/prefetch-cache.test.ts +++ b/tests/prefetch-cache.test.ts @@ -13,7 +13,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vite-plus/test" import { AppElementsWire } from "../packages/vinext/src/server/app-elements.js"; import { VINEXT_RSC_COMPATIBILITY_ID_HEADER } from "../packages/vinext/src/server/app-rsc-cache-busting.js"; import { + NEXT_ROUTER_STALE_TIME_HEADER, VINEXT_DYNAMIC_STALE_TIME_HEADER, + VINEXT_STALE_TIME_PENDING_HEADER, VINEXT_MOUNTED_SLOTS_HEADER, VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, } from "../packages/vinext/src/server/headers.js"; @@ -417,6 +419,36 @@ describe("prefetch cache eviction", () => { await expect(restored.text()).resolves.toBe("flight"); }); + it("carries the server-resolved cacheLife stale time through snapshot and replay", async () => { + const response = new Response("flight", { + headers: { + "content-type": "text/x-component", + [NEXT_ROUTER_STALE_TIME_HEADER]: "30", + }, + }); + + const snapshot = await snapshotRscResponse(response); + const restored = restoreRscResponse(snapshot); + + expect(snapshot.staleTimeSeconds).toBe(30); + expect(restored.headers.get(NEXT_ROUTER_STALE_TIME_HEADER)).toBe("30"); + }); + + it("carries the pending-stale marker through snapshot and replay", async () => { + const response = new Response("flight", { + headers: { + "content-type": "text/x-component", + [VINEXT_STALE_TIME_PENDING_HEADER]: "1", + }, + }); + + const snapshot = await snapshotRscResponse(response); + const restored = restoreRscResponse(snapshot); + + expect(snapshot.staleTimePending).toBe(true); + expect(restored.headers.get(VINEXT_STALE_TIME_PENDING_HEADER)).toBe("1"); + }); + it("releases queued App prefetch fetch slots after consuming the response body", async () => { let closeBody!: () => void; const body = new ReadableStream({ @@ -637,6 +669,94 @@ describe("prefetch cache eviction", () => { expect(consumed?.expiresAt).toBe(now + 30_000); }); + it("takes the shorter of the staleTimes config and the resolved cacheLife stale time", async () => { + // A route whose `use cache` subtree declares cacheLife("seconds") + // (stale: 30) alongside a 300s experimental.staleTimes value. Both are + // min-wins lattices, so the shorter one bounds prefetch reuse. + const now = 1_000_000; + vi.spyOn(Date, "now").mockReturnValue(now); + const rscUrl = "/cache-life-prefetch.rsc"; + + prefetchRscResponse( + rscUrl, + Promise.resolve( + new Response("flight", { + headers: { + "content-type": "text/x-component", + [VINEXT_DYNAMIC_STALE_TIME_HEADER]: "300", + [NEXT_ROUTER_STALE_TIME_HEADER]: "30", + }, + }), + ), + null, + null, + undefined, + { fallbackTtlMs: 300_000 }, + ); + await getPrefetchCache().get(rscUrl)?.pending; + + expect(getPrefetchCache().get(rscUrl)?.expiresAt).toBe(now + 30_000); + }); + + it("floors a shorter-than-minimum cacheLife stale time for prefetch entries", async () => { + // cacheLife({ stale: 1 }) is honored verbatim by the visited-response cache + // but floored here: Next.js's getStaleTimeMs clamps prefetch stale times to + // 30s so a too-short server value cannot prevent prefetching from ever + // paying off. + const now = 1_000_000; + vi.spyOn(Date, "now").mockReturnValue(now); + const rscUrl = "/tiny-cache-life-prefetch.rsc"; + + prefetchRscResponse( + rscUrl, + Promise.resolve( + new Response("flight", { + headers: { + "content-type": "text/x-component", + [NEXT_ROUTER_STALE_TIME_HEADER]: "1", + }, + }), + ), + null, + null, + undefined, + { fallbackTtlMs: PREFETCH_CACHE_TTL }, + ); + await getPrefetchCache().get(rscUrl)?.pending; + + expect(getPrefetchCache().get(rscUrl)?.expiresAt).toBe(now + 30_000); + }); + + it("bounds a pending-stale cold response at the floor instead of the fallback TTL", async () => { + // A cacheable render streamed before its cacheLife resolved (#961 keeps + // cold responses non-blocking) advertises no value, but the unresolved + // claim — once floored — can never license less than 30s. Holding the + // response for the 300s fallback would reproduce the headline bug on the + // first prefetch of every entry epoch. + const now = 1_000_000; + vi.spyOn(Date, "now").mockReturnValue(now); + const rscUrl = "/pending-stale-prefetch.rsc"; + + prefetchRscResponse( + rscUrl, + Promise.resolve( + new Response("flight", { + headers: { + "content-type": "text/x-component", + [VINEXT_STALE_TIME_PENDING_HEADER]: "1", + }, + }), + ), + null, + null, + undefined, + { fallbackTtlMs: 300_000 }, + ); + await getPrefetchCache().get(rscUrl)?.pending; + + expect(getPrefetchCache().get(rscUrl)?.expiresAt).toBe(now + 30_000); + }); + it("leaves a resolved in-flight prefetch for a newer navigation when the old navigation is stale", async () => { const rscUrl = "/dashboard.rsc"; const deferred = createDeferredResponse(); diff --git a/tests/prerender-kv-populate.test.ts b/tests/prerender-kv-populate.test.ts index 8bb06ddf2..0a2c0bfab 100644 --- a/tests/prerender-kv-populate.test.ts +++ b/tests/prerender-kv-populate.test.ts @@ -46,6 +46,7 @@ describe("buildPrerenderKVPairs", () => { status: "rendered", revalidate: 60, expire: 300, + stale: 30, router: "app", headers: { link: "; rel=preload; as=font" }, tags: ["test-update-tag"], @@ -81,7 +82,7 @@ describe("buildPrerenderKVPairs", () => { lastModified: 1_000, revalidateAt: 61_000, expireAt: 301_000, - cacheControl: { revalidate: 60, expire: 300 }, + cacheControl: { revalidate: 60, expire: 300, stale: 30 }, }); expect(pairs[0].metadata).toEqual({ tags: htmlEntry.tags }); expect(htmlEntry.tags).toContain("/about"); diff --git a/tests/prerender.test.ts b/tests/prerender.test.ts index 00a9b8e03..f13a485c0 100644 --- a/tests/prerender.test.ts +++ b/tests/prerender.test.ts @@ -17,6 +17,7 @@ import { buildPagesFixture, buildAppFixture, buildCloudflareAppFixture } from ". import { extractRscPayloadFromPrerenderedHtml, resolveParentParams, + writePrerenderIndex, type PrerenderRouteResult, type StaticParamsMap, } from "../packages/vinext/src/build/prerender.js"; @@ -704,6 +705,39 @@ describe("prerenderPages — default mode (pages-basic)", () => { }); }); +describe("writePrerenderIndex", () => { + it("carries the resolved cacheLife stale into the written index", () => { + // Regression: seedMemoryCacheFromPrerender reads `route.stale` from + // vinext-prerender.json — dropping it here silently reverts seeded cache + // hits to the configured staleTimes fallback. + const dir = tmpDir("vinext-prerender-index-"); + writePrerenderIndex( + [ + { + route: "/cached", + status: "rendered", + outputFiles: ["cached.html"], + revalidate: 60, + expire: 300, + stale: 30, + router: "app", + }, + ], + dir, + { buildId: "b1" }, + ); + + const index = JSON.parse(fs.readFileSync(path.join(dir, "vinext-prerender.json"), "utf-8")); + expect(index.routes[0]).toMatchObject({ + route: "/cached", + revalidate: 60, + expire: 300, + stale: 30, + }); + fs.rmSync(dir, { recursive: true, force: true }); + }); +}); + describe("prerenderPages — export mode (pages-basic)", () => { let outDir: string; let results: PrerenderRouteResult[]; diff --git a/tests/seed-cache.test.ts b/tests/seed-cache.test.ts index 01d738395..6c093fd0b 100644 --- a/tests/seed-cache.test.ts +++ b/tests/seed-cache.test.ts @@ -385,6 +385,47 @@ describe("seedMemoryCacheFromPrerender", () => { ]); }); + it("seeds the prerender's client stale time onto both artifacts", async () => { + // A prerendered page's `cacheLife` resolves before the artifact is written, + // so the seeded entry can carry the claim. Without it, a page served from + // the seed would be reusable for the configured staleTimes default while an + // identical runtime-rendered entry honored `cacheLife`. + const writes: { key: string; staleSeconds?: number }[] = []; + + setupPrerenderFixture( + serverDir, + { + buildId: "seed-stale-test", + routes: [ + { + route: "/isr", + status: "rendered", + revalidate: 60, + expire: 300, + stale: 30, + router: "app", + }, + ], + }, + { + "isr.html": "ISR", + "isr.rsc": "RSC isr", + }, + ); + + await seedMemoryCacheFromPrerender(serverDir, { + async writeAppPageEntry(key, _data, metadata): Promise { + writes.push({ key, staleSeconds: metadata.staleSeconds }); + }, + }); + + const baseKey = isrCacheKey("app", "/isr", "seed-stale-test"); + expect(writes).toEqual([ + { key: baseKey + ":html", staleSeconds: 30 }, + { key: baseKey + ":rsc", staleSeconds: 30 }, + ]); + }); + it("can use injected runtime app page cache key builders", async () => { const keys: string[] = []; diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 0fe8ae7b3..de9e6e7d2 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -6506,6 +6506,89 @@ describe('"use cache" runtime', () => { expect(callCount).toBe(1); }); + it("a use cache hit re-registers its client stale time on the request scope", async () => { + // The enclosing render's minimum is what gets persisted onto the page cache + // entry. If a warm `use cache` hit contributed revalidate/expire but dropped + // `stale`, the page entry would advertise a wider client-reuse window than + // the identical cold render did — the same output under two different + // freshness claims depending only on data-cache temperature. + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { + setCacheHandler, + MemoryCacheHandler, + cacheLife, + _peekRequestScopedCacheLife, + _runWithCacheState, + } = await import("../packages/vinext/src/shims/cache.js"); + setCacheHandler(new MemoryCacheHandler()); + + let callCount = 0; + const cached = registerCachedFunction(async () => { + // `seconds` is { stale: 30, revalidate: 1, expire: 60 }. + cacheLife("seconds"); + callCount++; + return { data: "value" }; + }, "test:stale-replay"); + + const coldStale = await _runWithCacheState(async () => { + await cached(); + return _peekRequestScopedCacheLife()?.stale; + }); + expect(callCount).toBe(1); + expect(coldStale).toBe(30); + + const warmStale = await _runWithCacheState(async () => { + await cached(); + return _peekRequestScopedCacheLife()?.stale; + }); + expect(callCount).toBe(1); + expect(warmStale).toBe(30); + }); + + it("a nested use cache hit constrains the enclosing cache entry's lifetime", async () => { + // Mirrors the MISS path's `parentCtx.lifeConfigs.push`: an inner HIT during + // an outer MISS must still constrain the outer entry, or the child's stale + // claim disappears once the outer itself goes warm. + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { + setCacheHandler, + MemoryCacheHandler, + cacheLife, + _peekRequestScopedCacheLife, + _runWithCacheState, + } = await import("../packages/vinext/src/shims/cache.js"); + setCacheHandler(new MemoryCacheHandler()); + + let innerCalls = 0; + let outerCalls = 0; + const inner = registerCachedFunction(async () => { + cacheLife({ stale: 30, revalidate: 300, expire: 600 }); + innerCalls++; + return "inner"; + }, "test:nested-inner"); + const outer = registerCachedFunction(async () => { + outerCalls++; + return await inner(); + }, "test:nested-outer"); + + // Warm the inner entry alone, then run the outer cold: the inner HITs + // inside the outer's execution. + await _runWithCacheState(() => inner()); + await _runWithCacheState(() => outer()); + expect(innerCalls).toBe(1); + expect(outerCalls).toBe(1); + + // Outer warm HIT — its stored entry must carry the inner's stale claim. + const warmStale = await _runWithCacheState(async () => { + await outer(); + return _peekRequestScopedCacheLife()?.stale; + }); + expect(outerCalls).toBe(1); + expect(warmStale).toBe(30); + }); + it("registerCachedFunction collects cacheTag", async () => { const { registerCachedFunction } = await import("../packages/vinext/src/shims/cache-runtime.js");