Commit Graph

3 Commits

Author SHA1 Message Date
Hendrik Liebau b93ed4fa0f [test] Unflake use-cache-custom-handler-dev tests (#97187) 2026-08-12 10:58:16 +02:00
Hendrik Liebau 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
Hendrik Liebau 96e9a8e246 Make cacheMaxMemorySize: 0 and custom cache handlers fast in dev (#94784)
When Cache Components is enabled, `next dev` treats a `'use cache'`
value as a miss and renders as if the cache were empty whenever the read
does not resolve right away. Two development configurations triggered
that even for values that were already cached, so warm reloads streamed
slowly instead of serving the cached value: `cacheMaxMemorySize: 0`
replaced the built-in default handler with a no-op stub, so nothing was
cached at all, and custom cache handlers with a slow or remote `get` did
not return in time.

For the size-0 case, development now uses a real in-memory handler
instead of the no-op stub, and the `'use cache'` wrapper forces a
dynamic cache life (`revalidate: 0`, a 5-minute `expire`) for it, the
same treatment private caches already receive, so every read serves the
stale entry and re-warms a fresh one in the background. This also fixes
the dev private handler, which was sized from `cacheMaxMemorySize` and
so degraded to the no-op stub whenever `cacheMaxMemorySize: 0` was set.

Custom cache handlers keep their configured cache life, since their
backing owns it. Instead we put a fast built-in in-memory front handler
in front of the configured one through the new `TieredCacheHandler`,
which serves warm reads from the front, writes through to both tiers,
and reconciles the front against the backing in the background, evicting
the front entry when the backing no longer has it (the handler interface
has no per-key delete, so it overwrites the entry with an
already-expired copy). These dev-only handlers are kept out of the
registered handler set and merged in only where tag operations iterate,
so `revalidateTag` still reaches them.

Everything is gated on `process.env.__NEXT_DEV_SERVER`, so production is
unchanged: `cacheMaxMemorySize: 0` still caches nothing, private entries
are still never persisted, and configured handlers are used directly.
New development test suites cover the size-0 and custom-handler
behavior.
2026-06-16 18:06:28 +02:00