Repository: vercel/next.js
Pull request: Decouple route stale time from segment-level data

## Summary

Refactors segment cache entries to receive their stale time from the server response rather than inheriting from the parent route cache entry. This decouples the lifetime of segment data from the route tree, preparing for future optimizations.

When I say "route" here, what I'm referring to is the mapping of URL -> layout/page hierarchy. Typically this is a predictable mapping based on the App Router's file-based routing conventions; however, it's possible to rewrite the URL to a different internal route path.

Conceptually, the cache life of a route corresponds to how often a proxy is expected to dynamically rewrite or redirect a URL. We don't currently expose a way to directly control this, but if we did, you could think of it as being equivalent to adding `use cache` (and `cacheLife`, `cacheTag`, etc.) to the `proxy()` function.

The point, though, is that the route tree has a different lifetime than data rendered on the page. Route trees change relatively infrequently, like due to a logged in/out state change, or a feature flag change. So we can/should cache them fairly aggressively by default.

This is related to an upcoming change where we'll go even further and _predict_ routes we haven't visited yet based on structurally similar routes we've already seen.

This PR itself, though, makes no behavioral changes; it just sets up the subsequent steps.

## Test Plan

No behavioral changes - existing tests should pass.

Changed files:
filename: packages/next/src/client/components/segment-cache/cache.ts
patch: @@ -679,16 +679,17 @@ function createOptimisticRouteTree(
 export function readOrCreateSegmentCacheEntry(
   now: number,
   fetchStrategy: FetchStrategy,
-  route: FulfilledRouteCacheEntry,
   tree: RouteTree
 ): SegmentCacheEntry {
   const existingEntry = readSegmentCacheEntry(now, tree.varyPath)
   if (existingEntry !== null) {
     return existingEntry
   }
-  // Create a pending entry and add it to the cache.
+  // Create a pending entry and add it to the cache. The stale time is set to a
+  // default value; the actual stale time will be set when the entry is
+  // fulfilled with data from the server response.
   const varyPathForRequest = getSegmentVaryPathForRequest(fetchStrategy, tree)
-  const pendingEntry = createDetachedSegmentCacheEntry(route.staleAt)
+  const pendingEntry = createDetachedSegmentCacheEntry(now)
   const isRevalidation = false
   setInCacheMap(
     segmentCacheMap,
@@ -702,7 +703,6 @@ export function readOrCreateSegmentCacheEntry(
 export function readOrCreateRevalidatingSegmentEntry(
   now: number,
   fetchStrategy: FetchStrategy,
-  route: FulfilledRouteCacheEntry,
   tree: RouteTree
 ): SegmentCacheEntry {
   // This function is called when we've already confirmed that a particular
@@ -736,9 +736,11 @@ export function readOrCreateRevalidatingSegmentEntry(
   if (existingEntry !== null) {
     return existingEntry
   }
-  // Create a pending entry and add it to the cache.
+  // Create a pending entry and add it to the cache. The stale time is set to a
+  // default value; the actual stale time will be set when the entry is
+  // fulfilled with data from the server response.
   const varyPathForRequest = getSegmentVaryPathForRequest(fetchStrategy, tree)
-  const pendingEntry = createDetachedSegmentCacheEntry(route.staleAt)
+  const pendingEntry = createDetachedSegmentCacheEntry(now)
   const isRevalidation = true
   setInCacheMap(
     segmentCacheMap,
@@ -750,15 +752,17 @@ export function readOrCreateRevalidatingSegmentEntry(
 }
 
 export function overwriteRevalidatingSegmentCacheEntry(
+  now: number,
   fetchStrategy: FetchStrategy,
-  route: FulfilledRouteCacheEntry,
   tree: RouteTree
 ) {
   // This function is called when we've already decided to replace an existing
   // revalidation entry. Create a new entry and write it into the cache,
-  // overwriting the previous value.
+  // overwriting the previous value. The stale time is set to a default value;
+  // the actual stale time will be set when the entry is fulfilled with data
+  // from the server response.
   const varyPathForRequest = getSegmentVaryPathForRequest(fetchStrategy, tree)
-  const pendingEntry = createDetachedSegmentCacheEntry(route.staleAt)
+  const pendingEntry = createDetachedSegmentCacheEntry(now)
   const isRevalidation = true
   setInCacheMap(
     segmentCacheMap,
@@ -824,8 +828,11 @@ export function upsertSegmentEntry(
 }
 
 export function createDetachedSegmentCacheEntry(
-  staleAt: number
+  now: number
 ): EmptySegmentCacheEntry {
+  // Default stale time for pending segment cache entries. The actual stale time
+  // is set when the entry is fulfilled with data from the server response.
+  const staleAt = now + 30 * 1000
   const emptyEntry: EmptySegmentCacheEntry = {
     status: EntryStatus.Empty,
     // Default to assuming the fetch strategy will be PPR. This will be updated
@@ -1765,13 +1772,12 @@ export async function fetchSegmentOnCacheMiss(
       rejectSegmentCacheEntry(segmentCacheEntry, Date.now() + 10 * 1000)
       return null
     }
+    const staleAt = Date.now() + getStaleTimeMs(serverData.staleTime)
     return {
       value: fulfillSegmentCacheEntry(
         segmentCacheEntry,
         serverData.rsc,
-        // TODO: The server does not currently provide per-segment stale time.
-        // So we use the stale time of the route.
-        route.staleAt,
+        staleAt,
         serverData.isPartial
       ),
       // Return a promise that resolves when the network connection closes, so
@@ -2105,7 +2111,6 @@ function writeDynamicRenderResponseIntoCache(
         now,
         task,
         fetchStrategy,
-        route,
         tree,
         staleAt,
         seedData,
@@ -2119,7 +2124,6 @@ function writeDynamicRenderResponseIntoCache(
       fulfillEntrySpawnedByRuntimePrefetch(
         now,
         fetchStrategy,
-        route,
         head,
         flightData.isHeadPartial,
         staleAt,
@@ -2153,7 +2157,6 @@ function writeSeedDataIntoCache(
     | FetchStrategy.LoadingBoundary
     | FetchStrategy.PPRRuntime
     | FetchStrategy.Full,
-  route: FulfilledRouteCacheEntry,
   tree: RouteTree,
   staleAt: number,
   seedData: CacheNodeSeedData,
@@ -2170,7 +2173,6 @@ function writeSeedDataIntoCache(
   fulfillEntrySpawnedByRuntimePrefetch(
     now,
     fetchStrategy,
-    route,
     rsc,
     isPartial,
     staleAt,
@@ -2191,7 +2193,6 @@ function writeSeedDataIntoCache(
           now,
           task,
           fetchStrategy,
-          route,
           childTree,
           staleAt,
           childSeedData,
@@ -2209,7 +2210,6 @@ function fulfillEntrySpawnedByRuntimePrefetch(
     | FetchStrategy.LoadingBoundary
     | FetchStrategy.PPRRuntime
     | FetchStrategy.Full,
-  route: FulfilledRouteCacheEntry,
   rsc: React.ReactNode,
   isPartial: boolean,
   staleAt: number,
@@ -2233,7 +2233,6 @@ function fulfillEntrySpawnedByRuntimePrefetch(
     const possiblyNewEntry = readOrCreateSegmentCacheEntry(
       now,
       fetchStrategy,
-      route,
       tree
     )
     if (possiblyNewEntry.status === EntryStatus.Empty) {
@@ -2250,7 +2249,7 @@ function fulfillEntrySpawnedByRuntimePrefetch(
       // replace it with the new one from the server.
       const newEntry = fulfillSegmentCacheEntry(
         upgradeToPendingSegment(
-          createDetachedSegmentCacheEntry(staleAt),
+          createDetachedSegmentCacheEntry(now),
           fetchStrategy
         ),
         rsc,

filename: packages/next/src/client/components/segment-cache/scheduler.ts
patch: @@ -788,12 +788,7 @@ function pingStaticHead(
     now,
     task,
     route,
-    readOrCreateSegmentCacheEntry(
-      now,
-      FetchStrategy.PPR,
-      route,
-      route.metadata
-    ),
+    readOrCreateSegmentCacheEntry(now, FetchStrategy.PPR, route.metadata),
     task.key,
     route.metadata
   )
@@ -848,7 +843,6 @@ function pingSharedPartOfCacheComponentsTree(
   const segment = readOrCreateSegmentCacheEntry(
     now,
     task.fetchStrategy,
-    route,
     newTree
   )
   pingStaticSegmentData(now, task, route, segment, task.key, newTree)
@@ -946,12 +940,7 @@ function pingNewPartOfCacheComponentsTree(
   }
 
   // This segment should not be runtime prefetched. Prefetch its static data.
-  const segment = readOrCreateSegmentCacheEntry(
-    now,
-    task.fetchStrategy,
-    route,
-    tree
-  )
+  const segment = readOrCreateSegmentCacheEntry(now, task.fetchStrategy, tree)
   pingStaticSegmentData(now, task, route, segment, task.key, tree)
   if (tree.slots !== null) {
     if (!hasNetworkBandwidth(task)) {
@@ -1146,12 +1135,7 @@ function pingPPRDisabledRouteTreeUpToLoadingBoundary(
   let refetchMarker: 'refetch' | 'inside-shared-layout' | null =
     refetchMarkerContext === null ? 'inside-shared-layout' : null
 
-  const segment = readOrCreateSegmentCacheEntry(
-    now,
-    task.fetchStrategy,
-    route,
-    tree
-  )
+  const segment = readOrCreateSegmentCacheEntry(now, task.fetchStrategy, tree)
   switch (segment.status) {
     case EntryStatus.Empty: {
       // This segment is not cached. Add a refetch marker so the server knows
@@ -1262,7 +1246,6 @@ function pingRouteTreeAndIncludeDynamicData(
     // always use runtime prefetching (via `export const prefetch`), and those should check for
     // entries that include search params.
     fetchStrategy,
-    route,
     tree
   )
 
@@ -1301,12 +1284,7 @@ function pingRouteTreeAndIncludeDynamicData(
         //   - we have a static prefetch, and we're doing a runtime prefetch
         //   - we have a static or runtime prefetch, and we're doing a Full prefetch (or a navigation).
         // In either case, we need to include it in the request to get a more specific (or full) version.
-        spawnedSegment = pingFullSegmentRevalidation(
-          now,
-          route,
-          tree,
-          fetchStrategy
-        )
+        spawnedSegment = pingFullSegmentRevalidation(now, tree, fetchStrategy)
       }
       break
     }
@@ -1320,12 +1298,7 @@ function pingRouteTreeAndIncludeDynamicData(
           fetchStrategy
         )
       ) {
-        spawnedSegment = pingFullSegmentRevalidation(
-          now,
-          route,
-          tree,
-          fetchStrategy
-        )
+        spawnedSegment = pingFullSegmentRevalidation(now, tree, fetchStrategy)
       }
       break
     }
@@ -1514,7 +1487,6 @@ function pingPPRSegmentRevalidation(
   const revalidatingSegment = readOrCreateRevalidatingSegmentEntry(
     now,
     FetchStrategy.PPR,
-    route,
     tree
   )
   switch (revalidatingSegment.status) {
@@ -1549,14 +1521,12 @@ function pingPPRSegmentRevalidation(
 
 function pingFullSegmentRevalidation(
   now: number,
-  route: FulfilledRouteCacheEntry,
   tree: RouteTree,
   fetchStrategy: FetchStrategy.Full | FetchStrategy.PPRRuntime
 ): PendingSegmentCacheEntry | null {
   const revalidatingSegment = readOrCreateRevalidatingSegmentEntry(
     now,
     fetchStrategy,
-    route,
     tree
   )
   if (revalidatingSegment.status === EntryStatus.Empty) {
@@ -1586,8 +1556,8 @@ function pingFullSegmentRevalidation(
       // The existing revalidation was fetched using a less specific strategy.
       // Reset it and start a new revalidation.
       const emptySegment = overwriteRevalidatingSegmentCacheEntry(
+        now,
         fetchStrategy,
-        route,
         tree
       )
       const pendingSegment = upgradeToPendingSegment(

filename: packages/next/src/server/app-render/collect-segment-data.tsx
patch: @@ -80,6 +80,7 @@ export type SegmentPrefetch = {
   buildId: string
   rsc: React.ReactNode | null
   isPartial: boolean
+  staleTime: number
 }
 
 const filterStackFrame =
@@ -241,6 +242,7 @@ async function PrefetchTreeData({
     isClientParamParsingEnabled,
     flightRouterState,
     buildId,
+    staleTime,
     seedData,
     clientModules,
     ROOT_SEGMENT_REQUEST_KEY,
@@ -253,7 +255,13 @@ async function PrefetchTreeData({
   // the client cache.
   segmentTasks.push(
     waitAtLeastOneReactRenderTask().then(() =>
-      renderSegmentPrefetch(buildId, head, HEAD_REQUEST_KEY, clientModules)
+      renderSegmentPrefetch(
+        buildId,
+        staleTime,
+        head,
+        HEAD_REQUEST_KEY,
+        clientModules
+      )
     )
   )
 
@@ -275,6 +283,7 @@ function collectSegmentDataImpl(
   isClientParamParsingEnabled: boolean,
   route: FlightRouterState,
   buildId: string,
+  staleTime: number,
   seedData: CacheNodeSeedData | null,
   clientModules: ManifestNode,
   requestKey: SegmentRequestKey,
@@ -301,6 +310,7 @@ function collectSegmentDataImpl(
       isClientParamParsingEnabled,
       childRoute,
       buildId,
+      staleTime,
       childSeedData,
       clientModules,
       childRequestKey,
@@ -320,7 +330,13 @@ function collectSegmentDataImpl(
       // Since we're already in the middle of a render, wait until after the
       // current task to escape the current rendering context.
       waitAtLeastOneReactRenderTask().then(() =>
-        renderSegmentPrefetch(buildId, seedData[0], requestKey, clientModules)
+        renderSegmentPrefetch(
+          buildId,
+          staleTime,
+          seedData[0],
+          requestKey,
+          clientModules
+        )
       )
     )
   } else {
@@ -361,17 +377,17 @@ function collectSegmentDataImpl(
 
 async function renderSegmentPrefetch(
   buildId: string,
+  staleTime: number,
   rsc: React.ReactNode,
   requestKey: SegmentRequestKey,
   clientModules: ManifestNode
 ): Promise<[SegmentRequestKey, Buffer]> {
   // Render the segment data to a stream.
-  // In the future, this is where we can include additional metadata, like the
-  // stale time and cache tags.
   const segmentPrefetch: SegmentPrefetch = {
     buildId,
     rsc,
     isPartial: await isPartialRSCData(rsc, clientModules),
+    staleTime,
   }
   // Since all we're doing is decoding and re-encoding a cached prerender, if
   // it takes longer than a microtask, it must because of hanging promises