diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index 3dae76083f..46d16b1f3c 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -571,7 +571,7 @@ async function runAppPageRevalidationContext< currentFetchCacheMode: options.currentFetchCacheMode ?? null, currentForceDynamicFetchDefault: options.dynamicConfig === "force-dynamic", executionContext: getRequestExecutionContext(), - unstableCacheRevalidation: "foreground", + functionCacheRevalidationMode: "foreground", }); const revalidation = runWithRequestContext(requestContext, async () => { diff --git a/packages/vinext/src/server/app-route-handler-dispatch.ts b/packages/vinext/src/server/app-route-handler-dispatch.ts index 37383c1b1a..94541623d9 100644 --- a/packages/vinext/src/server/app-route-handler-dispatch.ts +++ b/packages/vinext/src/server/app-route-handler-dispatch.ts @@ -132,7 +132,7 @@ async function runInRouteHandlerRevalidationContext( const requestContext = createRequestContext({ headersContext, executionContext: getRequestExecutionContext(), - unstableCacheRevalidation: "foreground", + functionCacheRevalidationMode: "foreground", }); const revalidation = runWithRequestContext(requestContext, async () => { diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index c5ee2740a1..c8d6a88264 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -1432,7 +1432,14 @@ export function createAppRscHandler( const requestContext = createRequestContext({ headersContext, executionContext, - unstableCacheRevalidation: "background", + // Ordinary runtime requests serve stale `use cache` data and refresh in + // the background. A build/prerender request (VINEXT_PRERENDER=1) instead + // bakes the response into a static artifact, so it must await the refresh + // in the foreground — otherwise a stale persistent entry would be written + // into the generated artifact. Matches the static-generation dispatch + // paths, which also force foreground during prerendering. + functionCacheRevalidationMode: + process.env.VINEXT_PRERENDER === "1" ? "foreground" : "background", }); const responsePromise = runWithRequestContext(requestContext, () => diff --git a/packages/vinext/src/shims/cache-request-state.ts b/packages/vinext/src/shims/cache-request-state.ts index c1a8307902..6b49ddbae0 100644 --- a/packages/vinext/src/shims/cache-request-state.ts +++ b/packages/vinext/src/shims/cache-request-state.ts @@ -41,7 +41,20 @@ export function getRegisteredCacheContext(): CacheContextLike | null { return getCacheContext?.() ?? null; } -export type UnstableCacheRevalidationMode = "foreground" | "background"; +/** + * Controls stale reads for the function caches ("use cache" and + * unstable_cache) only. Patched fetch response caching deliberately keeps its + * own `refreshStaleFetchesInForeground` flag (fetch-cache.ts) for now: its + * default is fail-open (serve stale, background refetch) across every request + * path, while this mode's default is fail-closed, so folding fetch into this + * field would need an audit of Pages Router and fallback-scope requests first. + * Converging both onto one request-level freshness policy is tracked in + * https://github.com/cloudflare/vinext/issues/2685; until then this field is + * intentionally named narrowly so it does not read as the authoritative + * policy for all persistent caches. + */ +export type FunctionCacheRevalidationMode = "foreground" | "background"; +export type CacheReadAction = "serve" | "serve-and-revalidate" | "revalidate"; export type ActionRevalidationKind = 0 | 1 | 2; export type UnstableCacheObservation = Readonly<{ kind: "unstable_cache"; @@ -57,7 +70,7 @@ export type CacheState = { pendingRevalidations: Set>; requestScopedCacheLife: CacheLifeConfig | null; unstableCacheObservations: Map; - unstableCacheRevalidation: UnstableCacheRevalidationMode; + functionCacheRevalidationMode: FunctionCacheRevalidationMode; }; const FALLBACK_KEY = Symbol.for("vinext.cache.fallback"); @@ -74,7 +87,7 @@ const fallbackState = (globalState[FALLBACK_KEY] ??= { pendingRevalidations: new Set>(), requestScopedCacheLife: null, unstableCacheObservations: new Map(), - unstableCacheRevalidation: "foreground", + functionCacheRevalidationMode: "foreground", } satisfies CacheState) as CacheState; function getCacheState(): CacheState { @@ -92,7 +105,7 @@ export function _runWithCacheState(fn: () => T | Promise): T | Promise context.actionRevalidationKind = ACTION_DID_NOT_REVALIDATE; context.requestScopedCacheLife = null; context.unstableCacheObservations = new Map(); - context.unstableCacheRevalidation = "foreground"; + context.functionCacheRevalidationMode = "foreground"; }, fn); } const state: CacheState = { @@ -101,7 +114,7 @@ export function _runWithCacheState(fn: () => T | Promise): T | Promise pendingRevalidations: new Set>(), requestScopedCacheLife: null, unstableCacheObservations: new Map(), - unstableCacheRevalidation: "foreground", + functionCacheRevalidationMode: "foreground", }; return cacheAls.run(state, fn); } @@ -245,6 +258,22 @@ export function _peekUnstableCacheObservations(): UnstableCacheObservation[] { ); } -export function shouldServeStaleUnstableCacheEntry(): boolean { - return getCacheState().unstableCacheRevalidation === "background"; +export function getFunctionCacheRevalidationMode(): FunctionCacheRevalidationMode { + return getCacheState().functionCacheRevalidationMode; +} + +/** + * Decide whether a function/data-cache value can satisfy the current read. + * An absent state is a fresh value. Stale values are policy-dependent, while + * expired or unrecognized states must be regenerated before use. + */ +export function decideCacheRead( + cacheState: string | undefined, + mode: FunctionCacheRevalidationMode, +): CacheReadAction { + if (cacheState === undefined) return "serve"; + if (cacheState === "stale") { + return mode === "background" ? "serve-and-revalidate" : "revalidate"; + } + return "revalidate"; } diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 13adb09efb..c8d99e7be8 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -32,6 +32,7 @@ import { getDataCacheHandler, type CachedFetchValue, type CacheControlMetadata, + type CacheHandler, type CacheHandlerValue, } from "./cache-handler.js"; import { @@ -39,17 +40,22 @@ import { _hasPendingRevalidatedTag, _setRequestScopedCacheLife, _registerCacheContextAccessor, + decideCacheRead, + getFunctionCacheRevalidationMode, type CacheLifeConfig, } from "./cache-request-state.js"; import { VINEXT_RSC_MARKER_HEADER } from "../server/headers.js"; import { addCollectedRequestTags, getCurrentFetchSoftTags } from "./fetch-cache.js"; import { getOrCreateAls } from "./internal/als-registry.js"; +import { scheduleBackgroundCacheRevalidation } from "./internal/cache-revalidation.js"; import { + createCacheRevalidationContext, isInsideUnifiedScope, getRequestContext, + runWithRequestContext, runWithUnifiedStateMutation, } from "./unified-request-context.js"; -import { markDynamicUsage } from "./headers.js"; +import { isDraftModeEnabled, markDynamicUsage } from "./headers.js"; import { trackPprFallbackShellCacheTask } from "./ppr-fallback-shell.js"; import { isMarkedAppPagePropsObject } from "./internal/app-page-props-cache-key.js"; @@ -472,6 +478,13 @@ export function registerCachedFunction( const cachedFn = (...args: TArgs): Promise => trackPprFallbackShellCacheTask(async (): Promise => { + // Preview data may differ from published data. Draft requests still run + // inside the normal cache scope so cacheLife() and cache API restrictions + // apply, but they must never read or populate shared storage. + if (cacheVariant !== "private" && isDraftModeEnabled()) { + return executeWithContext(fn, args, cacheVariant); + } + const rsc = await getRscModule(); const keySeed = getUseCacheKeySeed(); @@ -564,95 +577,60 @@ export function registerCachedFunction( console.error("[vinext] use cache: handler.get failed; treating as a cache miss:", error); } } + const cacheReadAction = decideCacheRead( + existing?.cacheState, + getFunctionCacheRevalidationMode(), + ); if ( existing?.value && existing.value.kind === "FETCH" && - existing.cacheState !== "stale" && + cacheReadAction !== "revalidate" && !_hasPendingRevalidatedTag([...(existing.value.tags ?? []), ...softTags]) ) { try { - // Surface the cached entry's tags to the surrounding request so the - // enclosing page / route-handler ISR entry carries them even on a data - // cache HIT — otherwise `revalidateTag()` could not evict the rendered - // output that embeds this cached value (issue #1453). - propagateCacheTagsToRequest(existing.value.tags); + let result: TResult; if (rsc && existing.value.data.headers[VINEXT_RSC_MARKER_HEADER] === "1") { // RSC-serialized entry: base64 → bytes → stream → deserialize const bytes = base64ToUint8(existing.value.data.body); const stream = uint8ToStream(bytes); - const result = await rsc.createFromReadableStream(stream); - recordRequestScopedCacheControl(existing.cacheControl); - return result; + result = await rsc.createFromReadableStream(stream); + } else { + // JSON-serialized entry (legacy or no RSC available) + result = JSON.parse(existing.value.data.body); + } + // Surface tags and cache-life only after deserialization succeeds. A + // corrupted entry falls through to foreground execution and must not + // affect the enclosing page's cache metadata. + propagateCacheTagsToRequest(existing.value.tags); + // Custom or legacy handlers may omit the optional `cacheControl` + // metadata; the required FETCH-value `revalidate` still bounds the + // served entry's lifetime, so an enclosing scope must inherit it. + propagateHitCacheControl( + existing.cacheControl ?? { revalidate: existing.value.revalidate }, + ); + if (cacheReadAction === "serve-and-revalidate") { + scheduleBackgroundCacheRevalidation( + cacheKey, + () => + runWithRequestContext(createCacheRevalidationContext(softTags), () => + refreshSharedCacheEntry(fn, args, cacheVariant, cacheKey, handler, rsc, { + skipOuterPropagation: true, + reportCacheWriteErrors: true, + }), + ), + (error) => { + console.error("[vinext] use cache background revalidation failed:", error); + }, + ); } - // JSON-serialized entry (legacy or no RSC available) - const result = JSON.parse(existing.value.data.body); - recordRequestScopedCacheControl(existing.cacheControl); return result; } catch { // Corrupted entry, fall through to re-execute } } - // Cache miss (or stale) — execute with context - const { result, ctx, effectiveLife } = await runCachedFunctionWithContext( - fn, - args, - cacheVariant, - ); - - recordRequestScopedCacheLife(effectiveLife); - // Bubble the cache scope's tags up to the surrounding request so the - // enclosing page / route-handler ISR entry is tagged for on-demand - // revalidation (issue #1453). `ctx.tags` already includes any nested - // child cache's tags via `runCachedFunctionWithContext`. - propagateCacheTagsToRequest(ctx.tags); - const revalidateSeconds = - effectiveLife.revalidate ?? cacheLifeProfiles.default.revalidate ?? 900; - - // Store in cache — use RSC stream serialization when available (handles - // React elements, client refs, Promises, etc.), JSON otherwise. - try { - let body: string; - const headers: Record = {}; - - if (rsc) { - // RSC serialization: result → stream → bytes → base64. - // No temporaryReferences — cached values must be self-contained - // since they're persisted across requests. - const stream = rsc.renderToReadableStream(result); - const bytes = await collectStream(stream); - body = uint8ToBase64(bytes); - headers[VINEXT_RSC_MARKER_HEADER] = "1"; - } else { - // JSON fallback - body = JSON.stringify(result); - if (body === undefined) return result; - } - - const cacheValue = { - kind: "FETCH", - data: { - headers, - body, - url: cacheKey, - }, - tags: ctx.tags, - revalidate: revalidateSeconds, - } satisfies CachedFetchValue; - - await handler.set(cacheKey, cacheValue, { - fetchCache: true, - tags: ctx.tags, - cacheControl: { - revalidate: revalidateSeconds, - expire: effectiveLife.expire, - }, - }); - } catch { - // Result not serializable — skip caching, still return the result - } - - return result; + // Cache miss or unusable entry — execute with context + return refreshSharedCacheEntry(fn, args, cacheVariant, cacheKey, handler, rsc); }, cacheVariant); // Preserve the original function's arity on the wrapper. The wrapper is @@ -678,6 +656,91 @@ export function registerCachedFunction( return cachedFn; } +type CacheExecutionOptions = { + skipOuterPropagation?: boolean; + reportCacheWriteErrors?: boolean; +}; + +async function refreshSharedCacheEntry( + fn: (...args: TArgs) => Promise, + args: TArgs, + cacheVariant: string, + cacheKey: string, + handler: CacheHandler, + rsc: RscModule | null, + options: CacheExecutionOptions = {}, +): Promise { + const { result, ctx, effectiveLife } = await runCachedFunctionWithContext( + fn, + args, + cacheVariant, + options, + ); + + if (!options.skipOuterPropagation) { + recordRequestScopedCacheLife(effectiveLife); + // Bubble the cache scope's tags up to the surrounding request so the + // enclosing page / route-handler ISR entry is tagged for on-demand + // revalidation (issue #1453). `ctx.tags` already includes any nested + // child cache's tags via `runCachedFunctionWithContext`. + propagateCacheTagsToRequest(ctx.tags); + } + const revalidateSeconds = effectiveLife.revalidate ?? cacheLifeProfiles.default.revalidate ?? 900; + + // Serialize first so unsupported values retain the existing non-fatal + // behavior without hiding storage failures from background refreshes. + let cacheValue: CachedFetchValue; + try { + let body: string; + const headers: Record = {}; + + if (rsc) { + // RSC serialization: result → stream → bytes → base64. + // No temporaryReferences — cached values must be self-contained + // since they're persisted across requests. + const stream = rsc.renderToReadableStream(result); + const bytes = await collectStream(stream); + body = uint8ToBase64(bytes); + headers[VINEXT_RSC_MARKER_HEADER] = "1"; + } else { + // JSON fallback + body = JSON.stringify(result); + if (body === undefined) return result; + } + + cacheValue = { + kind: "FETCH", + data: { + headers, + body, + url: cacheKey, + }, + tags: ctx.tags, + revalidate: revalidateSeconds, + } satisfies CachedFetchValue; + } catch { + // Result not serializable — skip caching, still return the result + return result; + } + + try { + await handler.set(cacheKey, cacheValue, { + fetchCache: true, + tags: ctx.tags, + cacheControl: { + revalidate: revalidateSeconds, + expire: effectiveLife.expire, + }, + }); + } catch (error) { + if (options.reportCacheWriteErrors) { + console.error("[vinext] use cache background revalidation cache write failed:", error); + } + } + + return result; +} + /** @internal Symbol used to identify "use cache" wrapper functions. */ const USE_CACHE_FUNCTION_SYMBOL = Symbol.for("vinext.useCacheFunction"); /** @internal Symbol carrying transform-derived cached function argument metadata. */ @@ -692,14 +755,35 @@ function throwPrivateUseCacheInsidePublicUseCacheError(): never { throw error; } -function recordRequestScopedCacheControl(cacheControl: CacheControlMetadata | undefined): void { - if (cacheControl === undefined) return; - _setRequestScopedCacheLife({ +/** + * Surface a data-cache HIT entry's stored cache-control into the surrounding + * scopes, mirroring the MISS path's dual propagation. + * + * On a MISS the executed child's `effectiveLife` reaches both the parent cache + * context (`runCachedFunctionWithContext` pushes it into `parentCtx.lifeConfigs` + * for the outer's minimum-wins `resolveCacheLife`) and the request store + * (`recordRequestScopedCacheLife`). On a HIT the child function never runs, so + * this is the only place the served entry's lifetime can constrain an enclosing + * `"use cache"` scope. Without the parent push, an outer miss that embeds this + * HIT would resolve its own (typically longer, e.g. the default 900s) lifetime + * and store the outer entry with a `revalidate`/`expire` wider than the child it + * contains — serving stale nested data past the child's own window even though a + * background refresh for the child was scheduled separately. Minimum-wins on + * both the parent `lifeConfigs` and the request store keeps the double record + * safe. + */ +function propagateHitCacheControl(cacheControl: CacheControlMetadata): void { + const life: CacheLifeConfig = { // `false` is an indefinite lifetime and therefore does not constrain an // enclosing cache scope's finite revalidation window. revalidate: cacheControl.revalidate === false ? undefined : cacheControl.revalidate, expire: cacheControl.expire, - }); + }; + const parentCtx = cacheContextStorage.getStore(); + if (parentCtx) { + parentCtx.lifeConfigs.push(life); + } + _setRequestScopedCacheLife(life); } function recordRequestScopedCacheLife(cacheLife: CacheLifeConfig): void { @@ -797,8 +881,10 @@ async function runCachedFunctionWithContext Promis // oxlint-disable-next-line @typescript-eslint/no-explicit-any args: any[], variant: string, + options: CacheExecutionOptions = {}, ): Promise>>> { const parentCtx = cacheContextStorage.getStore(); + const shouldPropagateToOuter = !options.skipOuterPropagation; // Eagerly capture an error at the call site if we're inside a public cache. // Private parents are intentionally excluded — "use cache: private" is @@ -819,7 +905,7 @@ async function runCachedFunctionWithContext Promis // bottleneck for cache-heavy workloads, switching to a lazy capture would // be the optimization — at the cost of less useful stack frames. let eagerError: Error | undefined; - if (parentCtx && parentCtx.variant !== "private") { + if (shouldPropagateToOuter && parentCtx && parentCtx.variant !== "private") { eagerError = new NestedDynamicUseCacheError(); if (typeof Error.captureStackTrace === "function") { Error.captureStackTrace(eagerError, runCachedFunctionWithContext); @@ -875,7 +961,7 @@ async function runCachedFunctionWithContext Promis // threshold checks below would evaluate false, and the throw would never // fire. (The `hasExplicit*` guards then independently decide whether to // suppress the throw — see the longer comment below.) - if (parentCtx) { + if (shouldPropagateToOuter && parentCtx) { parentCtx.lifeConfigs.push(effectiveLife); // Bubble this inner cache's tags into the parent cache scope so the // outer entry (and ultimately the request) is invalidated when a tag @@ -894,6 +980,7 @@ async function runCachedFunctionWithContext Promis // Next.js: see `dynamicNestedCacheError ??=` in // packages/next/src/server/use-cache/use-cache-wrapper.ts. if ( + shouldPropagateToOuter && parentCtx && eagerError && (effectiveLife.revalidate === 0 || diff --git a/packages/vinext/src/shims/cache.ts b/packages/vinext/src/shims/cache.ts index 0716b44052..8ed94427fb 100644 --- a/packages/vinext/src/shims/cache.ts +++ b/packages/vinext/src/shims/cache.ts @@ -25,14 +25,18 @@ import { markDynamicUsage as _markDynamic, } from "./headers.js"; import { getOrCreateAls } from "./internal/als-registry.js"; +import { scheduleBackgroundCacheRevalidation } from "./internal/cache-revalidation.js"; import { fnv1a64 } from "../utils/hash.js"; -import { isInsideUnifiedScope, getRequestContext } from "./unified-request-context.js"; import { workUnitAsyncStorage } from "./internal/work-unit-async-storage.js"; import { makeHangingPromise } from "./internal/make-hanging-promise.js"; import { encodeCacheTag, encodeCacheTags } from "../utils/encode-cache-tag.js"; import { getCdnCacheAdapter } from "./cdn-cache.js"; import { getDataCacheHandler, type CachedFetchValue } from "./cache-handler.js"; import { getRequestExecutionContext } from "./request-context.js"; +import { + createCacheRevalidationContext, + runWithRequestContext, +} from "./unified-request-context.js"; import { addCollectedRequestTags, getCurrentFetchSoftTags } from "./fetch-cache.js"; import { ACTION_DID_REVALIDATE_DYNAMIC_ONLY, @@ -42,18 +46,17 @@ import { _queuePendingRevalidation, _setRequestScopedCacheLife, cacheLifeProfiles, + decideCacheRead, + getFunctionCacheRevalidationMode, getRegisteredCacheContext, markActionRevalidation, recordUnstableCacheObservation, - shouldServeStaleUnstableCacheEntry, type CacheLifeConfig, } from "./cache-request-state.js"; export * from "./cache-handler.js"; export * from "./cache-request-state.js"; -const _g = globalThis as unknown as Record; - // --------------------------------------------------------------------------- // Request-scoped ExecutionContext ALS // @@ -513,46 +516,6 @@ type UnstableCacheOptions = { tags?: string[]; }; -const _UNSTABLE_CACHE_PENDING_REVALIDATIONS_KEY = Symbol.for( - "vinext.unstableCache.pendingRevalidations", -); - -function getPendingUnstableCacheRevalidations(): Map> { - const existing = _g[_UNSTABLE_CACHE_PENDING_REVALIDATIONS_KEY]; - if (existing instanceof Map) return existing; - - const pending = new Map>(); - _g[_UNSTABLE_CACHE_PENDING_REVALIDATIONS_KEY] = pending; - return pending; -} - -function waitUntilUnstableCacheRevalidation(promise: Promise): void { - if (!isInsideUnifiedScope()) return; - getRequestContext().executionContext?.waitUntil(promise); -} - -function scheduleUnstableCacheBackgroundRevalidation( - cacheKey: string, - refresh: () => Promise, -): void { - const pending = getPendingUnstableCacheRevalidations(); - if (pending.has(cacheKey)) return; - - const revalidation = refresh() - .then(() => undefined) - .catch((err) => { - console.error(`[vinext] unstable_cache background revalidation failed for ${cacheKey}:`, err); - }); - const trackedRevalidation = revalidation.finally(() => { - if (pending.get(cacheKey) === trackedRevalidation) { - pending.delete(cacheKey); - } - }); - - pending.set(cacheKey, trackedRevalidation); - waitUntilUnstableCacheRevalidation(trackedRevalidation); -} - async function refreshUnstableCacheResult( fn: (...args: Args) => Promise, args: Args, @@ -627,8 +590,8 @@ export function unstable_cache Promise>( const isDraftMode = isDraftModeEnabled(); if (!isDraftMode) { // Try to get from cache. Stale entries are usable in normal App Router - // requests, but foreground-refresh inside revalidation scopes so the - // regenerated page/route stores fresh data. + // requests, but revalidation scopes and unusable states must refresh in + // the foreground so the caller receives fresh data. const softTags = getCurrentFetchSoftTags(); const existing = _hasPendingRevalidatedTag([...tags, ...softTags]) ? null @@ -638,20 +601,39 @@ export function unstable_cache Promise>( softTags, }); if (existing?.value && existing.value.kind === "FETCH") { - const cached = tryDeserializeUnstableCacheResult(existing.value.data.body); - if (cached.ok) { - if (existing.cacheState === "stale") { - if (shouldServeStaleUnstableCacheEntry()) { - scheduleUnstableCacheBackgroundRevalidation(cacheKey, () => - refreshUnstableCacheResult(fn, args, cacheKey, tags, revalidateSeconds), + const cacheReadAction = decideCacheRead( + existing.cacheState, + getFunctionCacheRevalidationMode(), + ); + if (cacheReadAction !== "revalidate") { + const cached = tryDeserializeUnstableCacheResult(existing.value.data.body); + if (cached.ok) { + if (cacheReadAction === "serve-and-revalidate") { + // The detached refresh is a synthetic cache work unit, not a + // continuation of the triggering request: it runs in an isolated + // context so cached fetches and observations inside the callback + // cannot mutate this request's output containers, and its + // foreground mode forces nested stale dependencies to refresh + // before the regenerated entry is stored. + scheduleBackgroundCacheRevalidation( + cacheKey, + () => + runWithRequestContext(createCacheRevalidationContext(softTags), () => + refreshUnstableCacheResult(fn, args, cacheKey, tags, revalidateSeconds), + ), + (error) => { + console.error( + `[vinext] unstable_cache background revalidation failed for ${cacheKey}:`, + error, + ); + }, ); - return cached.value; } - } else { return cached.value; } } - // Corrupted entries fall through to a foreground refresh. + // Expired, unrecognized, and corrupted entries fall through to a + // foreground refresh. } } diff --git a/packages/vinext/src/shims/internal/cache-revalidation.ts b/packages/vinext/src/shims/internal/cache-revalidation.ts new file mode 100644 index 0000000000..28f99a5f5a --- /dev/null +++ b/packages/vinext/src/shims/internal/cache-revalidation.ts @@ -0,0 +1,51 @@ +import { getRequestExecutionContext } from "../request-context.js"; + +const PENDING_BACKGROUND_CACHE_REVALIDATIONS = Symbol.for( + "vinext.cache.pendingBackgroundRevalidations", +); +const globalState = globalThis as unknown as Record; + +function getPendingBackgroundCacheRevalidations(): Map> { + const existing = globalState[PENDING_BACKGROUND_CACHE_REVALIDATIONS]; + if (existing instanceof Map) return existing; + + const pending = new Map>(); + globalState[PENDING_BACKGROUND_CACHE_REVALIDATIONS] = pending; + return pending; +} + +/** + * Start at most one background refresh for a logical data-cache key. + * + * The caller owns the refresh's execution context and error message. This + * helper owns only isolate-wide deduplication, cleanup, rejection guarding, + * and attachment to the triggering request's runtime lifetime. + */ +export function scheduleBackgroundCacheRevalidation( + cacheKey: string, + refresh: () => Promise, + reportError: (error: unknown) => void, +): void { + const pending = getPendingBackgroundCacheRevalidations(); + if (pending.has(cacheKey)) return; + + const revalidation = Promise.resolve() + .then(refresh) + .then(() => undefined) + .catch((error) => { + reportError(error); + }); + const trackedRevalidation = revalidation.finally(() => { + if (pending.get(cacheKey) === trackedRevalidation) { + pending.delete(cacheKey); + } + }); + + pending.set(cacheKey, trackedRevalidation); + const executionContext = getRequestExecutionContext(); + if (executionContext) { + executionContext.waitUntil(trackedRevalidation); + } else { + void trackedRevalidation; + } +} diff --git a/packages/vinext/src/shims/unified-request-context.ts b/packages/vinext/src/shims/unified-request-context.ts index 223175ff90..75e9189afb 100644 --- a/packages/vinext/src/shims/unified-request-context.ts +++ b/packages/vinext/src/shims/unified-request-context.ts @@ -113,7 +113,7 @@ export function createRequestContext(opts?: Partial): Uni serverInsertedHTMLCallbacks: [], requestScopedCacheLife: null, unstableCacheObservations: new Map(), - unstableCacheRevalidation: "foreground", + functionCacheRevalidationMode: "foreground", _privateCache: null, cacheableFetchUrls: new Set(), currentRequestTags: [], @@ -142,6 +142,50 @@ export function createRequestContext(opts?: Partial): Uni }; } +/** + * Create the isolated request state used to regenerate a shared data-cache + * entry after serving stale data. + * + * Cache callbacks retain only supported cache-scope inputs from the triggering + * request, while request headers, cookies, and every response-owned output + * container start fresh. The refresh runs in foreground mode so nested stale + * dependencies are resolved before the refreshed entry is stored. + */ +export function createCacheRevalidationContext( + fallbackSoftTags: readonly string[] = [], +): UnifiedRequestContext { + const outer = _als.getStore(); + const rootParams = outer?.rootParams + ? Object.fromEntries( + Object.entries(outer.rootParams).map(([name, value]) => [ + name, + Array.isArray(value) ? [...value] : value, + ]), + ) + : null; + + return createRequestContext({ + executionContext: outer ? outer.executionContext : _getInheritedExecutionContext(), + // draftMode().isEnabled is readable inside public cache scopes. Preserve + // that API with a disabled provider, without exposing request data to the + // detached refresh. headers() and cookies() remain blocked by cache scope. + headersContext: { + headers: new Headers(), + cookies: new Map(), + draftModeEnabled: false, + draftModeSecret: outer?.headersContext?.draftModeSecret, + }, + pendingRevalidatedTags: new Set(outer?.pendingRevalidatedTags ?? []), + currentFetchSoftTags: [...(outer?.currentFetchSoftTags ?? fallbackSoftTags)], + currentFetchCacheMode: outer?.currentFetchCacheMode ?? null, + currentForceDynamicFetchDefault: outer?.currentForceDynamicFetchDefault ?? false, + isFetchDedupeActive: outer?.isFetchDedupeActive ?? false, + currentFetchDedupeEntries: new Map(), + rootParams, + functionCacheRevalidationMode: "foreground", + }); +} + function ensureAfterCompletion(ctx: UnifiedRequestContext): void { const state = ctx.afterContext; if (state.resolveCompletion && state.completion) return; diff --git a/tests/app-page-element-builder.test.ts b/tests/app-page-element-builder.test.ts index 7a2598bf22..19c1e3d7f1 100644 --- a/tests/app-page-element-builder.test.ts +++ b/tests/app-page-element-builder.test.ts @@ -42,6 +42,7 @@ const { markDynamicUsageMock, markRenderRequestApiUsageMock } = vi.hoisted(() => vi.mock("../packages/vinext/src/shims/headers.js", () => ({ getHeadersAccessPhase: () => "render", + isDraftModeEnabled: () => false, markDynamicUsage: markDynamicUsageMock, markRenderRequestApiUsage: markRenderRequestApiUsageMock, throwIfInsideCacheScope: vi.fn(), diff --git a/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index 2de3d93201..1fd666d747 100644 --- a/tests/app-rsc-handler.test.ts +++ b/tests/app-rsc-handler.test.ts @@ -1051,6 +1051,46 @@ describe("createAppRscHandler", () => { } }); + it("seeds foreground function-cache revalidation only while prerendering", async () => { + // Ordinary runtime requests serve stale "use cache"/unstable_cache data and + // refresh in the background. A build/prerender request (VINEXT_PRERENDER=1) + // bakes the response into a static artifact, so it must await the refresh in + // the foreground — otherwise a stale persistent entry would be written into + // the generated artifact. Regression for the mode seeded at the handler's + // createRequestContext call. + const { getFunctionCacheRevalidationMode } = + await import("../packages/vinext/src/shims/cache-request-state.js"); + + async function observeMode(): Promise { + let observed = ""; + const handler = createHandler({ + configHeaders: [], + dispatchMatchedPage: async () => { + observed = getFunctionCacheRevalidationMode(); + return new Response("page", { status: 200 }); + }, + }); + const response = await handler(new Request("https://example.test/docs/about"), null); + expect(response.status).toBe(200); + return observed; + } + + const previousPrerender = process.env.VINEXT_PRERENDER; + try { + delete process.env.VINEXT_PRERENDER; + expect(await observeMode()).toBe("background"); + + process.env.VINEXT_PRERENDER = "1"; + expect(await observeMode()).toBe("foreground"); + } finally { + if (previousPrerender === undefined) { + delete process.env.VINEXT_PRERENDER; + } else { + process.env.VINEXT_PRERENDER = previousPrerender; + } + } + }); + it("ignores encoded prerender route params from a different rewritten route pattern", async () => { const previousPrerender = process.env.VINEXT_PRERENDER; process.env.VINEXT_PRERENDER = "1"; diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 79ed9542a3..bdcddd0624 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -6478,6 +6478,16 @@ describe("next/cache shim", () => { setCacheHandler(new MemoryCacheHandler()); }); + it("decideCacheRead distinguishes fresh, stale, and unusable cache states", async () => { + const { decideCacheRead } = await import("../packages/vinext/src/shims/cache-request-state.js"); + + expect(decideCacheRead(undefined, "background")).toBe("serve"); + expect(decideCacheRead("stale", "background")).toBe("serve-and-revalidate"); + expect(decideCacheRead("stale", "foreground")).toBe("revalidate"); + expect(decideCacheRead("expired", "background")).toBe("revalidate"); + expect(decideCacheRead("unknown", "background")).toBe("revalidate"); + }); + it("unstable_cache serves stale entries and refreshes them in the background during App Router requests", async () => { const { unstable_cache, setCacheHandler, MemoryCacheHandler } = await import("../packages/vinext/src/shims/cache.js"); @@ -6529,7 +6539,7 @@ describe("next/cache shim", () => { // pending revalidate and return the stale response immediately. // Source: https://github.com/vercel/next.js/blob/canary/packages/next/src/server/web/spec-extension/unstable-cache.ts const requestContext = createRequestContext({ - unstableCacheRevalidation: "background", + functionCacheRevalidationMode: "background", executionContext: { waitUntil(promise) { waitUntilPromises.push(promise); @@ -6556,6 +6566,159 @@ describe("next/cache shim", () => { } }); + it("runs unstable_cache background refreshes as isolated work units with foreground nested reads", async () => { + const { unstable_cache, setCacheHandler, MemoryCacheHandler } = + await import("../packages/vinext/src/shims/cache.js"); + const { createRequestContext, runWithRequestContext } = + await import("../packages/vinext/src/shims/unified-request-context.js"); + + const setBodies = new Map(); + const handler: CacheHandler = { + async get(key: string): Promise { + return { + lastModified: Date.now() - 2_000, + cacheState: "stale", + value: { + kind: "FETCH", + data: { + headers: {}, + body: JSON.stringify({ + v: key.includes("swr-nested-inner") ? "inner-stale" : "outer-stale", + }), + url: key, + }, + tags: [], + revalidate: 1, + }, + }; + }, + async set(key: string, data: IncrementalCacheValue | null) { + if (data?.kind === "FETCH") { + setBodies.set(key, data.data.body); + } + }, + async revalidateTag(_tags: string | string[]) {}, + }; + setCacheHandler(handler); + + let innerCalls = 0; + const inner = unstable_cache( + async () => { + innerCalls++; + return "inner-fresh"; + }, + ["swr-nested-inner"], + { tags: ["nested-inner-tag"], revalidate: 1 }, + ); + let outerCalls = 0; + const outer = unstable_cache( + async () => { + outerCalls++; + return { inner: await inner() }; + }, + ["swr-nested-outer"], + { revalidate: 1 }, + ); + + const waitUntilPromises: Promise[] = []; + const requestContext = createRequestContext({ + functionCacheRevalidationMode: "background", + executionContext: { + waitUntil(promise) { + waitUntilPromises.push(promise); + }, + }, + }); + + try { + // The stale outer entry is served without waiting for the refresh. + await expect(runWithRequestContext(requestContext, () => outer())).resolves.toBe( + "outer-stale", + ); + expect(waitUntilPromises).toHaveLength(1); + await Promise.all(waitUntilPromises); + + // The detached refresh is a synthetic cache work unit: the nested stale + // inner entry is regenerated in the foreground, so the stored outer + // value is assembled from fresh nested data instead of the stale inner + // entry a "background"-mode read would have served. + expect(outerCalls).toBe(1); + expect(innerCalls).toBe(1); + expect(setBodies.get("unstable_cache:swr-nested-inner:[]")).toBe( + JSON.stringify({ v: "inner-fresh" }), + ); + expect(setBodies.get("unstable_cache:swr-nested-outer:[]")).toBe( + JSON.stringify({ v: { inner: "inner-fresh" } }), + ); + + // Tags and observations recorded inside the refresh belong to its + // isolated context, not to the request that happened to trigger it. + expect(requestContext.currentRequestTags).toEqual([]); + expect(requestContext.unstableCacheObservations.size).toBe(1); + } finally { + setCacheHandler(new MemoryCacheHandler()); + } + }); + + it("unstable_cache revalidates expired entries in the foreground", async () => { + const { unstable_cache, setCacheHandler, MemoryCacheHandler } = + await import("../packages/vinext/src/shims/cache.js"); + const { createRequestContext, runWithRequestContext } = + await import("../packages/vinext/src/shims/unified-request-context.js"); + + const setEntry = vi.fn(async () => {}); + setCacheHandler({ + async get() { + return { + lastModified: Date.now() - 60_000, + cacheState: "expired", + value: { + kind: "FETCH", + data: { + headers: {}, + body: JSON.stringify({ v: "expired-value" }), + url: "unstable_cache:expired-test:[]", + }, + tags: ["expired"], + revalidate: 1, + }, + }; + }, + set: setEntry, + async revalidateTag() {}, + }); + + const waitUntilCalls: Promise[] = []; + const requestContext = createRequestContext({ + functionCacheRevalidationMode: "background", + executionContext: { + waitUntil(promise) { + waitUntilCalls.push(promise); + }, + }, + }); + let callCount = 0; + const cached = unstable_cache( + async () => { + callCount++; + return "fresh-value"; + }, + ["expired-test"], + { tags: ["expired"], revalidate: 1 }, + ); + + try { + await expect(runWithRequestContext(requestContext, () => cached())).resolves.toBe( + "fresh-value", + ); + expect(callCount).toBe(1); + expect(waitUntilCalls).toHaveLength(0); + expect(setEntry).toHaveBeenCalledOnce(); + } finally { + setCacheHandler(new MemoryCacheHandler()); + } + }); + it("unstable_cache blocks on stale entries inside revalidation scopes", async () => { const { unstable_cache, setCacheHandler, MemoryCacheHandler } = await import("../packages/vinext/src/shims/cache.js"); @@ -6599,7 +6762,7 @@ describe("next/cache shim", () => { // regenerating a static/ISR page so the regenerated page stores fresh data. // Source test: https://github.com/vercel/next.js/blob/canary/test/production/app-dir/unstable-cache-foreground-revalidate/unstable-cache-foreground-revalidate.test.ts const requestContext = createRequestContext({ - unstableCacheRevalidation: "foreground", + functionCacheRevalidationMode: "foreground", }); try { @@ -6844,6 +7007,661 @@ describe('"use cache" runtime', () => { expect(callCount).toBe(1); }); + it("serves concurrent stale shared entries while one background revalidation runs", async () => { + // Ported from Next.js: test/e2e/app-dir/use-cache-swr/use-cache-swr.test.ts + // https://github.com/vercel/next.js/blob/a6223ac95d5e5a2f542d9bb76bd41e7451a21c73/test/e2e/app-dir/use-cache-swr/use-cache-swr.test.ts + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { setCacheHandler, MemoryCacheHandler, cacheLife } = + await import("../packages/vinext/src/shims/cache.js"); + const { addCollectedRequestTags, getCurrentFetchSoftTags } = + await import("../packages/vinext/src/shims/fetch-cache.js"); + const { draftMode } = await import("../packages/vinext/src/shims/headers.js"); + const { getRootParam } = await import("../packages/vinext/src/shims/root-params.js"); + const { createRequestContext, getRequestContext, runWithRequestContext } = + await import("../packages/vinext/src/shims/unified-request-context.js"); + + const staleEntry = { + lastModified: Date.now() - 2_000, + cacheState: "stale", + cacheControl: { revalidate: 3_600, expire: 7_200 }, + value: { + kind: "FETCH", + data: { + headers: {}, + body: JSON.stringify({ version: "stale" }), + url: "use-cache:test:stale-swr", + }, + tags: ["stale-entry-tag"], + revalidate: 3_600, + }, + } satisfies CacheHandlerValue; + const setEntry = vi.fn(async () => {}); + setCacheHandler({ + async get() { + return staleEntry; + }, + set: setEntry, + async revalidateTag() {}, + }); + + let markRevalidationStarted = () => {}; + const revalidationStarted = new Promise((resolve) => { + markRevalidationStarted = resolve; + }); + let releaseRevalidation = () => {}; + const revalidationGate = new Promise((resolve) => { + releaseRevalidation = resolve; + }); + const firstWaitUntilCalls: Promise[] = []; + const secondWaitUntilCalls: Promise[] = []; + const staleCalls: Promise<{ version: string }>[] = []; + const firstRequestContext = createRequestContext({ + functionCacheRevalidationMode: "background", + currentFetchSoftTags: ["implicit-route-tag"], + headersContext: { + headers: new Headers({ "x-request-only": "first" }), + cookies: new Map([["request-only", "first"]]), + draftModeSecret: "test-secret", + }, + isFetchDedupeActive: true, + rootParams: { lang: "en" }, + executionContext: { + waitUntil(promise) { + firstWaitUntilCalls.push(promise); + }, + }, + }); + const secondRequestContext = createRequestContext({ + functionCacheRevalidationMode: "background", + currentFetchSoftTags: ["implicit-route-tag"], + headersContext: { + headers: new Headers({ "x-request-only": "second" }), + cookies: new Map([["request-only", "second"]]), + draftModeSecret: "test-secret", + }, + isFetchDedupeActive: true, + rootParams: { lang: "en" }, + executionContext: { + waitUntil(promise) { + secondWaitUntilCalls.push(promise); + }, + }, + }); + let revalidationCalls = 0; + let refreshSoftTags: string[] = []; + let refreshRootParam: string | string[] | undefined; + let refreshDraftModeEnabled: boolean | undefined; + let refreshRequestHeader: string | null | undefined; + let refreshRequestCookie: string | undefined; + let refreshDraftModeSecret: string | undefined; + let refreshFetchDedupeActive = false; + let refreshFetchDedupeEntries: unknown; + + const cached = registerCachedFunction(async () => { + revalidationCalls++; + try { + refreshSoftTags = getCurrentFetchSoftTags(); + refreshRootParam = await getRootParam("lang"); + refreshDraftModeEnabled = (await draftMode()).isEnabled; + const refreshHeadersContext = getRequestContext().headersContext; + refreshRequestHeader = refreshHeadersContext?.headers.get("x-request-only"); + refreshRequestCookie = refreshHeadersContext?.cookies.get("request-only"); + refreshDraftModeSecret = refreshHeadersContext?.draftModeSecret; + refreshFetchDedupeActive = getRequestContext().isFetchDedupeActive; + refreshFetchDedupeEntries = getRequestContext().currentFetchDedupeEntries; + cacheLife({ stale: 1, revalidate: 1, expire: 60 }); + addCollectedRequestTags(["refresh-fetch-tag"]); + // These are the exact request-state slices updated by cached and + // uncached fetch observations during a real refresh. + getRequestContext().cacheableFetchUrls.add("https://example.com/refresh-cacheable"); + getRequestContext().dynamicFetchUrls.add("https://example.com/refresh-dynamic"); + } finally { + markRevalidationStarted(); + } + await revalidationGate; + return { version: "fresh" }; + }, "test:stale-swr"); + + try { + staleCalls.push( + runWithRequestContext(firstRequestContext, () => cached()), + runWithRequestContext(secondRequestContext, () => cached()), + ); + await revalidationStarted; + + const outcome = await Promise.race([ + Promise.all(staleCalls).then((values) => ({ status: "returned" as const, values })), + new Promise<{ status: "blocked" }>((resolve) => { + setImmediate(() => resolve({ status: "blocked" })); + }), + ]); + + expect(outcome).toEqual({ + status: "returned", + values: [{ version: "stale" }, { version: "stale" }], + }); + expect( + [firstWaitUntilCalls.length, secondWaitUntilCalls.length].sort((a, b) => a - b), + ).toEqual([0, 1]); + expect(revalidationCalls).toBe(1); + expect(refreshSoftTags).toEqual(["implicit-route-tag"]); + expect(refreshRootParam).toBe("en"); + expect(refreshDraftModeEnabled).toBe(false); + expect(refreshRequestHeader).toBeNull(); + expect(refreshRequestCookie).toBeUndefined(); + expect(refreshDraftModeSecret).toBe("test-secret"); + expect(refreshFetchDedupeActive).toBe(true); + expect(refreshFetchDedupeEntries).not.toBe(firstRequestContext.currentFetchDedupeEntries); + expect(refreshFetchDedupeEntries).not.toBe(secondRequestContext.currentFetchDedupeEntries); + } finally { + releaseRevalidation(); + await Promise.allSettled([...staleCalls, ...firstWaitUntilCalls, ...secondWaitUntilCalls]); + setCacheHandler(new MemoryCacheHandler()); + } + + expect(setEntry).toHaveBeenCalledOnce(); + for (const requestContext of [firstRequestContext, secondRequestContext]) { + expect(requestContext.requestScopedCacheLife).toEqual({ + revalidate: 3_600, + expire: 7_200, + }); + expect(requestContext.currentRequestTags).toEqual(["stale-entry-tag"]); + expect(requestContext.cacheableFetchUrls).toEqual(new Set()); + expect(requestContext.dynamicFetchUrls).toEqual(new Set()); + } + }); + + it("propagates a stale nested-hit entry's cache life into the enclosing cache entry", async () => { + // Regression: when a stale child "use cache" hit is served in background + // mode inside an outer "use cache" miss, the outer entry must inherit the + // child's shorter revalidate/expire. The child function never runs on a + // hit, so its served cache-control is the only thing that can constrain the + // parent scope. Without propagating it into the parent's lifeConfigs, the + // outer entry is stored under the default 900s lifetime and would serve the + // embedded stale child data long past the child's own window — even though + // the child's background refresh was scheduled separately. + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { setCacheHandler, MemoryCacheHandler } = + await import("../packages/vinext/src/shims/cache.js"); + const { createRequestContext, runWithRequestContext } = + await import("../packages/vinext/src/shims/unified-request-context.js"); + + const childKey = "use-cache:test:swr-nested-child"; + const outerKey = "use-cache:test:swr-nested-outer"; + + const staleChild = { + lastModified: Date.now() - 2_000, + cacheState: "stale", + cacheControl: { revalidate: 5, expire: 50 }, + value: { + kind: "FETCH", + data: { headers: {}, body: JSON.stringify({ child: "stale" }), url: childKey }, + tags: [], + revalidate: 5, + }, + } satisfies CacheHandlerValue; + + const setEntry = vi.fn(async () => {}); + setCacheHandler({ + // Child key is a stale hit; outer key is a miss so the outer executes and + // embeds the served child value. + async get(key) { + return key === childKey ? staleChild : null; + }, + set: setEntry, + async revalidateTag() {}, + }); + + const waitUntilCalls: Promise[] = []; + const requestContext = createRequestContext({ + functionCacheRevalidationMode: "background", + executionContext: { + waitUntil(promise) { + waitUntilCalls.push(promise); + }, + }, + }); + + const child = registerCachedFunction(async () => ({ child: "fresh" }), "test:swr-nested-child"); + // The outer sets no cacheLife of its own, so the nested child is its only + // lifetime source; the default would otherwise resolve to 900s. + const outer = registerCachedFunction(async () => { + const c = await child(); + return { outer: true, c }; + }, "test:swr-nested-outer"); + + try { + await runWithRequestContext(requestContext, () => outer()); + const outerSet = setEntry.mock.calls.find(([key]) => key === outerKey); + expect(outerSet).toBeDefined(); + expect((outerSet![2] as { cacheControl?: unknown }).cacheControl).toEqual({ + revalidate: 5, + expire: 50, + }); + } finally { + await Promise.allSettled(waitUntilCalls); + setCacheHandler(new MemoryCacheHandler()); + } + }); + + it("derives a stale nested hit's cache life from the entry revalidate when cacheControl is absent", async () => { + // Regression: a custom CacheHandler or legacy entry can return a stale + // FETCH value without the optional `cacheControl` metadata. The required + // `value.revalidate` still bounds the entry's lifetime, so an outer miss + // embedding the served hit must inherit it instead of resolving the + // default 900s window around stale child data. + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { setCacheHandler, MemoryCacheHandler } = + await import("../packages/vinext/src/shims/cache.js"); + const { createRequestContext, runWithRequestContext } = + await import("../packages/vinext/src/shims/unified-request-context.js"); + + const childKey = "use-cache:test:swr-bare-child"; + const outerKey = "use-cache:test:swr-bare-outer"; + + const staleChild = { + lastModified: Date.now() - 2_000, + cacheState: "stale", + value: { + kind: "FETCH", + data: { headers: {}, body: JSON.stringify({ child: "stale" }), url: childKey }, + tags: [], + revalidate: 5, + }, + } satisfies CacheHandlerValue; + + const setEntry = vi.fn(async () => {}); + setCacheHandler({ + async get(key) { + return key === childKey ? staleChild : null; + }, + set: setEntry, + async revalidateTag() {}, + }); + + const waitUntilCalls: Promise[] = []; + const requestContext = createRequestContext({ + functionCacheRevalidationMode: "background", + executionContext: { + waitUntil(promise) { + waitUntilCalls.push(promise); + }, + }, + }); + + const child = registerCachedFunction(async () => ({ child: "fresh" }), "test:swr-bare-child"); + const outer = registerCachedFunction(async () => { + const c = await child(); + return { outer: true, c }; + }, "test:swr-bare-outer"); + + try { + await runWithRequestContext(requestContext, () => outer()); + const outerSet = setEntry.mock.calls.find(([key]) => key === outerKey); + expect(outerSet).toBeDefined(); + expect((outerSet![2] as { cacheControl?: unknown }).cacheControl).toEqual({ + revalidate: 5, + }); + } finally { + await Promise.allSettled(waitUntilCalls); + setCacheHandler(new MemoryCacheHandler()); + } + }); + + it('"use cache" revalidates expired shared entries in the foreground', async () => { + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { setCacheHandler, MemoryCacheHandler } = + await import("../packages/vinext/src/shims/cache.js"); + const { createRequestContext, runWithRequestContext } = + await import("../packages/vinext/src/shims/unified-request-context.js"); + + const setEntry = vi.fn(async () => {}); + setCacheHandler({ + async get() { + return { + lastModified: Date.now() - 60_000, + cacheState: "expired", + value: { + kind: "FETCH", + data: { + headers: {}, + body: JSON.stringify({ version: "expired" }), + url: "use-cache:test:expired", + }, + tags: [], + revalidate: 1, + }, + }; + }, + set: setEntry, + async revalidateTag() {}, + }); + + const waitUntilCalls: Promise[] = []; + const requestContext = createRequestContext({ + functionCacheRevalidationMode: "background", + executionContext: { + waitUntil(promise) { + waitUntilCalls.push(promise); + }, + }, + }); + let callCount = 0; + const cached = registerCachedFunction(async () => { + callCount++; + return { version: "fresh" }; + }, "test:expired"); + + try { + await expect(runWithRequestContext(requestContext, () => cached())).resolves.toEqual({ + version: "fresh", + }); + expect(callCount).toBe(1); + expect(waitUntilCalls).toHaveLength(0); + expect(setEntry).toHaveBeenCalledOnce(); + } finally { + setCacheHandler(new MemoryCacheHandler()); + } + }); + + it("bypasses persistent shared cache while draft mode is enabled", async () => { + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { setCacheHandler, MemoryCacheHandler, cacheLife } = + await import("../packages/vinext/src/shims/cache.js"); + const { draftMode, headersContextFromRequest } = + await import("../packages/vinext/src/shims/headers.js"); + const { createRequestContext, runWithRequestContext } = + await import("../packages/vinext/src/shims/unified-request-context.js"); + + const getEntry = vi.fn(async () => ({ + lastModified: Date.now() - 2_000, + cacheState: "stale", + cacheControl: { revalidate: 1, expire: 60 }, + value: { + kind: "FETCH", + data: { + headers: {}, + body: JSON.stringify({ version: "published-stale" }), + url: "use-cache:test:draft-mode-bypass", + }, + tags: [], + revalidate: 1, + }, + })); + const setEntry = vi.fn(async () => {}); + setCacheHandler({ + get: getEntry, + set: setEntry, + async revalidateTag() {}, + }); + + const waitUntilCalls: Promise[] = []; + const requestContext = createRequestContext({ + functionCacheRevalidationMode: "background", + headersContext: headersContextFromRequest( + new Request("https://example.com/preview", { + headers: { cookie: "__prerender_bypass=test-secret" }, + }), + { draftModeSecret: "test-secret" }, + ), + executionContext: { + waitUntil(promise) { + waitUntilCalls.push(promise); + }, + }, + }); + let calls = 0; + let observedDraftMode = false; + const cached = registerCachedFunction(async () => { + calls++; + observedDraftMode = (await draftMode()).isEnabled; + cacheLife({ revalidate: 5, expire: 60 }); + return { version: "preview-fresh" }; + }, "test:draft-mode-bypass"); + + try { + await expect(runWithRequestContext(requestContext, () => cached())).resolves.toEqual({ + version: "preview-fresh", + }); + expect(calls).toBe(1); + expect(observedDraftMode).toBe(true); + expect(getEntry).not.toHaveBeenCalled(); + expect(setEntry).not.toHaveBeenCalled(); + expect(waitUntilCalls).toHaveLength(0); + expect(requestContext.requestScopedCacheLife).toEqual({ + revalidate: 5, + expire: 60, + }); + } finally { + setCacheHandler(new MemoryCacheHandler()); + } + }); + + it("logs failed background writes and retries stale entries without waitUntil", async () => { + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { setCacheHandler, MemoryCacheHandler } = + await import("../packages/vinext/src/shims/cache.js"); + const { createRequestContext, runWithRequestContext } = + await import("../packages/vinext/src/shims/unified-request-context.js"); + + let resolveFirstWrite = () => {}; + const firstWrite = new Promise((resolve) => { + resolveFirstWrite = resolve; + }); + let resolveSecondWrite = () => {}; + const secondWrite = new Promise((resolve) => { + resolveSecondWrite = resolve; + }); + let writeCalls = 0; + setCacheHandler({ + async get() { + return { + lastModified: Date.now() - 2_000, + cacheState: "stale", + cacheControl: { revalidate: 1, expire: 60 }, + value: { + kind: "FETCH", + data: { + headers: {}, + body: JSON.stringify({ version: "stale" }), + url: "use-cache:test:stale-write-retry", + }, + tags: [], + revalidate: 1, + }, + }; + }, + async set() { + writeCalls++; + if (writeCalls === 1) { + resolveFirstWrite(); + throw new Error("cache backend unavailable"); + } + resolveSecondWrite(); + }, + async revalidateTag() {}, + }); + + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + let revalidationCalls = 0; + const cached = registerCachedFunction(async () => { + revalidationCalls++; + return { version: "fresh" }; + }, "test:stale-write-retry"); + const requestContext = createRequestContext({ + functionCacheRevalidationMode: "background", + executionContext: null, + }); + + try { + await expect(runWithRequestContext(requestContext, () => cached())).resolves.toEqual({ + version: "stale", + }); + await firstWrite; + await new Promise((resolve) => setImmediate(resolve)); + + expect(consoleError).toHaveBeenCalledWith( + "[vinext] use cache background revalidation cache write failed:", + expect.objectContaining({ message: "cache backend unavailable" }), + ); + + await expect(runWithRequestContext(requestContext, () => cached())).resolves.toEqual({ + version: "stale", + }); + await secondWrite; + await new Promise((resolve) => setImmediate(resolve)); + } finally { + consoleError.mockRestore(); + setCacheHandler(new MemoryCacheHandler()); + } + + expect(revalidationCalls).toBe(2); + expect(writeCalls).toBe(2); + }); + + it("refreshes stale shared entries in foreground runtime revalidation contexts", async () => { + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { setCacheHandler, MemoryCacheHandler } = + await import("../packages/vinext/src/shims/cache.js"); + const { createRequestContext, runWithRequestContext } = + await import("../packages/vinext/src/shims/unified-request-context.js"); + + const setEntry = vi.fn(async () => {}); + setCacheHandler({ + async get() { + return { + lastModified: Date.now() - 2_000, + cacheState: "stale", + cacheControl: { revalidate: 1, expire: 60 }, + value: { + kind: "FETCH", + data: { + headers: {}, + body: JSON.stringify({ version: "stale" }), + url: "use-cache:test:stale-runtime-isr", + }, + tags: [], + revalidate: 1, + }, + }; + }, + set: setEntry, + async revalidateTag() {}, + }); + + let markRefreshStarted = () => {}; + const refreshStarted = new Promise((resolve) => { + markRefreshStarted = resolve; + }); + let releaseRefresh = () => {}; + const refreshGate = new Promise((resolve) => { + releaseRefresh = resolve; + }); + const waitUntilCalls: Promise[] = []; + const requestContext = createRequestContext({ + functionCacheRevalidationMode: "foreground", + executionContext: { + waitUntil(promise) { + waitUntilCalls.push(promise); + }, + }, + }); + const cached = registerCachedFunction(async () => { + markRefreshStarted(); + await refreshGate; + return { version: "fresh" }; + }, "test:stale-runtime-isr"); + const resultPromise = runWithRequestContext(requestContext, () => cached()); + + try { + await refreshStarted; + const outcome = await Promise.race([ + resultPromise.then((value) => ({ status: "returned" as const, value })), + new Promise<{ status: "blocked" }>((resolve) => { + setImmediate(() => resolve({ status: "blocked" })); + }), + ]); + + expect(outcome).toEqual({ status: "blocked" }); + expect(waitUntilCalls).toHaveLength(0); + } finally { + releaseRefresh(); + } + + try { + await expect(resultPromise).resolves.toEqual({ version: "fresh" }); + expect(setEntry).toHaveBeenCalledOnce(); + expect(setEntry).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + kind: "FETCH", + data: expect.objectContaining({ body: JSON.stringify({ version: "fresh" }) }), + }), + expect.any(Object), + ); + } finally { + setCacheHandler(new MemoryCacheHandler()); + } + }); + + it("refreshes stale shared entries in the foreground during prerendering", async () => { + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { setCacheHandler, MemoryCacheHandler } = + await import("../packages/vinext/src/shims/cache.js"); + + const setEntry = vi.fn(async () => {}); + setCacheHandler({ + async get() { + return { + lastModified: Date.now() - 2_000, + cacheState: "stale", + cacheControl: { revalidate: 1, expire: 60 }, + value: { + kind: "FETCH", + data: { + headers: {}, + body: JSON.stringify({ version: "stale" }), + url: "use-cache:test:stale-prerender", + }, + tags: [], + revalidate: 1, + }, + }; + }, + set: setEntry, + async revalidateTag() {}, + }); + + let callCount = 0; + const cached = registerCachedFunction(async () => { + callCount++; + return { version: "fresh" }; + }, "test:stale-prerender"); + const previousPrerender = process.env.VINEXT_PRERENDER; + + try { + process.env.VINEXT_PRERENDER = "1"; + await expect(cached()).resolves.toEqual({ version: "fresh" }); + expect(callCount).toBe(1); + expect(setEntry).toHaveBeenCalledOnce(); + } finally { + if (previousPrerender === undefined) { + delete process.env.VINEXT_PRERENDER; + } else { + process.env.VINEXT_PRERENDER = previousPrerender; + } + setCacheHandler(new MemoryCacheHandler()); + } + }); + it("registerCachedFunction collects cacheTag", async () => { const { registerCachedFunction } = await import("../packages/vinext/src/shims/cache-runtime.js");