Files
Hendrik LiebauandGitHub 2850659b74 Cache short-expire 'use cache' values across dev reloads (#95362)
Development has recently gained several mechanisms that make `'use
cache'` reloads fast under Cache Components: `'use cache: private'`
entries are persisted in a dedicated in-memory handler,
`cacheMaxMemorySize: 0` uses a real in-memory handler instead of the
no-op stub, and custom handlers are fronted by a fast built-in handler
through the tiered handler. One case was still missing. A value that
opts into a dynamic, client-only life with an explicit short `expire`
(for example `cacheLife({ expire: 0 })`, or the built-in `'seconds'`
profile) was treated as a miss on every reload, for both the built-in
default handler and custom handlers, so reloads re-ran the cache
function and streamed slowly.

The reason is that `expire` is the value's expiration bound, the longest
it may still be served before it has to be treated as expired. That is
its purpose in both dev and production; what differs is which threshold
the built-in in-memory handler enforces. In `next dev` it serves stale
entries up to `expire` to keep reloads fast, whereas in production it
drops them earlier, once past `revalidate`. An `expire` of zero
therefore leaves the dev handler no window in which a reload can be
served from the cache, and the wrapper's serve-vs-regenerate check,
which also keys on `expire`, regenerates instead.

This change extends the same dev-only treatment to those values without
altering their resolved cache life. The built-in default handler now
retains an entry for at least `MIN_PRERENDERABLE_EXPIRE` in dev, a
minimum the custom front handler inherits by being a built-in default
handler, and the wrapper applies the same minimum when deciding whether
to serve or regenerate. That affects the retain and serve decisions
only; the entry keeps its real `expire`, so the staged dev render still
resolves it at the appropriate stage rather than in the shell stage. A
short-`expire` entry is also re-warmed in the background on every
dynamic request render, so a reload serves the previously cached value
immediately and the freshly recomputed one appears on the next reload.
This is the same stale-while-revalidate trade-off already accepted for
the private-cache and `cacheMaxMemorySize: 0` dev optimizations, which
likewise favor a fast reload over serving a value these configurations
would not otherwise cache at all. For custom handlers the re-warm
re-executes the function and writes through to the backing.

Unlike the private-cache case, we deliberately do not force a dynamic
cache life here, because forcing `revalidate: 0` would leak into the
cache life propagated to an enclosing `'use cache'` and trip the
nested-dynamic error with the wrong message. And unlike the size-0 case,
keeping the resolved life alone is not enough, because a short `expire`
is exactly what makes the dev handler drop the entry, which is why the
minimum retention is needed. Because the dev front handler now enforces
that minimum, the tiered handler can no longer evict a stale front entry
by writing `expire: 0` (the minimum would keep it alive), so
`toExpiredEntry` now writes a negative `expire`, which the default
handler recognizes as an eviction sentinel and reports as missing
regardless of the retention minimum. This mirrors the existing
`revalidate = -1` convention, though a negative `expire` means the entry
is dropped rather than served-but-revalidated.

Everything is gated on `process.env.__NEXT_DEV_SERVER`, so production
behaves exactly as before: short-`expire` values keep their real cache
life, and configured handlers are used directly. New development tests
cover the built-in and custom-handler cases, asserting that a cache-miss
navigation shows the Suspense fallback while a cache-hit one does not,
and that a reload serves the cached value yet converges to a fresh one.


---

<sub>Stack created with <a
href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a
href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>
2026-07-02 11:26:08 +02:00

102 lines
4.0 KiB
TypeScript

import { nextTestSetup } from 'e2e-utils'
import { retry, waitFor } from 'next-test-utils'
describe('use-cache-custom-handler-dev', () => {
const { next, skipped } = nextTestSetup({
files: __dirname,
})
if (skipped) {
return
}
it('shows the Cold cache badge on a cold load but not on a warm reload through a slow custom handler', async () => {
const browser = await next.browser('/cold-badge')
// Cold load: the cache misses and fills while streaming, so the cold
// verdict is reported and (once the dev overlay's socket connects) the
// badge appears.
await browser.elementById('value')
await retry(async () => {
expect(await browser.hasElementByCss('[data-cold-cache-badge]')).toBe(
true
)
})
expect(
await browser
.elementByCss('[data-cold-cache-badge] [data-issues-open]')
.text()
).toBe('Cold cache')
// Warm reload: the built-in front serves the entry in a microtask, so the
// read isn't pending at a staged render boundary and no cold verdict is
// reported - even though the custom handler's own `get` is slow. Without
// the front, that slow `get` would make this a phantom cold miss. An
// absence can't be retried on, so wait out the replay window, then assert
// it never appeared.
await browser.refresh()
await browser.elementById('value')
await waitFor(500)
expect(await browser.hasElementByCss('[data-cold-cache-badge]')).toBe(false)
})
it('serves a short-expire value warm through a custom handler and re-warms it on each reload', async () => {
const browser = await next.browser('/expire-zero', {
waitHydration: false,
// Do not wait for "load"; inspect the page as it streams in.
waitUntil: 'commit',
})
// Cold load: the custom handler misses, the value generates and is written
// through to both the backing handler and the dev-only in-memory front. We
// wait for the streamed-in element without waiting for "load".
const coldValue = await browser
.elementByCss('#value', { waitUntil: false })
.text()
expect(coldValue).toBeDateString()
// Warm reload: served fast from the front, whose minimum retention keeps
// the short-`expire` entry. The custom handler's slow `get` isn't on the
// critical path, and the short `expire` no longer evicts the front entry on
// every read, so the same cached value shows.
await browser.refresh({ waitUntil: 'commit' })
expect(
await browser.elementByCss('#value', { waitUntil: false }).text()
).toBe(coldValue)
// Each warm reload re-executes the cache function and writes through to the
// backing, so reloads converge to a fresh value.
await retry(async () => {
await browser.refresh()
expect(await browser.elementById('value').text()).not.toBe(coldValue)
})
})
it('stops serving a front-cached entry after the backing cache is purged out-of-band', async () => {
const browser = await next.browser('/purged')
// Cold load: the custom handler misses, the value generates and is written
// through to both the backing handler and the dev-only in-memory front.
const coldValue = await browser.elementById('value').text()
// Warm reload: served from the front. Its real cache life keeps it
// shell-eligible, so the same cached value shows immediately.
await browser.refresh()
expect(await browser.elementById('value').text()).toBe(coldValue)
// Purge the backing handler out-of-band. The front still holds the entry,
// so it would keep serving the stale value indefinitely if the tiered
// handler didn't evict it.
await next.fetch('/purge')
// The reconcile can only evict the front entry once it has observed the
// backing miss, which happens one read after the purge. So reloads converge
// on a freshly generated value instead of serving the purged front copy
// forever.
await retry(async () => {
await browser.refresh()
expect(await browser.elementById('value').text()).not.toBe(coldValue)
})
})
})