Repository: vercel/next.js
Pull request: [Segment Cache]: fix infinite prefetching when staleTime is 0

This PR fixes an infinite loop bug that occurred when using `cacheLife({ stale: 0 })` with `clientSegmentCache`. When the server returned a stale time of 0 seconds, cache entries would have `staleAt = Date.now()`, making them immediately stale. The cache would evict these entries on the same tick they were created, triggering continuous refetch requests.

The fix updates the minimum stale time to be 30s, in the event that the server sends a low value. The rationale being that a value lower than 30s here would render prefetching ineffective. This prevents the immediate eviction while still respecting the server's intent for very short-lived cache entries. 

Closes NAR-269

Changed files:
filename: packages/next/src/client/components/segment-cache-impl/cache.ts
patch: @@ -244,6 +244,14 @@ const isOutputExportMode =
   process.env.NODE_ENV === 'production' &&
   process.env.__NEXT_CONFIG_OUTPUT === 'export'
 
+/**
+ * Ensures a minimum stale time of 30s to avoid issues where the server sends a too
+ * short-lived stale time, which would prevent anything from being prefetched.
+ */
+function getStaleTimeMs(staleTimeSeconds: number): number {
+  return Math.max(staleTimeSeconds, 30) * 1000
+}
+
 // Route cache entries vary on multiple keys: the href and the Next-Url. Each of
 // these parts needs to be included in the internal cache key. Rather than
 // concatenate the keys into a single key, we use a multi-level map, where the
@@ -1271,7 +1279,7 @@ export async function fetchRouteOnCacheMiss(
         renderedPathname
       )
 
-      const staleTimeMs = serverData.staleTime * 1000
+      const staleTimeMs = getStaleTimeMs(serverData.staleTime)
       fulfillRouteCacheEntry(
         entry,
         routeTree,
@@ -1641,7 +1649,7 @@ function writeDynamicTreeResponseIntoCache(
   )
   const staleTimeMs =
     staleTimeHeaderSeconds !== null
-      ? parseInt(staleTimeHeaderSeconds, 10) * 1000
+      ? getStaleTimeMs(parseInt(staleTimeHeaderSeconds, 10))
       : STATIC_STALETIME_MS
 
   // If the response contains dynamic holes, then we must conservatively assume
@@ -1740,7 +1748,7 @@ function writeDynamicRenderResponseIntoCache(
   )
   const staleTimeMs =
     staleTimeHeaderSeconds !== null
-      ? parseInt(staleTimeHeaderSeconds, 10) * 1000
+      ? getStaleTimeMs(parseInt(staleTimeHeaderSeconds, 10))
       : STATIC_STALETIME_MS
   const staleAt = now + staleTimeMs
 

filename: test/e2e/app-dir/segment-cache/basic/app/cache-life-seconds-test/page.tsx
patch: @@ -0,0 +1,12 @@
+import { LinkAccordion } from '../../components/link-accordion'
+
+export default function CacheLifeSecondsTestPage() {
+  return (
+    <div>
+      <h1>Cache Life Seconds Test</h1>
+      <LinkAccordion href="/cache-life-seconds">
+        Go to cache-life-seconds page
+      </LinkAccordion>
+    </div>
+  )
+}

filename: test/e2e/app-dir/segment-cache/basic/app/cache-life-seconds/loading.tsx
patch: @@ -0,0 +1,7 @@
+export default function Loading() {
+  return (
+    <div>
+      <h1>Loading...</h1>
+    </div>
+  )
+}

filename: test/e2e/app-dir/segment-cache/basic/app/cache-life-seconds/page.tsx
patch: @@ -0,0 +1,15 @@
+import { unstable_cacheLife } from 'next/cache'
+
+export default async function CacheLifeSecondsPage() {
+  'use cache'
+  unstable_cacheLife({ stale: 0, revalidate: 1, expire: 60 })
+
+  const randomNumber = Math.random()
+
+  return (
+    <div id="cache-life-seconds-page">
+      <p>Cache Life Seconds Page</p>
+      <p id="random-value">{randomNumber}</p>
+    </div>
+  )
+}

filename: test/e2e/app-dir/segment-cache/basic/segment-cache-basic.test.ts
patch: @@ -1,5 +1,6 @@
 import { nextTestSetup } from 'e2e-utils'
 import { createRouterAct } from '../router-act'
+import { waitFor } from 'next-test-utils'
 
 describe('segment cache (basic tests)', () => {
   const { next, isNextDev } = nextTestSetup({
@@ -371,4 +372,40 @@ describe('segment cache (basic tests)', () => {
       'no-requests'
     )
   })
+
+  it('does not cause infinite loop with cacheLife("seconds")', async () => {
+    let requestCount = 0
+
+    const browser = await next.browser('/cache-life-seconds-test', {
+      beforePageLoad(page) {
+        page.on('request', (request) => {
+          const url = request.url()
+          if (url.includes('/cache-life-seconds') && url.includes('_rsc')) {
+            requestCount++
+          }
+        })
+      },
+    })
+
+    // Reveal the link to trigger a prefetch
+    const reveal = await browser.elementByCss('input[type="checkbox"]')
+    await reveal.click()
+
+    // Wait for the link to appear
+    const link = await browser.elementByCss('a[href="/cache-life-seconds"]')
+
+    // Give the prefetch a moment to potentially start looping
+    await waitFor(500)
+
+    // Check that we haven't made excessive requests during prefetch
+    expect(requestCount).toBeLessThan(10)
+
+    // Now navigate to the page to ensure it works correctly
+    await link.click()
+
+    // Wait for the page to load
+    const page = await browser.elementById('cache-life-seconds-page')
+    const content = await page.textContent()
+    expect(content).toContain('Cache Life Seconds Page')
+  })
 })