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 01/12] 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 02/12] 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 03/12] 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 04/12] 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 05/12] 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"); From dd449b0c57dbd9646898d20a21b3469dbab42f84 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:51:38 +1000 Subject: [PATCH 06/12] ci: retrigger preview release From 79403894ce3d9caebad03ca48b7abf5b53e429f2 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:58:07 +1000 Subject: [PATCH 07/12] ci: retry flaky checks From 02d40e9c99e19ec0a39e8a44cd54b8d30f39f7bd Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:21:03 +1000 Subject: [PATCH 08/12] fix(cache): isolate detached unstable_cache refreshes from the request A stale unstable_cache entry served during an App Router request scheduled its background refresh inside the triggering request's unified context. The detached callback could therefore mutate that request's tag, observation, and fetch containers after the response was already being finalized, and nested stale function-cache reads still saw the request's "background" policy, so a refreshed outer entry could be assembled from stale nested data. Run the refresh through the same isolated synthetic work unit that "use cache" refreshes use, whose foreground mode forces nested dependencies fresh before the regenerated entry is stored. Also rename cacheRevalidationMode to functionCacheRevalidationMode: patched fetch keeps its separate refreshStaleFetchesInForeground flag whose default is fail-open, so the field only governs the function caches and the broader name misrepresented that boundary. Converging both onto one request-level freshness policy is deliberate follow-up work, documented on the type. --- .../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 | 27 +++-- packages/vinext/src/shims/cache-runtime.ts | 7 +- packages/vinext/src/shims/cache.ts | 22 +++- .../src/shims/unified-request-context.ts | 4 +- tests/shims.test.ts | 112 ++++++++++++++++-- 8 files changed, 149 insertions(+), 29 deletions(-) diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index 01a2d8621c..cb3b7514f9 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(), - cacheRevalidationMode: "foreground", + functionCacheRevalidationMode: "foreground", }); const revalidation = runWithRequestContext(requestContext, async () => { diff --git a/packages/vinext/src/server/app-route-handler-dispatch.ts b/packages/vinext/src/server/app-route-handler-dispatch.ts index 913142e2d4..94541623d9 100644 --- a/packages/vinext/src/server/app-route-handler-dispatch.ts +++ b/packages/vinext/src/server/app-route-handler-dispatch.ts @@ -132,7 +132,7 @@ async function runInRouteHandlerRevalidationContext( const requestContext = createRequestContext({ headersContext, executionContext: getRequestExecutionContext(), - cacheRevalidationMode: "foreground", + functionCacheRevalidationMode: "foreground", }); const revalidation = runWithRequestContext(requestContext, async () => { diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 5d0cf8abf4..1429b0b786 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, - cacheRevalidationMode: "background", + functionCacheRevalidationMode: "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 9e70666332..eaf96a5f20 100644 --- a/packages/vinext/src/shims/cache-request-state.ts +++ b/packages/vinext/src/shims/cache-request-state.ts @@ -42,10 +42,17 @@ export function getRegisteredCacheContext(): CacheContextLike | null { } /** - * Controls stale function/data-cache reads. Patched fetch response caching has - * a separate policy because it owns response streams and refetch timeouts. + * Controls stale reads for the function caches ("use cache" and + * unstable_cache) only. Patched fetch response caching deliberately keeps its + * own `refreshStaleFetchesInForeground` flag (fetch-cache.ts) for now: its + * default is fail-open (serve stale, background refetch) across every request + * path, while this mode's default is fail-closed, so folding fetch into this + * field would need an audit of Pages Router and fallback-scope requests first. + * Converging both onto one request-level freshness policy is tracked follow-up + * work; until then this field is intentionally named narrowly so it does not + * read as the authoritative policy for all persistent caches. */ -export type CacheRevalidationMode = "foreground" | "background"; +export type FunctionCacheRevalidationMode = "foreground" | "background"; export type CacheReadAction = "serve" | "serve-and-revalidate" | "revalidate"; export type ActionRevalidationKind = 0 | 1 | 2; export type UnstableCacheObservation = Readonly<{ @@ -62,7 +69,7 @@ export type CacheState = { pendingRevalidations: Set>; requestScopedCacheLife: CacheLifeConfig | null; unstableCacheObservations: Map; - cacheRevalidationMode: CacheRevalidationMode; + functionCacheRevalidationMode: FunctionCacheRevalidationMode; }; const FALLBACK_KEY = Symbol.for("vinext.cache.fallback"); @@ -79,7 +86,7 @@ const fallbackState = (globalState[FALLBACK_KEY] ??= { pendingRevalidations: new Set>(), requestScopedCacheLife: null, unstableCacheObservations: new Map(), - cacheRevalidationMode: "foreground", + functionCacheRevalidationMode: "foreground", } satisfies CacheState) as CacheState; function getCacheState(): CacheState { @@ -97,7 +104,7 @@ export function _runWithCacheState(fn: () => T | Promise): T | Promise context.actionRevalidationKind = ACTION_DID_NOT_REVALIDATE; context.requestScopedCacheLife = null; context.unstableCacheObservations = new Map(); - context.cacheRevalidationMode = "foreground"; + context.functionCacheRevalidationMode = "foreground"; }, fn); } const state: CacheState = { @@ -106,7 +113,7 @@ export function _runWithCacheState(fn: () => T | Promise): T | Promise pendingRevalidations: new Set>(), requestScopedCacheLife: null, unstableCacheObservations: new Map(), - cacheRevalidationMode: "foreground", + functionCacheRevalidationMode: "foreground", }; return cacheAls.run(state, fn); } @@ -250,8 +257,8 @@ export function _peekUnstableCacheObservations(): UnstableCacheObservation[] { ); } -export function getCacheRevalidationMode(): CacheRevalidationMode { - return getCacheState().cacheRevalidationMode; +export function getFunctionCacheRevalidationMode(): FunctionCacheRevalidationMode { + return getCacheState().functionCacheRevalidationMode; } /** @@ -261,7 +268,7 @@ export function getCacheRevalidationMode(): CacheRevalidationMode { */ export function decideCacheRead( cacheState: string | undefined, - mode: CacheRevalidationMode, + mode: FunctionCacheRevalidationMode, ): CacheReadAction { if (cacheState === undefined) return "serve"; if (cacheState === "stale") { diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index b0ba4dafba..5d730095c0 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -41,7 +41,7 @@ import { _setRequestScopedCacheLife, _registerCacheContextAccessor, decideCacheRead, - getCacheRevalidationMode, + getFunctionCacheRevalidationMode, type CacheLifeConfig, } from "./cache-request-state.js"; import { VINEXT_RSC_MARKER_HEADER } from "../server/headers.js"; @@ -577,7 +577,10 @@ export function registerCachedFunction( console.error("[vinext] use cache: handler.get failed; treating as a cache miss:", error); } } - const cacheReadAction = decideCacheRead(existing?.cacheState, getCacheRevalidationMode()); + const cacheReadAction = decideCacheRead( + existing?.cacheState, + getFunctionCacheRevalidationMode(), + ); if ( existing?.value && existing.value.kind === "FETCH" && diff --git a/packages/vinext/src/shims/cache.ts b/packages/vinext/src/shims/cache.ts index 96c541922e..8ed94427fb 100644 --- a/packages/vinext/src/shims/cache.ts +++ b/packages/vinext/src/shims/cache.ts @@ -33,6 +33,10 @@ import { encodeCacheTag, encodeCacheTags } from "../utils/encode-cache-tag.js"; import { getCdnCacheAdapter } from "./cdn-cache.js"; import { getDataCacheHandler, type CachedFetchValue } from "./cache-handler.js"; import { getRequestExecutionContext } from "./request-context.js"; +import { + createCacheRevalidationContext, + runWithRequestContext, +} from "./unified-request-context.js"; import { addCollectedRequestTags, getCurrentFetchSoftTags } from "./fetch-cache.js"; import { ACTION_DID_REVALIDATE_DYNAMIC_ONLY, @@ -43,7 +47,7 @@ import { _setRequestScopedCacheLife, cacheLifeProfiles, decideCacheRead, - getCacheRevalidationMode, + getFunctionCacheRevalidationMode, getRegisteredCacheContext, markActionRevalidation, recordUnstableCacheObservation, @@ -597,14 +601,26 @@ export function unstable_cache Promise>( softTags, }); if (existing?.value && existing.value.kind === "FETCH") { - const cacheReadAction = decideCacheRead(existing.cacheState, getCacheRevalidationMode()); + const cacheReadAction = decideCacheRead( + existing.cacheState, + getFunctionCacheRevalidationMode(), + ); if (cacheReadAction !== "revalidate") { const cached = tryDeserializeUnstableCacheResult(existing.value.data.body); if (cached.ok) { if (cacheReadAction === "serve-and-revalidate") { + // The detached refresh is a synthetic cache work unit, not a + // continuation of the triggering request: it runs in an isolated + // context so cached fetches and observations inside the callback + // cannot mutate this request's output containers, and its + // foreground mode forces nested stale dependencies to refresh + // before the regenerated entry is stored. scheduleBackgroundCacheRevalidation( cacheKey, - () => refreshUnstableCacheResult(fn, args, cacheKey, tags, revalidateSeconds), + () => + runWithRequestContext(createCacheRevalidationContext(softTags), () => + refreshUnstableCacheResult(fn, args, cacheKey, tags, revalidateSeconds), + ), (error) => { console.error( `[vinext] unstable_cache background revalidation failed for ${cacheKey}:`, diff --git a/packages/vinext/src/shims/unified-request-context.ts b/packages/vinext/src/shims/unified-request-context.ts index eb88ae5768..9a7093be89 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(), - cacheRevalidationMode: "foreground", + functionCacheRevalidationMode: "foreground", _privateCache: null, cacheableFetchUrls: new Set(), currentRequestTags: [], @@ -180,7 +180,7 @@ export function createCacheRevalidationContext( isFetchDedupeActive: outer?.isFetchDedupeActive ?? false, currentFetchDedupeEntries: new Map(), rootParams, - cacheRevalidationMode: "foreground", + functionCacheRevalidationMode: "foreground", }); } diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 3b0dfc0636..486703025b 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -6201,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({ - cacheRevalidationMode: "background", + functionCacheRevalidationMode: "background", executionContext: { waitUntil(promise) { waitUntilPromises.push(promise); @@ -6228,6 +6228,100 @@ describe("next/cache shim", () => { } }); + it("runs unstable_cache background refreshes as isolated work units with foreground nested reads", async () => { + const { unstable_cache, setCacheHandler, MemoryCacheHandler } = + await import("../packages/vinext/src/shims/cache.js"); + const { createRequestContext, runWithRequestContext } = + await import("../packages/vinext/src/shims/unified-request-context.js"); + + const setBodies = new Map(); + const handler: CacheHandler = { + async get(key: string): Promise { + return { + lastModified: Date.now() - 2_000, + cacheState: "stale", + value: { + kind: "FETCH", + data: { + headers: {}, + body: JSON.stringify({ + v: key.includes("swr-nested-inner") ? "inner-stale" : "outer-stale", + }), + url: key, + }, + tags: [], + revalidate: 1, + }, + }; + }, + async set(key: string, data: IncrementalCacheValue | null) { + if (data?.kind === "FETCH") { + setBodies.set(key, data.data.body); + } + }, + async revalidateTag(_tags: string | string[]) {}, + }; + setCacheHandler(handler); + + let innerCalls = 0; + const inner = unstable_cache( + async () => { + innerCalls++; + return "inner-fresh"; + }, + ["swr-nested-inner"], + { tags: ["nested-inner-tag"], revalidate: 1 }, + ); + let outerCalls = 0; + const outer = unstable_cache( + async () => { + outerCalls++; + return { inner: await inner() }; + }, + ["swr-nested-outer"], + { revalidate: 1 }, + ); + + const waitUntilPromises: Promise[] = []; + const requestContext = createRequestContext({ + functionCacheRevalidationMode: "background", + executionContext: { + waitUntil(promise) { + waitUntilPromises.push(promise); + }, + }, + }); + + try { + // The stale outer entry is served without waiting for the refresh. + await expect(runWithRequestContext(requestContext, () => outer())).resolves.toBe( + "outer-stale", + ); + expect(waitUntilPromises).toHaveLength(1); + await Promise.all(waitUntilPromises); + + // The detached refresh is a synthetic cache work unit: the nested stale + // inner entry is regenerated in the foreground, so the stored outer + // value is assembled from fresh nested data instead of the stale inner + // entry a "background"-mode read would have served. + expect(outerCalls).toBe(1); + expect(innerCalls).toBe(1); + expect(setBodies.get("unstable_cache:swr-nested-inner:[]")).toBe( + JSON.stringify({ v: "inner-fresh" }), + ); + expect(setBodies.get("unstable_cache:swr-nested-outer:[]")).toBe( + JSON.stringify({ v: { inner: "inner-fresh" } }), + ); + + // Tags and observations recorded inside the refresh belong to its + // isolated context, not to the request that happened to trigger it. + expect(requestContext.currentRequestTags).toEqual([]); + expect(requestContext.unstableCacheObservations.size).toBe(1); + } finally { + setCacheHandler(new MemoryCacheHandler()); + } + }); + it("unstable_cache revalidates expired entries in the foreground", async () => { const { unstable_cache, setCacheHandler, MemoryCacheHandler } = await import("../packages/vinext/src/shims/cache.js"); @@ -6258,7 +6352,7 @@ describe("next/cache shim", () => { const waitUntilCalls: Promise[] = []; const requestContext = createRequestContext({ - cacheRevalidationMode: "background", + functionCacheRevalidationMode: "background", executionContext: { waitUntil(promise) { waitUntilCalls.push(promise); @@ -6330,7 +6424,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({ - cacheRevalidationMode: "foreground", + functionCacheRevalidationMode: "foreground", }); try { @@ -6625,7 +6719,7 @@ describe('"use cache" runtime', () => { const secondWaitUntilCalls: Promise[] = []; const staleCalls: Promise<{ version: string }>[] = []; const firstRequestContext = createRequestContext({ - cacheRevalidationMode: "background", + functionCacheRevalidationMode: "background", currentFetchSoftTags: ["implicit-route-tag"], headersContext: { headers: new Headers({ "x-request-only": "first" }), @@ -6641,7 +6735,7 @@ describe('"use cache" runtime', () => { }, }); const secondRequestContext = createRequestContext({ - cacheRevalidationMode: "background", + functionCacheRevalidationMode: "background", currentFetchSoftTags: ["implicit-route-tag"], headersContext: { headers: new Headers({ "x-request-only": "second" }), @@ -6772,7 +6866,7 @@ describe('"use cache" runtime', () => { const waitUntilCalls: Promise[] = []; const requestContext = createRequestContext({ - cacheRevalidationMode: "background", + functionCacheRevalidationMode: "background", executionContext: { waitUntil(promise) { waitUntilCalls.push(promise); @@ -6831,7 +6925,7 @@ describe('"use cache" runtime', () => { const waitUntilCalls: Promise[] = []; const requestContext = createRequestContext({ - cacheRevalidationMode: "background", + functionCacheRevalidationMode: "background", headersContext: headersContextFromRequest( new Request("https://example.com/preview", { headers: { cookie: "__prerender_bypass=test-secret" }, @@ -6924,7 +7018,7 @@ describe('"use cache" runtime', () => { return { version: "fresh" }; }, "test:stale-write-retry"); const requestContext = createRequestContext({ - cacheRevalidationMode: "background", + functionCacheRevalidationMode: "background", executionContext: null, }); @@ -6995,7 +7089,7 @@ describe('"use cache" runtime', () => { }); const waitUntilCalls: Promise[] = []; const requestContext = createRequestContext({ - cacheRevalidationMode: "foreground", + functionCacheRevalidationMode: "foreground", executionContext: { waitUntil(promise) { waitUntilCalls.push(promise); From 2485606728af2746b85c512ba42f37aaab61bfa2 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:28:37 +1000 Subject: [PATCH 09/12] ci: retry flaky checks From 277d7885a17cc3d39504fba1bd16203b540e2ce6 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:34:26 +1000 Subject: [PATCH 10/12] docs(cache): link the fetch freshness-policy convergence issue The functionCacheRevalidationMode comment promised the fetch-policy convergence as tracked follow-up without saying where. Point it at issue #2685, which records the per-path audit and the Pages Router semantics decision that keeps the convergence out of this PR. --- packages/vinext/src/shims/cache-request-state.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/shims/cache-request-state.ts b/packages/vinext/src/shims/cache-request-state.ts index eaf96a5f20..6b49ddbae0 100644 --- a/packages/vinext/src/shims/cache-request-state.ts +++ b/packages/vinext/src/shims/cache-request-state.ts @@ -48,9 +48,10 @@ export function getRegisteredCacheContext(): CacheContextLike | null { * default is fail-open (serve stale, background refetch) across every request * path, while this mode's default is fail-closed, so folding fetch into this * field would need an audit of Pages Router and fallback-scope requests first. - * Converging both onto one request-level freshness policy is tracked follow-up - * work; until then this field is intentionally named narrowly so it does not - * read as the authoritative policy for all persistent caches. + * Converging both onto one request-level freshness policy is tracked in + * https://github.com/cloudflare/vinext/issues/2685; until then this field is + * intentionally named narrowly so it does not read as the authoritative + * policy for all persistent caches. */ export type FunctionCacheRevalidationMode = "foreground" | "background"; export type CacheReadAction = "serve" | "serve-and-revalidate" | "revalidate"; From d6caf53cd5e7a303dc87ccc2d576a64030e544fe Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:50:49 +1000 Subject: [PATCH 11/12] fix(cache): propagate stale-hit cache life and force prerender foreground Address two review findings on the "use cache" stale-while-revalidate path: - Propagate a served data-cache HIT entry's cache-control into the enclosing cache scope's lifeConfigs, not only the request store. On a hit the child function never runs, so this is the only place its (shorter) lifetime can constrain an outer "use cache" entry. Without it, an outer miss embedding a stale child stored the outer entry under the default 900s window while the child refreshed separately, serving stale nested data past the child's own expire. Mirrors the tag propagation already done on the hit path. - Seed foreground function-cache revalidation for prerender requests in createAppRscHandler. VINEXT_PRERENDER=1 requests bake a static artifact, so a stale persistent entry must be refreshed in the foreground rather than served stale and refreshed in the background. Matches the static-generation dispatch paths. Both are covered by regressions that fail against the prior behavior. --- packages/vinext/src/server/app-rsc-handler.ts | 9 ++- packages/vinext/src/shims/cache-runtime.ts | 36 +++++++-- tests/app-rsc-handler.test.ts | 40 ++++++++++ tests/shims.test.ts | 74 +++++++++++++++++++ 4 files changed, 151 insertions(+), 8 deletions(-) diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 1429b0b786..5d3f4ee841 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -1428,7 +1428,14 @@ export function createAppRscHandler( const requestContext = createRequestContext({ headersContext, executionContext, - functionCacheRevalidationMode: "background", + // Ordinary runtime requests serve stale `use cache` data and refresh in + // the background. A build/prerender request (VINEXT_PRERENDER=1) instead + // bakes the response into a static artifact, so it must await the refresh + // in the foreground — otherwise a stale persistent entry would be written + // into the generated artifact. Matches the static-generation dispatch + // paths, which also force foreground during prerendering. + functionCacheRevalidationMode: + process.env.VINEXT_PRERENDER === "1" ? "foreground" : "background", }); const responsePromise = runWithRequestContext(requestContext, () => diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 5d730095c0..73786ea54b 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -598,11 +598,11 @@ export function registerCachedFunction( // JSON-serialized entry (legacy or no RSC available) 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. + // Surface tags and cache-life only after deserialization succeeds. A + // corrupted entry falls through to foreground execution and must not + // affect the enclosing page's cache metadata. propagateCacheTagsToRequest(existing.value.tags); - recordRequestScopedCacheControl(existing.cacheControl); + propagateHitCacheControl(existing.cacheControl); if (cacheReadAction === "serve-and-revalidate") { scheduleBackgroundCacheRevalidation( cacheKey, @@ -750,14 +750,36 @@ function throwPrivateUseCacheInsidePublicUseCacheError(): never { throw error; } -function recordRequestScopedCacheControl(cacheControl: CacheControlMetadata | undefined): void { +/** + * Surface a data-cache HIT entry's stored cache-control into the surrounding + * scopes, mirroring the MISS path's dual propagation. + * + * On a MISS the executed child's `effectiveLife` reaches both the parent cache + * context (`runCachedFunctionWithContext` pushes it into `parentCtx.lifeConfigs` + * for the outer's minimum-wins `resolveCacheLife`) and the request store + * (`recordRequestScopedCacheLife`). On a HIT the child function never runs, so + * this is the only place the served entry's lifetime can constrain an enclosing + * `"use cache"` scope. Without the parent push, an outer miss that embeds this + * HIT would resolve its own (typically longer, e.g. the default 900s) lifetime + * and store the outer entry with a `revalidate`/`expire` wider than the child it + * contains — serving stale nested data past the child's own window even though a + * background refresh for the child was scheduled separately. Minimum-wins on + * both the parent `lifeConfigs` and the request store keeps the double record + * safe. + */ +function propagateHitCacheControl(cacheControl: CacheControlMetadata | undefined): void { if (cacheControl === undefined) return; - _setRequestScopedCacheLife({ + const life: CacheLifeConfig = { // `false` is an indefinite lifetime and therefore does not constrain an // enclosing cache scope's finite revalidation window. revalidate: cacheControl.revalidate === false ? undefined : cacheControl.revalidate, expire: cacheControl.expire, - }); + }; + const parentCtx = cacheContextStorage.getStore(); + if (parentCtx) { + parentCtx.lifeConfigs.push(life); + } + _setRequestScopedCacheLife(life); } function recordRequestScopedCacheLife(cacheLife: CacheLifeConfig): void { diff --git a/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index 2de3d93201..1fd666d747 100644 --- a/tests/app-rsc-handler.test.ts +++ b/tests/app-rsc-handler.test.ts @@ -1051,6 +1051,46 @@ describe("createAppRscHandler", () => { } }); + it("seeds foreground function-cache revalidation only while prerendering", async () => { + // Ordinary runtime requests serve stale "use cache"/unstable_cache data and + // refresh in the background. A build/prerender request (VINEXT_PRERENDER=1) + // bakes the response into a static artifact, so it must await the refresh in + // the foreground — otherwise a stale persistent entry would be written into + // the generated artifact. Regression for the mode seeded at the handler's + // createRequestContext call. + const { getFunctionCacheRevalidationMode } = + await import("../packages/vinext/src/shims/cache-request-state.js"); + + async function observeMode(): Promise { + let observed = ""; + const handler = createHandler({ + configHeaders: [], + dispatchMatchedPage: async () => { + observed = getFunctionCacheRevalidationMode(); + return new Response("page", { status: 200 }); + }, + }); + const response = await handler(new Request("https://example.test/docs/about"), null); + expect(response.status).toBe(200); + return observed; + } + + const previousPrerender = process.env.VINEXT_PRERENDER; + try { + delete process.env.VINEXT_PRERENDER; + expect(await observeMode()).toBe("background"); + + process.env.VINEXT_PRERENDER = "1"; + expect(await observeMode()).toBe("foreground"); + } finally { + if (previousPrerender === undefined) { + delete process.env.VINEXT_PRERENDER; + } else { + process.env.VINEXT_PRERENDER = previousPrerender; + } + } + }); + it("ignores encoded prerender route params from a different rewritten route pattern", async () => { const previousPrerender = process.env.VINEXT_PRERENDER; process.env.VINEXT_PRERENDER = "1"; diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 486703025b..0cf4e48ee4 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -6834,6 +6834,80 @@ describe('"use cache" runtime', () => { } }); + it("propagates a stale nested-hit entry's cache life into the enclosing cache entry", async () => { + // Regression: when a stale child "use cache" hit is served in background + // mode inside an outer "use cache" miss, the outer entry must inherit the + // child's shorter revalidate/expire. The child function never runs on a + // hit, so its served cache-control is the only thing that can constrain the + // parent scope. Without propagating it into the parent's lifeConfigs, the + // outer entry is stored under the default 900s lifetime and would serve the + // embedded stale child data long past the child's own window — even though + // the child's background refresh was scheduled separately. + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { setCacheHandler, MemoryCacheHandler } = + await import("../packages/vinext/src/shims/cache.js"); + const { createRequestContext, runWithRequestContext } = + await import("../packages/vinext/src/shims/unified-request-context.js"); + + const childKey = "use-cache:test:swr-nested-child"; + const outerKey = "use-cache:test:swr-nested-outer"; + + const staleChild = { + lastModified: Date.now() - 2_000, + cacheState: "stale", + cacheControl: { revalidate: 5, expire: 50 }, + value: { + kind: "FETCH", + data: { headers: {}, body: JSON.stringify({ child: "stale" }), url: childKey }, + tags: [], + revalidate: 5, + }, + } satisfies CacheHandlerValue; + + const setEntry = vi.fn(async () => {}); + setCacheHandler({ + // Child key is a stale hit; outer key is a miss so the outer executes and + // embeds the served child value. + async get(key) { + return key === childKey ? staleChild : null; + }, + set: setEntry, + async revalidateTag() {}, + }); + + const waitUntilCalls: Promise[] = []; + const requestContext = createRequestContext({ + functionCacheRevalidationMode: "background", + executionContext: { + waitUntil(promise) { + waitUntilCalls.push(promise); + }, + }, + }); + + const child = registerCachedFunction(async () => ({ child: "fresh" }), "test:swr-nested-child"); + // The outer sets no cacheLife of its own, so the nested child is its only + // lifetime source; the default would otherwise resolve to 900s. + const outer = registerCachedFunction(async () => { + const c = await child(); + return { outer: true, c }; + }, "test:swr-nested-outer"); + + try { + await runWithRequestContext(requestContext, () => outer()); + const outerSet = setEntry.mock.calls.find(([key]) => key === outerKey); + expect(outerSet).toBeDefined(); + expect((outerSet![2] as { cacheControl?: unknown }).cacheControl).toEqual({ + revalidate: 5, + expire: 50, + }); + } finally { + await Promise.allSettled(waitUntilCalls); + setCacheHandler(new MemoryCacheHandler()); + } + }); + it('"use cache" revalidates expired shared entries in the foreground', async () => { const { registerCachedFunction } = await import("../packages/vinext/src/shims/cache-runtime.js"); From 07d00ed0e9b9768944f5f07c66f431cba995e460 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:13:24 +1000 Subject: [PATCH 12/12] fix(cache): derive stale-hit cache life from the entry revalidate A custom CacheHandler or legacy entry can return a stale FETCH value without the optional cacheControl metadata. The stale-hit propagation into the enclosing cache scope was a no-op in that case, so an outer "use cache" miss embedding the served value resolved its own lifetime (default 900s) and stored the outer entry past the child's intended window. The required CachedFetchValue.revalidate still bounds the entry's lifetime, so fall back to it when cacheControl is absent. The sole call site now always supplies a value, so propagateHitCacheControl no longer accepts undefined. The added regression fails against the previous behaviour: the outer entry stored the default lifetime instead of the child's 5s revalidate. --- packages/vinext/src/shims/cache-runtime.ts | 10 +++- tests/shims.test.ts | 65 ++++++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 73786ea54b..c8d99e7be8 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -602,7 +602,12 @@ export function registerCachedFunction( // corrupted entry falls through to foreground execution and must not // affect the enclosing page's cache metadata. propagateCacheTagsToRequest(existing.value.tags); - propagateHitCacheControl(existing.cacheControl); + // Custom or legacy handlers may omit the optional `cacheControl` + // metadata; the required FETCH-value `revalidate` still bounds the + // served entry's lifetime, so an enclosing scope must inherit it. + propagateHitCacheControl( + existing.cacheControl ?? { revalidate: existing.value.revalidate }, + ); if (cacheReadAction === "serve-and-revalidate") { scheduleBackgroundCacheRevalidation( cacheKey, @@ -767,8 +772,7 @@ function throwPrivateUseCacheInsidePublicUseCacheError(): never { * both the parent `lifeConfigs` and the request store keeps the double record * safe. */ -function propagateHitCacheControl(cacheControl: CacheControlMetadata | undefined): void { - if (cacheControl === undefined) return; +function propagateHitCacheControl(cacheControl: CacheControlMetadata): void { const life: CacheLifeConfig = { // `false` is an indefinite lifetime and therefore does not constrain an // enclosing cache scope's finite revalidation window. diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 36fabe5354..bdcddd0624 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -7246,6 +7246,71 @@ describe('"use cache" runtime', () => { } }); + it("derives a stale nested hit's cache life from the entry revalidate when cacheControl is absent", async () => { + // Regression: a custom CacheHandler or legacy entry can return a stale + // FETCH value without the optional `cacheControl` metadata. The required + // `value.revalidate` still bounds the entry's lifetime, so an outer miss + // embedding the served hit must inherit it instead of resolving the + // default 900s window around stale child data. + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { setCacheHandler, MemoryCacheHandler } = + await import("../packages/vinext/src/shims/cache.js"); + const { createRequestContext, runWithRequestContext } = + await import("../packages/vinext/src/shims/unified-request-context.js"); + + const childKey = "use-cache:test:swr-bare-child"; + const outerKey = "use-cache:test:swr-bare-outer"; + + const staleChild = { + lastModified: Date.now() - 2_000, + cacheState: "stale", + value: { + kind: "FETCH", + data: { headers: {}, body: JSON.stringify({ child: "stale" }), url: childKey }, + tags: [], + revalidate: 5, + }, + } satisfies CacheHandlerValue; + + const setEntry = vi.fn(async () => {}); + setCacheHandler({ + async get(key) { + return key === childKey ? staleChild : null; + }, + set: setEntry, + async revalidateTag() {}, + }); + + const waitUntilCalls: Promise[] = []; + const requestContext = createRequestContext({ + functionCacheRevalidationMode: "background", + executionContext: { + waitUntil(promise) { + waitUntilCalls.push(promise); + }, + }, + }); + + const child = registerCachedFunction(async () => ({ child: "fresh" }), "test:swr-bare-child"); + const outer = registerCachedFunction(async () => { + const c = await child(); + return { outer: true, c }; + }, "test:swr-bare-outer"); + + try { + await runWithRequestContext(requestContext, () => outer()); + const outerSet = setEntry.mock.calls.find(([key]) => key === outerKey); + expect(outerSet).toBeDefined(); + expect((outerSet![2] as { cacheControl?: unknown }).cacheControl).toEqual({ + revalidate: 5, + }); + } finally { + await Promise.allSettled(waitUntilCalls); + setCacheHandler(new MemoryCacheHandler()); + } + }); + it('"use cache" revalidates expired shared entries in the foreground', async () => { const { registerCachedFunction } = await import("../packages/vinext/src/shims/cache-runtime.js");