Files
next.js/test/e2e/app-dir/use-cache-swr/use-cache-swr.test.ts
a6223ac95d fix(use-cache): remove awaiting revalidation (#92636)
### What?
fixes #74882

The `use cache` directive does not revalidate in the background while
serving (not expired) stale data.

For the static pages, the whole page is blocked during revalidation, and
fresh data is fetched served. While I don't agree with this, as it is
just a duplication of the expire behaviour, this is documented as
intended in the comments in the code.

For the dynamic pages, when the data is flagged for revalidation, the
stale data is blocked while fresh data is fetched. Once the fresh data
is fetched, the function returns the stale data. On refreshing, the
fresh data is served. This is the worst of both worlds, as we are still
waiting for fresh data, and then seeing the stale data.

In both cases there is no background revalidation of data, leading to
slow, unresponsive pages.

I have an example app showing this behaviour here:
https://github.com/awo00/nextjs-use-cache-revalidate

### Why?

In the `use-cache-wrapper`, the `generateCacheEntry()` function is
awaited during revalidation which is blocking the stale data being
returned.

### How?

Removing the await allows the stale data to be returned faster.

---------

Co-authored-by: Hendrik Liebau <[email protected]>
2026-04-22 10:15:51 +02:00

165 lines
6.2 KiB
TypeScript

import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
describe('use-cache-swr', () => {
const { next, skipped } = nextTestSetup({
files: __dirname,
skipDeployment: true,
})
if (skipped) return
let outputIndex: number
beforeEach(() => {
outputIndex = next.cliOutput.length
})
it('should serve stale data and then pre-warmed data on subsequent request', async () => {
const browser = await next.browser('/')
const initialOuter = await browser.elementById('outer-data').text()
expect(initialOuter).toBeDateString()
// Wait for the outer cache to go stale (revalidate: 5).
await new Promise((resolve) => setTimeout(resolve, 6000))
// Reset output index so the regen set we poll for below can't be satisfied
// by the initial cold-fill set from the first fetch.
outputIndex = next.cliOutput.length
// This request should trigger SWR: the handler returns the stale entry,
// the framework serves it to the client, and kicks off a background regen.
await browser.refresh()
const afterStale = await browser.elementById('outer-data').text()
// The stale data should be the same as the initial data.
expect(afterStale).toBe(initialOuter)
// Wait for the background regen to complete by polling for its set log.
await retry(() => {
const regenOutput = next.cliOutput.slice(outputIndex)
expect(regenOutput).toMatch(/PersistentCacheHandler::set.*"outer"/)
})
// Reset output index to capture only the next request's handler logs.
outputIndex = next.cliOutput.length
// Refresh again. The pre-warmed entry from the SWR regen should be served.
await browser.refresh()
const afterRegen = await browser.elementById('outer-data').text()
// The data should now be fresh (different from the stale data).
expect(afterRegen).not.toBe(initialOuter)
// Verify this was served from the pre-warmed cache (get hit, no set).
const cliOutput = next.cliOutput.slice(outputIndex)
expect(cliOutput).toMatch(/PersistentCacheHandler::get.*"outer".*-> hit/)
expect(cliOutput).not.toMatch(/PersistentCacheHandler::set.*"outer"/)
})
it('should serve stale data without blocking on the background regeneration', async () => {
// Fetch 1: cold cache. The cached component blocks for ~1s.
const $1 = await next.render$('/delayed')
const cached1 = $1('#cached').text()
const dynamic1 = $1('#dynamic').text()
expect(cached1).toBeDateString()
expect(dynamic1).toBeDateString()
// Wait past the 1s revalidate window (cacheLife('seconds')).
await new Promise((resolve) => setTimeout(resolve, 1200))
outputIndex = next.cliOutput.length
// Fetch 2: stale hit. Should return the stale entry immediately and kick
// off the regeneration in the background, rather than blocking on the 1s
// delay in the cached component.
const start2 = Date.now()
const $2 = await next.render$('/delayed')
const duration2 = Date.now() - start2
const cached2 = $2('#cached').text()
const dynamic2 = $2('#dynamic').text()
expect(cached2).toBe(cached1)
expect(dynamic2).not.toBe(dynamic1)
expect(duration2).toBeLessThan(1000)
// Wait for the background regen to finish writing the fresh entry.
await retry(() => {
expect(next.cliOutput.slice(outputIndex)).toMatch(
/PersistentCacheHandler::set/
)
})
// Fetch 3: should serve the pre-warmed fresh entry from the background
// regen, not a new stale value.
const start3 = Date.now()
const $3 = await next.render$('/delayed')
const duration3 = Date.now() - start3
const cached3 = $3('#cached').text()
const dynamic3 = $3('#dynamic').text()
expect(cached3).not.toBe(cached1)
expect(dynamic3).not.toBe(dynamic2)
expect(duration3).toBeLessThan(1000)
})
it('should pass implicit tags to cache handler get() for nested caches during SWR', async () => {
const browser = await next.browser('/')
await browser.elementById('outer-data').text()
// Wait for the outer cache to go stale (revalidate: 5).
await new Promise((resolve) => setTimeout(resolve, 6000))
// Reset output index to capture only the SWR-related logs.
outputIndex = next.cliOutput.length
// This triggers SWR: stale outer is served, background regen starts.
// During regen, the outer fn re-executes and calls the inner "use cache".
// The inner cache's get() should receive the page's implicit tags.
await browser.refresh()
// Wait for the background regen to complete.
await new Promise((resolve) => setTimeout(resolve, 1000))
const cliOutput = next.cliOutput.slice(outputIndex)
// The inner cache's get() during SWR regen should include the page's
// implicit tags (softTags), not an empty array. We identify the inner
// cache by its "inner" sentinel argument in the key.
expect(cliOutput).toMatch(/PersistentCacheHandler::get.*"inner".*_N_T_\//)
})
it('should dedupe SWR regens across concurrent requests', async () => {
const browser = await next.browser('/')
await browser.elementById('outer-data').text()
// Wait for the outer cache to go stale (revalidate: 5).
await new Promise((resolve) => setTimeout(resolve, 6000))
// Reset output index to capture only the SWR-related logs.
outputIndex = next.cliOutput.length
// Fire multiple concurrent requests that all find the stale entry.
// Only one of them should trigger a background regen.
await Promise.all([next.fetch('/'), next.fetch('/'), next.fetch('/')])
// Wait for the background regen to complete.
await retry(() => {
const regenOutput = next.cliOutput.slice(outputIndex)
expect(regenOutput).toInclude('use-cache-swr: generating outer data')
})
const cliOutput = next.cliOutput.slice(outputIndex)
// The cache function should have been executed only once across all
// concurrent requests, not once per request.
const generationCalls = cliOutput.split('\n').filter(
(line) =>
line.includes('use-cache-swr: generating outer data') &&
// Ignore replayed logs that have a Cache badge.
!line.includes(' Cache ')
)
expect(generationCalls).toHaveLength(1)
})
})