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>