diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index ee42dc89d1..01a2d8621c 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -565,7 +565,7 @@ async function runAppPageRevalidationContext< currentFetchCacheMode: options.currentFetchCacheMode ?? null, currentForceDynamicFetchDefault: options.dynamicConfig === "force-dynamic", executionContext: getRequestExecutionContext(), - unstableCacheRevalidation: "foreground", + cacheRevalidationMode: "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..913142e2d4 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", + cacheRevalidationMode: "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 de89ff34e5..5d0cf8abf4 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -1428,7 +1428,7 @@ export function createAppRscHandler( const requestContext = createRequestContext({ headersContext, executionContext, - unstableCacheRevalidation: "background", + cacheRevalidationMode: "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..9e70666332 100644 --- a/packages/vinext/src/shims/cache-request-state.ts +++ b/packages/vinext/src/shims/cache-request-state.ts @@ -41,7 +41,12 @@ export function getRegisteredCacheContext(): CacheContextLike | null { return getCacheContext?.() ?? null; } -export type UnstableCacheRevalidationMode = "foreground" | "background"; +/** + * Controls stale function/data-cache reads. Patched fetch response caching has + * a separate policy because it owns response streams and refetch timeouts. + */ +export type CacheRevalidationMode = "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 +62,7 @@ export type CacheState = { pendingRevalidations: Set>; requestScopedCacheLife: CacheLifeConfig | null; unstableCacheObservations: Map; - unstableCacheRevalidation: UnstableCacheRevalidationMode; + cacheRevalidationMode: CacheRevalidationMode; }; const FALLBACK_KEY = Symbol.for("vinext.cache.fallback"); @@ -74,7 +79,7 @@ const fallbackState = (globalState[FALLBACK_KEY] ??= { pendingRevalidations: new Set>(), requestScopedCacheLife: null, unstableCacheObservations: new Map(), - unstableCacheRevalidation: "foreground", + cacheRevalidationMode: "foreground", } satisfies CacheState) as CacheState; function getCacheState(): CacheState { @@ -92,7 +97,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.cacheRevalidationMode = "foreground"; }, fn); } const state: CacheState = { @@ -101,7 +106,7 @@ export function _runWithCacheState(fn: () => T | Promise): T | Promise pendingRevalidations: new Set>(), requestScopedCacheLife: null, unstableCacheObservations: new Map(), - unstableCacheRevalidation: "foreground", + cacheRevalidationMode: "foreground", }; return cacheAls.run(state, fn); } @@ -245,6 +250,22 @@ export function _peekUnstableCacheObservations(): UnstableCacheObservation[] { ); } -export function shouldServeStaleUnstableCacheEntry(): boolean { - return getCacheState().unstableCacheRevalidation === "background"; +export function getCacheRevalidationMode(): CacheRevalidationMode { + return getCacheState().cacheRevalidationMode; +} + +/** + * 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: CacheRevalidationMode, +): 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..b0ba4dafba 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, + getCacheRevalidationMode, 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,52 @@ export function registerCachedFunction( console.error("[vinext] use cache: handler.get failed; treating as a cache miss:", error); } } + const cacheReadAction = decideCacheRead(existing?.cacheState, getCacheRevalidationMode()); 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); } - // JSON-serialized entry (legacy or no RSC available) - const result = JSON.parse(existing.value.data.body); + // Surface tags 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); recordRequestScopedCacheControl(existing.cacheControl); + 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); + }, + ); + } 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 +648,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. */ @@ -797,8 +852,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 +876,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 +932,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 +951,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..96c541922e 100644 --- a/packages/vinext/src/shims/cache.ts +++ b/packages/vinext/src/shims/cache.ts @@ -25,8 +25,8 @@ 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"; @@ -42,18 +42,17 @@ import { _queuePendingRevalidation, _setRequestScopedCacheLife, cacheLifeProfiles, + decideCacheRead, + getCacheRevalidationMode, 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 +512,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 +586,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 +597,27 @@ 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, getCacheRevalidationMode()); + if (cacheReadAction !== "revalidate") { + const cached = tryDeserializeUnstableCacheResult(existing.value.data.body); + if (cached.ok) { + if (cacheReadAction === "serve-and-revalidate") { + scheduleBackgroundCacheRevalidation( + cacheKey, + () => 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 1c1243eac5..eb88ae5768 100644 --- a/packages/vinext/src/shims/unified-request-context.ts +++ b/packages/vinext/src/shims/unified-request-context.ts @@ -112,7 +112,7 @@ export function createRequestContext(opts?: Partial): Uni serverInsertedHTMLCallbacks: [], requestScopedCacheLife: null, unstableCacheObservations: new Map(), - unstableCacheRevalidation: "foreground", + cacheRevalidationMode: "foreground", _privateCache: null, cacheableFetchUrls: new Set(), currentRequestTags: [], @@ -140,6 +140,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, + cacheRevalidationMode: "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 49f9e970d6..f7aaa3aaca 100644 --- a/tests/app-page-element-builder.test.ts +++ b/tests/app-page-element-builder.test.ts @@ -40,6 +40,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/shims.test.ts b/tests/shims.test.ts index 0fe8ae7b37..3b0dfc0636 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -6140,6 +6140,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"); @@ -6191,7 +6201,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", + cacheRevalidationMode: "background", executionContext: { waitUntil(promise) { waitUntilPromises.push(promise); @@ -6218,6 +6228,65 @@ describe("next/cache shim", () => { } }); + 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({ + cacheRevalidationMode: "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"); @@ -6261,7 +6330,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", + cacheRevalidationMode: "foreground", }); try { @@ -6506,6 +6575,522 @@ 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({ + cacheRevalidationMode: "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({ + cacheRevalidationMode: "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('"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({ + cacheRevalidationMode: "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({ + cacheRevalidationMode: "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({ + cacheRevalidationMode: "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({ + cacheRevalidationMode: "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");