Skip to content

Commit 48b20d0

Browse files
committed
fix: unstable_cache should perform blocking revalidation during ISR revalidation
1 parent 439279c commit 48b20d0

File tree

5 files changed

+172
-23
lines changed

5 files changed

+172
-23
lines changed

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

Lines changed: 62 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -239,33 +239,72 @@ export function unstable_cache<T extends Callback>(
239239
? JSON.parse(cacheEntry.value.data.body)
240240
: undefined
241241
if (cacheEntry.isStale) {
242-
// In App Router we return the stale result and revalidate in the background
243242
if (!workStore.pendingRevalidates) {
244243
workStore.pendingRevalidates = {}
245244
}
246245

247-
// We run the cache function asynchronously and save the result when it completes
248-
workStore.pendingRevalidates[invocationKey] =
249-
workUnitAsyncStorage
250-
.run(innerCacheStore, cb, ...args)
251-
.then((result) => {
252-
return cacheNewResult(
253-
result,
254-
incrementalCache,
255-
cacheKey,
256-
tags,
257-
options.revalidate,
258-
fetchIdx,
259-
fetchUrl
260-
)
261-
})
262-
// @TODO This error handling seems wrong. We swallow the error?
263-
.catch((err) =>
264-
console.error(
265-
`revalidating cache with key: ${invocationKey}`,
266-
err
267-
)
268-
)
246+
if (workStore.isRevalidate) {
247+
// When the page is revalidating and the cache entry is stale,
248+
// we need to wait for fresh data (blocking revalidate)
249+
let pendingRevalidate =
250+
workStore.pendingRevalidates[invocationKey]
251+
252+
if (!pendingRevalidate) {
253+
// Create the revalidation promise
254+
pendingRevalidate = workUnitAsyncStorage
255+
.run(innerCacheStore, cb, ...args)
256+
.then(async (result) => {
257+
await cacheNewResult(
258+
result,
259+
incrementalCache,
260+
cacheKey,
261+
tags,
262+
options.revalidate,
263+
fetchIdx,
264+
fetchUrl
265+
)
266+
return result
267+
})
268+
269+
// Attach empty catch to prevent unhandled rejection warnings
270+
// This mirrors handling in patch-fetch
271+
pendingRevalidate.catch(() => {})
272+
273+
workStore.pendingRevalidates[invocationKey] =
274+
pendingRevalidate
275+
}
276+
277+
// Wait for and return the revalidated result
278+
return pendingRevalidate
279+
} else if (cacheEntry.isStale) {
280+
// In App Router we return the stale result and revalidate in the background
281+
282+
// Check if there's already a pending revalidation to avoid duplicate work
283+
if (!workStore.pendingRevalidates[invocationKey]) {
284+
// We run the cache function asynchronously and save the result when it completes
285+
workStore.pendingRevalidates[invocationKey] =
286+
workUnitAsyncStorage
287+
.run(innerCacheStore, cb, ...args)
288+
.then((result) => {
289+
return cacheNewResult(
290+
result,
291+
incrementalCache,
292+
cacheKey,
293+
tags,
294+
options.revalidate,
295+
fetchIdx,
296+
fetchUrl
297+
)
298+
})
299+
// @TODO This error handling seems wrong. We swallow the error?
300+
.catch((err) =>
301+
console.error(
302+
`revalidating cache with key: ${invocationKey}`,
303+
err
304+
)
305+
)
306+
}
307+
}
269308
}
270309
// We had a valid cache entry so we return it here
271310
return cachedResponse
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: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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+
// Extract timestamps from logs
34+
const cacheExecutionMatch = logs.match(
35+
/\[TEST\] unstable_cache callback executed at: (\d+)/
36+
)
37+
const completionMatch = logs.match(
38+
/\[TEST\] Page render completed with cache data from: (\d+)/
39+
)
40+
41+
if (cacheExecutionMatch && completionMatch) {
42+
const cacheExecutedAt = parseInt(cacheExecutionMatch[1])
43+
const cacheDataFrom = parseInt(completionMatch[1])
44+
const timeDiff = Math.abs(cacheExecutedAt - cacheDataFrom)
45+
46+
console.log('Cache execution timing:')
47+
console.log('- Cache executed at:', cacheExecutedAt)
48+
console.log('- Page used cache data from:', cacheDataFrom)
49+
console.log('- Time difference:', timeDiff, 'ms')
50+
51+
// With foreground revalidation:
52+
// - ISR waits for fresh data, so timestamps should match (< 1000ms difference)
53+
// Without foreground revalidation:
54+
// - ISR uses stale data, so timestamps will be far apart (> 10000ms)
55+
expect(timeDiff).toBeLessThan(1000)
56+
}
57+
})
58+
})

0 commit comments

Comments
 (0)