Skip to content

Commit 2061f04

Browse files
committed
[backport] fix: unstable_cache should perform blocking revalidation during ISR revalidation (#84716)
Backports: - #83820
1 parent ce3d963 commit 2061f04

File tree

6 files changed

+161
-10
lines changed

6 files changed

+161
-10
lines changed

packages/next/src/server/web/spec-extension/unstable-cache.ts

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -238,18 +238,19 @@ export function unstable_cache<T extends Callback>(
238238
cacheEntry.value.data.body !== undefined
239239
? JSON.parse(cacheEntry.value.data.body)
240240
: undefined
241+
241242
if (cacheEntry.isStale) {
242-
// In App Router we return the stale result and revalidate in the background
243243
if (!workStore.pendingRevalidates) {
244244
workStore.pendingRevalidates = {}
245245
}
246246

247-
// We run the cache function asynchronously and save the result when it completes
248-
workStore.pendingRevalidates[invocationKey] =
249-
workUnitAsyncStorage
247+
// Check if there's already a pending revalidation to avoid duplicate work
248+
if (!workStore.pendingRevalidates[invocationKey]) {
249+
// Create the revalidation promise
250+
const revalidationPromise = workUnitAsyncStorage
250251
.run(innerCacheStore, cb, ...args)
251-
.then((result) => {
252-
return cacheNewResult(
252+
.then(async (result) => {
253+
await cacheNewResult(
253254
result,
254255
incrementalCache,
255256
cacheKey,
@@ -258,15 +259,37 @@ export function unstable_cache<T extends Callback>(
258259
fetchIdx,
259260
fetchUrl
260261
)
262+
return result
261263
})
262-
// @TODO This error handling seems wrong. We swallow the error?
263-
.catch((err) =>
264+
.catch((err) => {
265+
// @TODO This error handling seems wrong. We swallow the error?
264266
console.error(
265267
`revalidating cache with key: ${invocationKey}`,
266268
err
267269
)
268-
)
270+
// Return the stale value on error for foreground revalidation
271+
return cachedResponse
272+
})
273+
274+
// Attach the empty catch here so we don't get a "unhandled promise
275+
// rejection" warning. (Behavior is matched with patch-fetch)
276+
if (workStore.isRevalidate) {
277+
revalidationPromise.catch(() => {})
278+
}
279+
280+
workStore.pendingRevalidates[invocationKey] =
281+
revalidationPromise
282+
}
283+
284+
// Check if we need to do foreground revalidation
285+
if (workStore.isRevalidate) {
286+
// When the page is revalidating and the cache entry is stale,
287+
// we need to wait for fresh data (blocking revalidate)
288+
return workStore.pendingRevalidates[invocationKey]
289+
}
290+
// Otherwise, we're doing background revalidation - return stale immediately
269291
}
292+
270293
// We had a valid cache entry so we return it here
271294
return cachedResponse
272295
}

test/cache-components-tests-manifest.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,9 @@
349349
"test/production/app-dir/subresource-integrity/subresource-integrity.test.ts",
350350
"test/production/app-dir/typed-routes-with-webpack-worker/typed-routes-with-webpack-worker.test.ts",
351351
"test/production/app-dir/unexpected-error/unexpected-error.test.ts",
352-
"test/production/app-dir/worker-restart/worker-restart.test.ts"
352+
"test/production/app-dir/worker-restart/worker-restart.test.ts",
353+
"test/production/app-dir/resume-data-cache/resume-data-cache.test.ts",
354+
"test/production/app-dir/unstable-cache-foreground-revalidate/unstable-cache-foreground-revalidate.test.ts"
353355
]
354356
}
355357
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { unstable_cache } from 'next/cache'
2+
3+
export const revalidate = 10
4+
5+
const getCachedData = unstable_cache(
6+
async () => {
7+
const generatedAt = Date.now()
8+
9+
// Log when this function is actually executed
10+
console.log('[TEST] unstable_cache callback executed at:', generatedAt)
11+
12+
// Add a delay to simulate expensive operation
13+
await new Promise((resolve) => setTimeout(resolve, 100))
14+
15+
return {
16+
generatedAt,
17+
random: Math.random(),
18+
}
19+
},
20+
['cached-data'],
21+
{
22+
revalidate: 5,
23+
}
24+
)
25+
26+
export default async function Page() {
27+
const pageRenderStart = Date.now()
28+
console.log('[TEST] Page render started at:', pageRenderStart)
29+
30+
const cachedData = await getCachedData()
31+
32+
console.log(
33+
'[TEST] Page render completed with cache data from:',
34+
cachedData.generatedAt
35+
)
36+
37+
return (
38+
<div>
39+
<div id="page-time">{pageRenderStart}</div>
40+
<div id="cache-generated-at">{cachedData.generatedAt}</div>
41+
<div id="random">{cachedData.random}</div>
42+
</div>
43+
)
44+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function RootLayout({ children }) {
2+
return (
3+
<html>
4+
<body>{children}</body>
5+
</html>
6+
)
7+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = {}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { nextTestSetup } from 'e2e-utils'
2+
3+
describe('unstable-cache-foreground-revalidate', () => {
4+
const { next, isNextDev } = nextTestSetup({
5+
files: __dirname,
6+
skipDeployment: true,
7+
})
8+
9+
if (isNextDev) {
10+
it.skip('should not run in dev mode', () => {})
11+
return
12+
}
13+
14+
it('should block and wait for fresh data when ISR page revalidate time is greater than unstable_cache TTL', async () => {
15+
// Initial render to warm up cache
16+
await next.render('/isr-10')
17+
18+
// Record initial log position
19+
const initialLogLength = next.cliOutput.length
20+
21+
// Wait for both ISR and unstable_cache to become stale
22+
await new Promise((resolve) => setTimeout(resolve, 11000))
23+
24+
// This request triggers ISR background revalidation
25+
await next.render('/isr-10')
26+
27+
// Wait for ISR background revalidation to complete
28+
await new Promise((resolve) => setTimeout(resolve, 2000))
29+
30+
// Get logs since the initial render
31+
const logs = next.cliOutput.substring(initialLogLength)
32+
33+
const cacheExecutions = [
34+
...logs.matchAll(/\[TEST\] unstable_cache callback executed at: (\d+)/g),
35+
]
36+
const completions = [
37+
...logs.matchAll(
38+
/\[TEST\] Page render completed with cache data from: (\d+)/g
39+
),
40+
]
41+
42+
if (completions.length === 0) {
43+
throw new Error('No page completions found in logs')
44+
}
45+
46+
const lastCompletion = completions[completions.length - 1]
47+
const lastCacheExecution =
48+
cacheExecutions.length > 0
49+
? cacheExecutions[cacheExecutions.length - 1]
50+
: null
51+
52+
if (!lastCacheExecution) {
53+
throw new Error(
54+
`Expected cache execution during ISR revalidation but found none. ` +
55+
`Cache executions: ${cacheExecutions.length}, Page completions: ${completions.length}`
56+
)
57+
}
58+
59+
const cacheExecutedAt = parseInt(lastCacheExecution[1])
60+
const cacheDataFrom = parseInt(lastCompletion[1])
61+
const timeDiff = Math.abs(cacheExecutedAt - cacheDataFrom)
62+
63+
console.log('ISR revalidation timing:')
64+
console.log('- Cache executed at:', cacheExecutedAt)
65+
console.log('- ISR used cache data from:', cacheDataFrom)
66+
console.log('- Time difference:', timeDiff, 'ms')
67+
68+
// With foreground revalidation:
69+
// - ISR waits for fresh data, so timestamps should match (< 1000ms difference)
70+
// Without foreground revalidation:
71+
// - ISR uses stale data, so timestamps will be far apart (> 10000ms)
72+
expect(timeDiff).toBeLessThan(1000)
73+
})
74+
})

0 commit comments

Comments
 (0)