Skip to content

Commit f63290f

Browse files
authored
[Segment Cache]: fix infinite prefetching when staleTime is 0 (#82388)
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
1 parent 6cae6d8 commit f63290f

File tree

5 files changed

+82
-3
lines changed

5 files changed

+82
-3
lines changed

packages/next/src/client/components/segment-cache-impl/cache.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,14 @@ const isOutputExportMode =
244244
process.env.NODE_ENV === 'production' &&
245245
process.env.__NEXT_CONFIG_OUTPUT === 'export'
246246

247+
/**
248+
* Ensures a minimum stale time of 30s to avoid issues where the server sends a too
249+
* short-lived stale time, which would prevent anything from being prefetched.
250+
*/
251+
function getStaleTimeMs(staleTimeSeconds: number): number {
252+
return Math.max(staleTimeSeconds, 30) * 1000
253+
}
254+
247255
// Route cache entries vary on multiple keys: the href and the Next-Url. Each of
248256
// these parts needs to be included in the internal cache key. Rather than
249257
// concatenate the keys into a single key, we use a multi-level map, where the
@@ -1271,7 +1279,7 @@ export async function fetchRouteOnCacheMiss(
12711279
renderedPathname
12721280
)
12731281

1274-
const staleTimeMs = serverData.staleTime * 1000
1282+
const staleTimeMs = getStaleTimeMs(serverData.staleTime)
12751283
fulfillRouteCacheEntry(
12761284
entry,
12771285
routeTree,
@@ -1641,7 +1649,7 @@ function writeDynamicTreeResponseIntoCache(
16411649
)
16421650
const staleTimeMs =
16431651
staleTimeHeaderSeconds !== null
1644-
? parseInt(staleTimeHeaderSeconds, 10) * 1000
1652+
? getStaleTimeMs(parseInt(staleTimeHeaderSeconds, 10))
16451653
: STATIC_STALETIME_MS
16461654

16471655
// If the response contains dynamic holes, then we must conservatively assume
@@ -1740,7 +1748,7 @@ function writeDynamicRenderResponseIntoCache(
17401748
)
17411749
const staleTimeMs =
17421750
staleTimeHeaderSeconds !== null
1743-
? parseInt(staleTimeHeaderSeconds, 10) * 1000
1751+
? getStaleTimeMs(parseInt(staleTimeHeaderSeconds, 10))
17441752
: STATIC_STALETIME_MS
17451753
const staleAt = now + staleTimeMs
17461754

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { LinkAccordion } from '../../components/link-accordion'
2+
3+
export default function CacheLifeSecondsTestPage() {
4+
return (
5+
<div>
6+
<h1>Cache Life Seconds Test</h1>
7+
<LinkAccordion href="/cache-life-seconds">
8+
Go to cache-life-seconds page
9+
</LinkAccordion>
10+
</div>
11+
)
12+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function Loading() {
2+
return (
3+
<div>
4+
<h1>Loading...</h1>
5+
</div>
6+
)
7+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { unstable_cacheLife } from 'next/cache'
2+
3+
export default async function CacheLifeSecondsPage() {
4+
'use cache'
5+
unstable_cacheLife({ stale: 0, revalidate: 1, expire: 60 })
6+
7+
const randomNumber = Math.random()
8+
9+
return (
10+
<div id="cache-life-seconds-page">
11+
<p>Cache Life Seconds Page</p>
12+
<p id="random-value">{randomNumber}</p>
13+
</div>
14+
)
15+
}

test/e2e/app-dir/segment-cache/basic/segment-cache-basic.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { nextTestSetup } from 'e2e-utils'
22
import { createRouterAct } from '../router-act'
3+
import { waitFor } from 'next-test-utils'
34

45
describe('segment cache (basic tests)', () => {
56
const { next, isNextDev } = nextTestSetup({
@@ -371,4 +372,40 @@ describe('segment cache (basic tests)', () => {
371372
'no-requests'
372373
)
373374
})
375+
376+
it('does not cause infinite loop with cacheLife("seconds")', async () => {
377+
let requestCount = 0
378+
379+
const browser = await next.browser('/cache-life-seconds-test', {
380+
beforePageLoad(page) {
381+
page.on('request', (request) => {
382+
const url = request.url()
383+
if (url.includes('/cache-life-seconds') && url.includes('_rsc')) {
384+
requestCount++
385+
}
386+
})
387+
},
388+
})
389+
390+
// Reveal the link to trigger a prefetch
391+
const reveal = await browser.elementByCss('input[type="checkbox"]')
392+
await reveal.click()
393+
394+
// Wait for the link to appear
395+
const link = await browser.elementByCss('a[href="/cache-life-seconds"]')
396+
397+
// Give the prefetch a moment to potentially start looping
398+
await waitFor(500)
399+
400+
// Check that we haven't made excessive requests during prefetch
401+
expect(requestCount).toBeLessThan(10)
402+
403+
// Now navigate to the page to ensure it works correctly
404+
await link.click()
405+
406+
// Wait for the page to load
407+
const page = await browser.elementById('cache-life-seconds-page')
408+
const content = await page.textContent()
409+
expect(content).toContain('Cache Life Seconds Page')
410+
})
374411
})

0 commit comments

Comments
 (0)