From 7627ed3a8716e4479c6d100fe47c2c1fba0bfb82 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:03:38 +1000 Subject: [PATCH 1/5] fix(cache): serve stale use cache entries during refresh Valid stale shared cache entries currently fall through to the miss path, so the triggering request waits for regeneration even though the entry remains usable until expiry. Return stale data immediately and coordinate one background refresh per cache key. Keep prerender refreshes in the foreground, isolate regeneration metadata while preserving supported cache-scope inputs, and attach guarded work to the request lifetime when available. --- packages/vinext/src/shims/cache-runtime.ts | 258 +++++++++++++------ tests/shims.test.ts | 277 +++++++++++++++++++++ 2 files changed, 463 insertions(+), 72 deletions(-) diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 13adb09efb..ff2257a749 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 { @@ -45,13 +46,16 @@ import { VINEXT_RSC_MARKER_HEADER } from "../server/headers.js"; import { addCollectedRequestTags, getCurrentFetchSoftTags } from "./fetch-cache.js"; import { getOrCreateAls } from "./internal/als-registry.js"; import { + createRequestContext, isInsideUnifiedScope, getRequestContext, + runWithRequestContext, runWithUnifiedStateMutation, } from "./unified-request-context.js"; import { markDynamicUsage } from "./headers.js"; import { trackPprFallbackShellCacheTask } from "./ppr-fallback-shell.js"; import { isMarkedAppPagePropsObject } from "./internal/app-page-props-cache-key.js"; +import { getRequestExecutionContext } from "./request-context.js"; export { markAppPagePropsForUseCache } from "./internal/app-page-props-cache-key.js"; @@ -564,29 +568,40 @@ export function registerCachedFunction( console.error("[vinext] use cache: handler.get failed; treating as a cache miss:", error); } } + const shouldRefreshStaleInForeground = + existing?.cacheState === "stale" && + typeof process !== "undefined" && + process.env.VINEXT_PRERENDER === "1"; if ( existing?.value && existing.value.kind === "FETCH" && - existing.cacheState !== "stale" && + !shouldRefreshStaleInForeground && !_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 (existing.cacheState === "stale") { + scheduleSharedCacheRevalidation(cacheKey, () => + refreshSharedCacheEntry(fn, args, cacheVariant, cacheKey, handler, rsc, { + skipOuterPropagation: true, + reportCacheWriteErrors: true, + }), + ); + } return result; } catch { // Corrupted entry, fall through to re-execute @@ -594,65 +609,7 @@ export function registerCachedFunction( } // 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; + return refreshSharedCacheEntry(fn, args, cacheVariant, cacheKey, handler, rsc); }, cacheVariant); // Preserve the original function's arity on the wrapper. The wrapper is @@ -678,6 +635,160 @@ 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; +} + +const _USE_CACHE_PENDING_REVALIDATIONS_KEY = Symbol.for("vinext.useCache.pendingRevalidations"); + +function getPendingSharedCacheRevalidations(): Map> { + const existing = _g[_USE_CACHE_PENDING_REVALIDATIONS_KEY]; + if (existing instanceof Map) return existing; + + const pending = new Map>(); + _g[_USE_CACHE_PENDING_REVALIDATIONS_KEY] = pending; + return pending; +} + +function scheduleSharedCacheRevalidation(cacheKey: string, refresh: () => Promise): void { + const pending = getPendingSharedCacheRevalidations(); + if (pending.has(cacheKey)) return; + + const outerRequestContext = isInsideUnifiedScope() ? getRequestContext() : null; + const executionContext = getRequestExecutionContext(); + // A stale response has already selected its cache metadata. Regeneration + // therefore runs in a fresh request-state container so cacheLife(), tags, + // and fetch observations produced by the refresh cannot mutate that response. + // Preserve only inputs that affect cache reads and runtime lifetime. + const revalidationContext = createRequestContext({ + executionContext, + headersContext: outerRequestContext?.headersContext + ? { + ...outerRequestContext.headersContext, + headers: new Headers(outerRequestContext.headersContext.headers), + cookies: new Map(outerRequestContext.headersContext.cookies), + mutableCookies: undefined, + readonlyCookies: undefined, + readonlyHeaders: undefined, + } + : null, + pendingRevalidatedTags: new Set(outerRequestContext?.pendingRevalidatedTags ?? []), + currentFetchSoftTags: [...getCurrentFetchSoftTags()], + currentFetchCacheMode: outerRequestContext?.currentFetchCacheMode ?? null, + currentForceDynamicFetchDefault: outerRequestContext?.currentForceDynamicFetchDefault ?? false, + isFetchDedupeActive: outerRequestContext?.isFetchDedupeActive ?? false, + currentFetchDedupeEntries: new Map(), + rootParams: outerRequestContext?.rootParams + ? Object.fromEntries( + Object.entries(outerRequestContext.rootParams).map(([name, value]) => [ + name, + Array.isArray(value) ? [...value] : value, + ]), + ) + : null, + }); + + const revalidation = Promise.resolve() + .then(() => runWithRequestContext(revalidationContext, refresh)) + .then(() => undefined) + .catch((error) => { + console.error("[vinext] use cache background revalidation failed:", error); + }); + const trackedRevalidation = revalidation.finally(() => { + if (pending.get(cacheKey) === trackedRevalidation) { + pending.delete(cacheKey); + } + }); + + pending.set(cacheKey, trackedRevalidation); + if (executionContext) { + executionContext.waitUntil(trackedRevalidation); + } else { + void trackedRevalidation; + } +} + /** @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 +908,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 +932,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 +988,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 +1007,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/tests/shims.test.ts b/tests/shims.test.ts index 0fe8ae7b37..96698de8e7 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -6506,6 +6506,283 @@ 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, headersContextFromRequest } = + 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({ + currentFetchSoftTags: ["implicit-route-tag"], + headersContext: headersContextFromRequest( + new Request("https://example.com/first", { + headers: { cookie: "__prerender_bypass=test-secret" }, + }), + { draftModeSecret: "test-secret" }, + ), + isFetchDedupeActive: true, + rootParams: { lang: "en" }, + executionContext: { + waitUntil(promise) { + firstWaitUntilCalls.push(promise); + }, + }, + }); + const secondRequestContext = createRequestContext({ + currentFetchSoftTags: ["implicit-route-tag"], + headersContext: headersContextFromRequest( + new Request("https://example.com/second", { + headers: { cookie: "__prerender_bypass=test-secret" }, + }), + { 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 = false; + let refreshFetchDedupeActive = false; + let refreshFetchDedupeEntries: unknown; + + const cached = registerCachedFunction(async () => { + revalidationCalls++; + refreshSoftTags = getCurrentFetchSoftTags(); + refreshRootParam = await getRootParam("lang"); + refreshDraftModeEnabled = (await draftMode()).isEnabled; + 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"); + 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(true); + 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("logs failed background writes and retries stale entries without a request context", async () => { + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { setCacheHandler, MemoryCacheHandler } = + await import("../packages/vinext/src/shims/cache.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"); + + try { + await expect(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(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 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"); From f9322a369236f6f58d673b57aafd9375018c6c3f Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:23:22 +1000 Subject: [PATCH 2/5] fix(cache): honor foreground stale revalidation Runtime ISR regeneration could store a fresh route artifact containing stale use-cache data because stale hits always detached their refresh outside build-time prerendering. That ignored the request policy already used by unstable_cache to require fresh dependencies while regenerating an artifact.\n\nUse one cache revalidation mode for both APIs, isolate background refresh request state behind a named boundary, and share the per-key scheduler lifecycle. The runtime ISR regression now proves a foreground stale read blocks, stores the fresh value, and registers no background work. --- .../vinext/src/server/app-page-dispatch.ts | 2 +- .../src/server/app-route-handler-dispatch.ts | 2 +- packages/vinext/src/server/app-rsc-handler.ts | 2 +- .../vinext/src/shims/cache-request-state.ts | 14 +-- packages/vinext/src/shims/cache-runtime.ts | 98 +++------------- packages/vinext/src/shims/cache.ts | 59 ++-------- .../src/shims/internal/cache-revalidation.ts | 51 +++++++++ .../src/shims/unified-request-context.ts | 48 +++++++- tests/shims.test.ts | 108 +++++++++++++++++- 9 files changed, 240 insertions(+), 144 deletions(-) create mode 100644 packages/vinext/src/shims/internal/cache-revalidation.ts 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..9c991746e5 100644 --- a/packages/vinext/src/shims/cache-request-state.ts +++ b/packages/vinext/src/shims/cache-request-state.ts @@ -41,7 +41,7 @@ export function getRegisteredCacheContext(): CacheContextLike | null { return getCacheContext?.() ?? null; } -export type UnstableCacheRevalidationMode = "foreground" | "background"; +export type CacheRevalidationMode = "foreground" | "background"; export type ActionRevalidationKind = 0 | 1 | 2; export type UnstableCacheObservation = Readonly<{ kind: "unstable_cache"; @@ -57,7 +57,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 +74,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 +92,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 +101,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 +245,6 @@ export function _peekUnstableCacheObservations(): UnstableCacheObservation[] { ); } -export function shouldServeStaleUnstableCacheEntry(): boolean { - return getCacheState().unstableCacheRevalidation === "background"; +export function shouldServeStaleCacheEntry(): boolean { + return getCacheState().cacheRevalidationMode === "background"; } diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index ff2257a749..b44a713dd7 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -40,13 +40,15 @@ import { _hasPendingRevalidatedTag, _setRequestScopedCacheLife, _registerCacheContextAccessor, + shouldServeStaleCacheEntry, 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 { - createRequestContext, + createCacheRevalidationContext, isInsideUnifiedScope, getRequestContext, runWithRequestContext, @@ -55,7 +57,6 @@ import { import { markDynamicUsage } from "./headers.js"; import { trackPprFallbackShellCacheTask } from "./ppr-fallback-shell.js"; import { isMarkedAppPagePropsObject } from "./internal/app-page-props-cache-key.js"; -import { getRequestExecutionContext } from "./request-context.js"; export { markAppPagePropsForUseCache } from "./internal/app-page-props-cache-key.js"; @@ -568,14 +569,11 @@ export function registerCachedFunction( console.error("[vinext] use cache: handler.get failed; treating as a cache miss:", error); } } - const shouldRefreshStaleInForeground = - existing?.cacheState === "stale" && - typeof process !== "undefined" && - process.env.VINEXT_PRERENDER === "1"; + const shouldUseCachedEntry = existing?.cacheState !== "stale" || shouldServeStaleCacheEntry(); if ( existing?.value && existing.value.kind === "FETCH" && - !shouldRefreshStaleInForeground && + shouldUseCachedEntry && !_hasPendingRevalidatedTag([...(existing.value.tags ?? []), ...softTags]) ) { try { @@ -595,11 +593,18 @@ export function registerCachedFunction( propagateCacheTagsToRequest(existing.value.tags); recordRequestScopedCacheControl(existing.cacheControl); if (existing.cacheState === "stale") { - scheduleSharedCacheRevalidation(cacheKey, () => - refreshSharedCacheEntry(fn, args, cacheVariant, cacheKey, handler, rsc, { - skipOuterPropagation: true, - reportCacheWriteErrors: true, - }), + 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; @@ -720,75 +725,6 @@ async function refreshSharedCacheEntry( return result; } -const _USE_CACHE_PENDING_REVALIDATIONS_KEY = Symbol.for("vinext.useCache.pendingRevalidations"); - -function getPendingSharedCacheRevalidations(): Map> { - const existing = _g[_USE_CACHE_PENDING_REVALIDATIONS_KEY]; - if (existing instanceof Map) return existing; - - const pending = new Map>(); - _g[_USE_CACHE_PENDING_REVALIDATIONS_KEY] = pending; - return pending; -} - -function scheduleSharedCacheRevalidation(cacheKey: string, refresh: () => Promise): void { - const pending = getPendingSharedCacheRevalidations(); - if (pending.has(cacheKey)) return; - - const outerRequestContext = isInsideUnifiedScope() ? getRequestContext() : null; - const executionContext = getRequestExecutionContext(); - // A stale response has already selected its cache metadata. Regeneration - // therefore runs in a fresh request-state container so cacheLife(), tags, - // and fetch observations produced by the refresh cannot mutate that response. - // Preserve only inputs that affect cache reads and runtime lifetime. - const revalidationContext = createRequestContext({ - executionContext, - headersContext: outerRequestContext?.headersContext - ? { - ...outerRequestContext.headersContext, - headers: new Headers(outerRequestContext.headersContext.headers), - cookies: new Map(outerRequestContext.headersContext.cookies), - mutableCookies: undefined, - readonlyCookies: undefined, - readonlyHeaders: undefined, - } - : null, - pendingRevalidatedTags: new Set(outerRequestContext?.pendingRevalidatedTags ?? []), - currentFetchSoftTags: [...getCurrentFetchSoftTags()], - currentFetchCacheMode: outerRequestContext?.currentFetchCacheMode ?? null, - currentForceDynamicFetchDefault: outerRequestContext?.currentForceDynamicFetchDefault ?? false, - isFetchDedupeActive: outerRequestContext?.isFetchDedupeActive ?? false, - currentFetchDedupeEntries: new Map(), - rootParams: outerRequestContext?.rootParams - ? Object.fromEntries( - Object.entries(outerRequestContext.rootParams).map(([name, value]) => [ - name, - Array.isArray(value) ? [...value] : value, - ]), - ) - : null, - }); - - const revalidation = Promise.resolve() - .then(() => runWithRequestContext(revalidationContext, refresh)) - .then(() => undefined) - .catch((error) => { - console.error("[vinext] use cache background revalidation failed:", error); - }); - const trackedRevalidation = revalidation.finally(() => { - if (pending.get(cacheKey) === trackedRevalidation) { - pending.delete(cacheKey); - } - }); - - pending.set(cacheKey, trackedRevalidation); - if (executionContext) { - executionContext.waitUntil(trackedRevalidation); - } else { - void trackedRevalidation; - } -} - /** @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. */ diff --git a/packages/vinext/src/shims/cache.ts b/packages/vinext/src/shims/cache.ts index 0716b44052..f81fc071b7 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"; @@ -45,15 +45,13 @@ import { getRegisteredCacheContext, markActionRevalidation, recordUnstableCacheObservation, - shouldServeStaleUnstableCacheEntry, + shouldServeStaleCacheEntry, 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 +511,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, @@ -641,9 +599,16 @@ export function unstable_cache Promise>( const cached = tryDeserializeUnstableCacheResult(existing.value.data.body); if (cached.ok) { if (existing.cacheState === "stale") { - if (shouldServeStaleUnstableCacheEntry()) { - scheduleUnstableCacheBackgroundRevalidation(cacheKey, () => - refreshUnstableCacheResult(fn, args, cacheKey, tags, revalidateSeconds), + if (shouldServeStaleCacheEntry()) { + scheduleBackgroundCacheRevalidation( + cacheKey, + () => refreshUnstableCacheResult(fn, args, cacheKey, tags, revalidateSeconds), + (error) => { + console.error( + `[vinext] unstable_cache background revalidation failed for ${cacheKey}:`, + error, + ); + }, ); return cached.value; } 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..dbb93b0fa8 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,52 @@ 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 supported read inputs from the triggering request, + * while every response-owned output container starts 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 headersContext = outer?.headersContext + ? { + ...outer.headersContext, + headers: new Headers(outer.headersContext.headers), + cookies: new Map(outer.headersContext.cookies), + mutableCookies: undefined, + readonlyCookies: undefined, + readonlyHeaders: undefined, + } + : null; + 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(), + headersContext, + 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/shims.test.ts b/tests/shims.test.ts index 96698de8e7..f55750edb3 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -6191,7 +6191,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); @@ -6261,7 +6261,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 { @@ -6557,6 +6557,7 @@ describe('"use cache" runtime', () => { const secondWaitUntilCalls: Promise[] = []; const staleCalls: Promise<{ version: string }>[] = []; const firstRequestContext = createRequestContext({ + cacheRevalidationMode: "background", currentFetchSoftTags: ["implicit-route-tag"], headersContext: headersContextFromRequest( new Request("https://example.com/first", { @@ -6573,6 +6574,7 @@ describe('"use cache" runtime', () => { }, }); const secondRequestContext = createRequestContext({ + cacheRevalidationMode: "background", currentFetchSoftTags: ["implicit-route-tag"], headersContext: headersContextFromRequest( new Request("https://example.com/second", { @@ -6659,11 +6661,13 @@ describe('"use cache" runtime', () => { } }); - it("logs failed background writes and retries stale entries without a request context", async () => { + 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) => { @@ -6709,9 +6713,15 @@ describe('"use cache" runtime', () => { revalidationCalls++; return { version: "fresh" }; }, "test:stale-write-retry"); + const requestContext = createRequestContext({ + cacheRevalidationMode: "background", + executionContext: null, + }); try { - await expect(cached()).resolves.toEqual({ version: "stale" }); + await expect(runWithRequestContext(requestContext, () => cached())).resolves.toEqual({ + version: "stale", + }); await firstWrite; await new Promise((resolve) => setImmediate(resolve)); @@ -6720,7 +6730,9 @@ describe('"use cache" runtime', () => { expect.objectContaining({ message: "cache backend unavailable" }), ); - await expect(cached()).resolves.toEqual({ version: "stale" }); + await expect(runWithRequestContext(requestContext, () => cached())).resolves.toEqual({ + version: "stale", + }); await secondWrite; await new Promise((resolve) => setImmediate(resolve)); } finally { @@ -6732,6 +6744,92 @@ describe('"use cache" runtime', () => { 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"); From 99bffbbe741fae673b7e68ddabcc71781667ff94 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:43:55 +1000 Subject: [PATCH 3/5] fix(cache): bypass shared cache in draft mode Draft requests could return stale published data and regenerate preview output into the persistent use-cache entry because the shared handler path did not consult the active draft state.\n\nExecute public and remote use-cache callbacks directly inside their normal cache scope when draft mode is enabled. Background refreshes no longer inherit headers or cookies, since persistent regeneration now runs only for non-draft requests. --- packages/vinext/src/shims/cache-runtime.ts | 9 +- .../src/shims/unified-request-context.ts | 19 +--- tests/shims.test.ts | 102 +++++++++++++++--- 3 files changed, 97 insertions(+), 33 deletions(-) diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index b44a713dd7..2b87ed12cb 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -54,7 +54,7 @@ import { 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"; @@ -477,6 +477,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(); diff --git a/packages/vinext/src/shims/unified-request-context.ts b/packages/vinext/src/shims/unified-request-context.ts index dbb93b0fa8..783d38811c 100644 --- a/packages/vinext/src/shims/unified-request-context.ts +++ b/packages/vinext/src/shims/unified-request-context.ts @@ -144,25 +144,15 @@ 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 supported read inputs from the triggering request, - * while every response-owned output container starts fresh. The refresh runs - * in foreground mode so nested stale dependencies are resolved before the - * refreshed entry is stored. + * Cache callbacks retain only cache-specific read inputs from the triggering + * request, while request APIs 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 headersContext = outer?.headersContext - ? { - ...outer.headersContext, - headers: new Headers(outer.headersContext.headers), - cookies: new Map(outer.headersContext.cookies), - mutableCookies: undefined, - readonlyCookies: undefined, - readonlyHeaders: undefined, - } - : null; const rootParams = outer?.rootParams ? Object.fromEntries( Object.entries(outer.rootParams).map(([name, value]) => [ @@ -174,7 +164,6 @@ export function createCacheRevalidationContext( return createRequestContext({ executionContext: outer ? outer.executionContext : _getInheritedExecutionContext(), - headersContext, pendingRevalidatedTags: new Set(outer?.pendingRevalidatedTags ?? []), currentFetchSoftTags: [...(outer?.currentFetchSoftTags ?? fallbackSoftTags)], currentFetchCacheMode: outer?.currentFetchCacheMode ?? null, diff --git a/tests/shims.test.ts b/tests/shims.test.ts index f55750edb3..95536e065e 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -6515,8 +6515,6 @@ describe('"use cache" runtime', () => { await import("../packages/vinext/src/shims/cache.js"); const { addCollectedRequestTags, getCurrentFetchSoftTags } = await import("../packages/vinext/src/shims/fetch-cache.js"); - const { draftMode, headersContextFromRequest } = - 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"); @@ -6559,12 +6557,10 @@ describe('"use cache" runtime', () => { const firstRequestContext = createRequestContext({ cacheRevalidationMode: "background", currentFetchSoftTags: ["implicit-route-tag"], - headersContext: headersContextFromRequest( - new Request("https://example.com/first", { - headers: { cookie: "__prerender_bypass=test-secret" }, - }), - { draftModeSecret: "test-secret" }, - ), + headersContext: { + headers: new Headers({ "x-request-only": "first" }), + cookies: new Map([["request-only", "first"]]), + }, isFetchDedupeActive: true, rootParams: { lang: "en" }, executionContext: { @@ -6576,12 +6572,10 @@ describe('"use cache" runtime', () => { const secondRequestContext = createRequestContext({ cacheRevalidationMode: "background", currentFetchSoftTags: ["implicit-route-tag"], - headersContext: headersContextFromRequest( - new Request("https://example.com/second", { - headers: { cookie: "__prerender_bypass=test-secret" }, - }), - { draftModeSecret: "test-secret" }, - ), + headersContext: { + headers: new Headers({ "x-request-only": "second" }), + cookies: new Map([["request-only", "second"]]), + }, isFetchDedupeActive: true, rootParams: { lang: "en" }, executionContext: { @@ -6593,7 +6587,7 @@ describe('"use cache" runtime', () => { let revalidationCalls = 0; let refreshSoftTags: string[] = []; let refreshRootParam: string | string[] | undefined; - let refreshDraftModeEnabled = false; + let refreshHeadersContext: unknown; let refreshFetchDedupeActive = false; let refreshFetchDedupeEntries: unknown; @@ -6601,7 +6595,7 @@ describe('"use cache" runtime', () => { revalidationCalls++; refreshSoftTags = getCurrentFetchSoftTags(); refreshRootParam = await getRootParam("lang"); - refreshDraftModeEnabled = (await draftMode()).isEnabled; + refreshHeadersContext = getRequestContext().headersContext; refreshFetchDedupeActive = getRequestContext().isFetchDedupeActive; refreshFetchDedupeEntries = getRequestContext().currentFetchDedupeEntries; cacheLife({ stale: 1, revalidate: 1, expire: 60 }); @@ -6639,7 +6633,7 @@ describe('"use cache" runtime', () => { expect(revalidationCalls).toBe(1); expect(refreshSoftTags).toEqual(["implicit-route-tag"]); expect(refreshRootParam).toBe("en"); - expect(refreshDraftModeEnabled).toBe(true); + expect(refreshHeadersContext).toBeNull(); expect(refreshFetchDedupeActive).toBe(true); expect(refreshFetchDedupeEntries).not.toBe(firstRequestContext.currentFetchDedupeEntries); expect(refreshFetchDedupeEntries).not.toBe(secondRequestContext.currentFetchDedupeEntries); @@ -6661,6 +6655,80 @@ describe('"use cache" runtime', () => { } }); + 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"); From d3a902c34db3aacff99b1241c5d7ebea46499207 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:57:13 +1000 Subject: [PATCH 4/5] fix(cache): preserve draft mode during stale refresh Background use-cache regeneration lost its headers context entirely, so cached callbacks that read draftMode().isEnabled failed after a stale hit even though that read is supported inside cache scopes. Give detached refreshes a disabled draft-mode provider with empty header and cookie stores. This keeps preview state isolated while allowing the callback to regenerate and persist the fresh entry. --- .../src/shims/unified-request-context.ts | 17 +++++-- tests/shims.test.ts | 44 +++++++++++++------ 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/packages/vinext/src/shims/unified-request-context.ts b/packages/vinext/src/shims/unified-request-context.ts index 783d38811c..eb88ae5768 100644 --- a/packages/vinext/src/shims/unified-request-context.ts +++ b/packages/vinext/src/shims/unified-request-context.ts @@ -144,10 +144,10 @@ 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 cache-specific read inputs from the triggering - * request, while request APIs 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. + * 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[] = [], @@ -164,6 +164,15 @@ export function createCacheRevalidationContext( 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, diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 95536e065e..08546f2e8e 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -6515,6 +6515,7 @@ describe('"use cache" runtime', () => { 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"); @@ -6560,6 +6561,7 @@ describe('"use cache" runtime', () => { headersContext: { headers: new Headers({ "x-request-only": "first" }), cookies: new Map([["request-only", "first"]]), + draftModeSecret: "test-secret", }, isFetchDedupeActive: true, rootParams: { lang: "en" }, @@ -6575,6 +6577,7 @@ describe('"use cache" runtime', () => { headersContext: { headers: new Headers({ "x-request-only": "second" }), cookies: new Map([["request-only", "second"]]), + draftModeSecret: "test-secret", }, isFetchDedupeActive: true, rootParams: { lang: "en" }, @@ -6587,24 +6590,34 @@ describe('"use cache" runtime', () => { let revalidationCalls = 0; let refreshSoftTags: string[] = []; let refreshRootParam: string | string[] | undefined; - let refreshHeadersContext: unknown; + 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++; - refreshSoftTags = getCurrentFetchSoftTags(); - refreshRootParam = await getRootParam("lang"); - refreshHeadersContext = getRequestContext().headersContext; - 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"); - markRevalidationStarted(); + 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"); @@ -6633,7 +6646,10 @@ describe('"use cache" runtime', () => { expect(revalidationCalls).toBe(1); expect(refreshSoftTags).toEqual(["implicit-route-tag"]); expect(refreshRootParam).toBe("en"); - expect(refreshHeadersContext).toBeNull(); + 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); From 53812f3c0d70fb47d6d204cb3adcd329ec827240 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:39:54 +1000 Subject: [PATCH 5/5] fix(cache): revalidate expired function cache entries Expired values returned by cache handlers were treated as ordinary hits because only stale entries received special handling. Memory-backed entries could therefore remain visible indefinitely without triggering regeneration. Choose an explicit read action from the entry state and request revalidation policy. Stale values remain eligible for background refresh during ordinary requests, while expired and unrecognized states regenerate in the foreground for both unstable_cache and "use cache". --- .../vinext/src/shims/cache-request-state.ts | 25 +++- packages/vinext/src/shims/cache-runtime.ts | 11 +- packages/vinext/src/shims/cache.ts | 21 +-- tests/app-page-element-builder.test.ts | 1 + tests/shims.test.ts | 126 ++++++++++++++++++ 5 files changed, 167 insertions(+), 17 deletions(-) diff --git a/packages/vinext/src/shims/cache-request-state.ts b/packages/vinext/src/shims/cache-request-state.ts index 9c991746e5..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; } +/** + * 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"; @@ -245,6 +250,22 @@ export function _peekUnstableCacheObservations(): UnstableCacheObservation[] { ); } -export function shouldServeStaleCacheEntry(): boolean { - return getCacheState().cacheRevalidationMode === "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 2b87ed12cb..b0ba4dafba 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -40,7 +40,8 @@ import { _hasPendingRevalidatedTag, _setRequestScopedCacheLife, _registerCacheContextAccessor, - shouldServeStaleCacheEntry, + decideCacheRead, + getCacheRevalidationMode, type CacheLifeConfig, } from "./cache-request-state.js"; import { VINEXT_RSC_MARKER_HEADER } from "../server/headers.js"; @@ -576,11 +577,11 @@ export function registerCachedFunction( console.error("[vinext] use cache: handler.get failed; treating as a cache miss:", error); } } - const shouldUseCachedEntry = existing?.cacheState !== "stale" || shouldServeStaleCacheEntry(); + const cacheReadAction = decideCacheRead(existing?.cacheState, getCacheRevalidationMode()); if ( existing?.value && existing.value.kind === "FETCH" && - shouldUseCachedEntry && + cacheReadAction !== "revalidate" && !_hasPendingRevalidatedTag([...(existing.value.tags ?? []), ...softTags]) ) { try { @@ -599,7 +600,7 @@ export function registerCachedFunction( // the enclosing page's cache metadata. propagateCacheTagsToRequest(existing.value.tags); recordRequestScopedCacheControl(existing.cacheControl); - if (existing.cacheState === "stale") { + if (cacheReadAction === "serve-and-revalidate") { scheduleBackgroundCacheRevalidation( cacheKey, () => @@ -620,7 +621,7 @@ export function registerCachedFunction( } } - // Cache miss (or stale) — execute with context + // Cache miss or unusable entry — execute with context return refreshSharedCacheEntry(fn, args, cacheVariant, cacheKey, handler, rsc); }, cacheVariant); diff --git a/packages/vinext/src/shims/cache.ts b/packages/vinext/src/shims/cache.ts index f81fc071b7..96c541922e 100644 --- a/packages/vinext/src/shims/cache.ts +++ b/packages/vinext/src/shims/cache.ts @@ -42,10 +42,11 @@ import { _queuePendingRevalidation, _setRequestScopedCacheLife, cacheLifeProfiles, + decideCacheRead, + getCacheRevalidationMode, getRegisteredCacheContext, markActionRevalidation, recordUnstableCacheObservation, - shouldServeStaleCacheEntry, type CacheLifeConfig, } from "./cache-request-state.js"; @@ -585,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 @@ -596,10 +597,11 @@ 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 (shouldServeStaleCacheEntry()) { + 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), @@ -610,13 +612,12 @@ export function unstable_cache Promise>( ); }, ); - 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/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 08546f2e8e..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"); @@ -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"); @@ -6671,6 +6740,63 @@ describe('"use cache" runtime', () => { } }); + 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");