Repository: vercel/next.js
Pull request: Fix inconsistent cache life/tags propagation for cache handler hits

When a `"use cache"` entry is newly generated during a prerender (`prerender` or `prerender-runtime`), `collectResult` defers propagation of cache life and tags to the outer context. This is because the entry might later be omitted from the final prerender due to short expire or stale times, and omitted entries should not affect the prerender's cache life.

However, when a cache handler returns a hit for an existing entry, `propagateCacheEntryMetadata` was called unconditionally, without the same deferral logic. This meant that short-lived cache entries retrieved from the cache handler could propagate their cache life to the prerender store, even though they would later be omitted from the final render.

This inconsistency is currently not observable because runtime prefetches use a prospective and final two-store architecture (see `prospectiveRuntimeServerPrerender` and `finalRuntimeServerPrerender` in `app-render.tsx`). The cache handler hit propagation corrupts the prospective store, but the response is produced from the final store, which reads from the resume data cache with correct stale and expire checks. Static prerenders have a similar two-phase architecture that masks the issue. Because of this, there is no test case that can observe the incorrect behavior, but the fix avoids confusion and prevents the inconsistency from becoming a real bug if the architecture changes.

This change extracts a `maybePropagateCacheEntryMetadata` function that encapsulates the conditional propagation logic and is now called from both the generation path (inside `collectResult`) and the cache handler hit path. The resume data cache read path continues to call `propagateCacheEntryMetadata` unconditionally, since it runs in the final render phase after short-lived entries have already been filtered out.

Changed files:
filename: packages/next/src/server/use-cache/use-cache-wrapper.ts
patch: @@ -552,6 +552,63 @@ function propagateCacheEntryMetadata(
   }
 }
 
+/**
+ * Conditionally propagates cache life, tags, and root param names to the outer
+ * context. During prerenders (`prerender` / `prerender-runtime`) and dev
+ * cache-filling requests, propagation is deferred because the entry might be
+ * omitted from the final prerender due to short expire/stale times. If omitted,
+ * it should not affect the prerender. The final decision happens when the entry
+ * is read from the resume data cache in the final render phase — at that point
+ * `propagateCacheEntryMetadata` is called unconditionally (after the omission
+ * checks have already filtered out short-lived entries).
+ *
+ * Note: Root param names are only propagated when the outer context is a
+ * `cache` store (i.e. an enclosing `"use cache"` function), which is never
+ * deferred. For prerender contexts, root param names are tracked separately
+ * via `addKnownRootParamNames` in the resume data cache read path.
+ */
+function maybePropagateCacheEntryMetadata(
+  cacheContext: CacheContext,
+  entry: CacheEntry,
+  readRootParamNames: ReadonlySet<string> | undefined
+): void {
+  const outerWorkUnitStore = cacheContext.outerWorkUnitStore
+
+  switch (outerWorkUnitStore.type) {
+    case 'prerender':
+    case 'prerender-runtime': {
+      // Don't propagate yet — the entry might be omitted from the final
+      // prerender due to short expire/stale times. Propagation will happen when
+      // the entry is read from the resume data cache.
+      break
+    }
+    case 'request': {
+      if (
+        process.env.NODE_ENV === 'development' &&
+        outerWorkUnitStore.cacheSignal
+      ) {
+        // If we're filling caches for a dev request, apply the same logic as
+        // prerenders do above.
+        break
+      }
+      // fallthrough
+    }
+    case 'private-cache':
+    case 'cache':
+    case 'unstable-cache':
+    case 'prerender-legacy':
+    case 'prerender-ppr': {
+      propagateCacheEntryMetadata(cacheContext, entry, readRootParamNames)
+      break
+    }
+    case 'generate-static-params':
+      break
+    default: {
+      outerWorkUnitStore satisfies never
+    }
+  }
+}
+
 export interface CollectedCacheResult {
   entry: CacheEntry
   /**
@@ -654,56 +711,15 @@ async function collectResult(
   }
 
   if (!cacheContext.skipPropagation) {
-    const outerWorkUnitStore = cacheContext.outerWorkUnitStore
-
-    // Propagate cache life & tags to the outer context if appropriate.
-    switch (outerWorkUnitStore.type) {
-      case 'prerender':
-      case 'prerender-runtime': {
-        // If we've just created a cache result, and we're filling caches for a
-        // Cache Components prerender, then we don't want to propagate cache
-        // life & tags yet, in case the entry ends up being omitted from the
-        // final prerender due to short expire/stale times. If it is omitted,
-        // then it shouldn't have any effects on the prerender. We'll decide
-        // whether or not this cache should have its life & tags propagated when
-        // we read the entry in the final prerender from the resume data cache.
-
-        break
-      }
-      case 'request': {
-        if (
-          process.env.NODE_ENV === 'development' &&
-          outerWorkUnitStore.cacheSignal
-        ) {
-          // If we're filling caches for a dev request, apply the same logic as prerenders do above,
-          // and don't propagate cache life/tags yet.
-          break
-        }
-        // fallthrough
-      }
-
-      case 'private-cache':
-      case 'cache':
-      case 'unstable-cache':
-      case 'prerender-legacy':
-      case 'prerender-ppr': {
-        propagateCacheEntryMetadata(
-          cacheContext,
-          entry,
-          innerCacheStore.type === 'cache'
-            ? innerCacheStore.readRootParamNames
-            : undefined
-        )
-        break
-      }
-      case 'generate-static-params':
-        break
-      default: {
-        outerWorkUnitStore satisfies never
-      }
-    }
+    maybePropagateCacheEntryMetadata(
+      cacheContext,
+      entry,
+      innerCacheStore.type === 'cache'
+        ? innerCacheStore.readRootParamNames
+        : undefined
+    )
 
-    const cacheSignal = getCacheSignal(outerWorkUnitStore)
+    const cacheSignal = getCacheSignal(cacheContext.outerWorkUnitStore)
     if (cacheSignal) {
       cacheSignal.endRead()
     }
@@ -1976,7 +1992,7 @@ export async function cache(
         )
       }
 
-      propagateCacheEntryMetadata(
+      maybePropagateCacheEntryMetadata(
         cacheContext,
         entry,
         knownRootParamsByFunctionId.get(id)