Commit Graph

12 Commits

Author SHA1 Message Date
Hendrik Liebau 9da15443c1 Gate the dev Cold cache badge behind an experimental flag (#95169)
The cold cache dev indicator added in #94611 surfaces a load that filled
an empty cache while streaming. After the load settles it leaves a
persistent "Cold cache" badge in the corner. That badge is visually too
loud and disruptive in its current form, so we want to keep iterating on
its UI and UX before showing it to everyone. Until then it should be off
by default.

This change puts that persistent badge behind a new, default-off
`experimental.coldCacheBadge` flag, plumbed to the dev overlay as
`process.env.__NEXT_EXPERIMENTAL_COLD_CACHE_BADGE` via the define
plugin. The transient "Rendering (cold cache)" pill shown during a
navigation is intentionally kept regardless of the flag: it is transient
and clears itself once the blocking navigation transition has committed,
so it stays valuable without being disruptive. Only the permanent badge
was the problem. The pre-existing "Cache disabled" (bypass) badge and
the DevTools menu's cold-cache entry are also unaffected.

The gate lives in `computeIntent`, so when the flag is off a cold load
resolves to no badge and the indicator follows its existing empty-badge
render path. Storybook forces the flag on through its `env` hook so the
badge stories remain the surface for iterating on the design, and every
test suite that asserts on the badge opts into the flag so none of them
regress while it is disabled by default.
2026-06-25 19:53:59 +02:00
Hendrik Liebau 763d85b833 Scope the Cold cache indicator in dev to shell cache misses (#94911) 2026-06-18 07:52:46 +02:00
Hendrik Liebau 5b0aa04b10 Persist 'use cache: private' entries in dev (#94694)
Private caches were never stored in a cache handler, so every reload in
`next dev` re-ran them from scratch and they registered as a cache miss
on each load. This change persists `'use cache: private'` entries in
development in a dedicated built-in in-memory handler so that warm
reloads are fast. The handler is gated on
`process.env.__NEXT_DEV_SERVER` and is kept out of the kind-keyed
handlers map so it can never be replaced by a user-configured `default`
handler: private cache entries can hold data specific to the incoming
request (for example, derived from its cookies or headers) and must
never reach a remote or otherwise persistent handler. Production keeps
private caches non-persisted as before.

The coarse cache-handler key for a dev private cache is scoped by the
request's cookies and headers so entries for requests with different
request data don't collide. It excludes Next-internal cookies that
aren't application data (the HMR refresh hash, already part of the cache
key, and the instant-navigation cookie, which toggles while a navigation
lock is held) and the transport and content-negotiation headers that
vary between otherwise-equivalent requests (a browser reload adds
`cache-control`, and `accept` and `sec-fetch-*` differ between an HTML
navigation and an RSC request). Keying by only the cookies and headers a
cache actually reads is left as a follow-up; read root params are
already tracked that way, the same as for public caches. The cache life
is forced to `revalidate: 0` with a 5-minute `expire`, so each read
serves the stale entry immediately and warms a fresh one in the
background through the existing stale-while-revalidate path.

Cross-request deduplication now applies to private caches in development
too, so concurrent requests with identical request data share a single
fill; it remains skipped in production where request-specific data must
not be shared across requests. To make this deterministic,
`saveToCacheHandler` resolves the metadata a cross-request joiner awaits
only after the entry has been written to the handler, so the joiner
finds it when re-reading its recomputed key. This closes a pre-existing
race in that path, present for public caches too but never surfaced by
their cross-request test, where the joiner's metadata could resolve
before the handler write had landed.

The defensive invariant that rejected reading a private entry from a
handler is removed: it only existed to narrow `cacheContext.kind` for a
code path that no longer needs it, and dev now legitimately reads
persisted private entries while production never registers a private
handler to read from.
2026-06-11 22:30:08 +02:00
Hendrik Liebau a9e076d5af Serve stale 'use cache' entries in the dev server until they expire (#94662)
The default in-memory cache handler drops an entry once it goes stale
(past its `revalidate` time) rather than serving it: in-memory entries
are fragile, so warming a replacement in the background isn't worth it
when it's likely to be evicted before it's used, and we don't want to
keep reusing a stale entry for too long either. With this change the dev
server (`next dev`) makes an exception and serves the stale entry until
it actually expires, using `expire` as the drop threshold instead of
`revalidate` and relying on the wrapper's existing
stale-while-revalidate path to warm a fresh entry in the background.
Production (`next start`) and `next build --debug-prerender` keep the
previous drop-at-`revalidate` behavior, so the change is gated on
`process.env.__NEXT_DEV_SERVER`. The tag-based discard is preserved, so
`revalidateTag` still invalidates in dev.

This change has two motivations. It keeps the dev inner loop fast,
turning a warm reload of a short-lived cache into an immediate hit
instead of a cold miss that re-runs the cache. It is also the foundation
for upcoming dev-only caching changes that depend on the handler serving
stale entries: persisting `'use cache: private'` entries in dev, and
keeping `cacheMaxMemorySize: 0` fast in dev. Both serve a previous value
while warming a fresh one in the background, which only works once the
handler stops dropping stale entries in dev.

This also lets us drop the workarounds that several Cache Components dev
fixtures used to isolate themselves from the handler dropping the entry
at `revalidate`. The short-lived fixtures go back to
`cacheLife('seconds')` and the short-stale fixtures drop their long
`revalidate`. Their warm reloads are now stale hits served by the dev
server, which is the behavior these tests are meant to exercise and
which removes the short-lived hit/miss flakiness at its source.

A new `use-cache` test pins the behavior directly: in dev a reload after
`revalidate` serves the previous value immediately and warms a fresh one
in the background, while a `next start` build of the same fixture
re-runs the cache and shows a fresh value.
2026-06-11 13:21:55 +02:00
Hendrik Liebau 073bb783ea Stage short-lived 'use cache' entries correctly in dev (#94645)
When the streaming dev render defers a short-lived `'use cache'` entry
to a later stage (a `revalidate` of zero or a short `expire` excludes it
from the static shell, a short `stale` time excludes it from the runtime
prefetch shell), it previously left the cache signal read open. At a
staged rendering task boundary that open read looked like a pending
cache read, so even a warm handler hit was counted as a cache miss,
lighting the cold-cache indicator and routing validation through the
background warm render. We now end the cache signal read as soon as such
an entry is deferred, the same as the prerender path already does, and
serve the buffered value through a plain stream instead of one tracked
by the cache signal, so a warm short-lived hit is no longer mistaken for
a miss. A deferred entry can still turn out to be stale and need
regenerating (for instance, a cache handler may return a past-`expire`
entry), so the regenerate path re-begins the read before generating,
keeping the signal balanced against the regeneration's own end of the
read rather than over-decrementing it.

The runtime-prefetch exclusion for a short stale time must only apply
when the render actually produces the runtime prefetch shell, which is a
property of the request rather than the cache entry. An initial HTML
load, a plain client navigation, and an HMR refresh all produce the
static shell, where a short-stale entry stays, mirroring the static
prerender. A client navigation into a runtime-prefetch route produces
the runtime prefetch shell, where it is excluded, mirroring the
`prerender-runtime` prerender. We thread the render's shell stage onto
the request store, and onto the warm validation render that mirrors it,
and gate the stale-based deferral on `shellStage === Runtime`. This
preserves the build-time asymmetry where a short stale time is omitted
from the runtime prefetch but not from the static shell.

A new `short-stale-cache` fixture and warmup test exercise this
asymmetry directly: a `'use cache'` entry with a short stale time but a
long expire resolves in the static stage on an initial load and on a
navigation without runtime prefetch, and only resolves dynamically on a
navigation into a runtime-prefetch route. The previously skipped
short-lived warmup case is re-enabled, and the cache-indicator
short-lived case now asserts the cold-cache badge on a cold load but not
on a warm reload. Those fixtures use a long `revalidate` so the entry
stays a fresh hit for the duration of the test, isolating them from the
in-memory handler dropping the entry at `revalidate`; that workaround
can be dropped once the cache handler serves stale entries in dev.
2026-06-10 18:12:34 +02:00
Hendrik Liebau 96d7526ce3 Add a cold cache dev indicator (#94611)
When Cache Components is enabled, a `next dev` load that streams while
filling an empty cache is not representative of production: cached
content streams in as it is computed rather than being served instantly,
and React's DevTools cannot accurately show what would normally suspend.
This surfaces that state in the dev indicator. While a client navigation
is pending the rendering pill is colored and labeled by the cache state
(teal "Rendering" normally, orange "Rendering (cold cache)" when the
render hit an empty cache, and orange "Rendering (cache disabled)" when
caches were bypassed), and once the load settles a cold or bypassed load
leaves a persistent, dismissible orange badge ("Cold cache" or "Cache
disabled") with an info panel that explains why the load was not
production-like and suggests reloading once the caches are warm.



https://github.com/user-attachments/assets/9be2c35a-3a36-47d7-8803-6e284c332a4b


The indicator's displayed state is now owned by a single state machine,
`useIndicatorDisplay`, rather than being composed from a debounce
(`useDebouncedValue`) and a delayed render (`useDelayedRender`) whose
delays compounded and were hard to reason about. It models the indicator
as an explicit set of phases (idle, entering, pill, exiting, badge)
driven by the raw compiling, rendering, and cache-status signals, and it
hands the rendering pill off to the persistent badge in a single commit
so the indicator never collapses to the bare logo between them. It also
unifies the pre-existing "Cache disabled" badge with the new cold-cache
state so both flow through one path (a navigation shows "Rendering
(cache disabled)" and then settles into the badge). The Cold cache badge
tracks the most recent load, so a later navigation that settles warm
clears it.

The rework also collapses the timing into a single 200ms window for both
showing and hiding, matching the transition used elsewhere in the dev
overlay, and relabeling between active states (for example "Compiling"
to "Rendering", or the flip to the cold-cache color) is now immediate.
This is intentional: the previous debounce held a label on screen past
the moment its underlying state ended, so "Compiling" could linger after
the compile had finished and make the bundler look slower than it
actually was. The one genuine flicker the old debounce guarded against,
the pill blinking out to the bare logo when the status briefly drops
between rapid compile bursts, is still prevented by the new exit linger.

Two cases are knowingly not handled yet: a short-lived `'use cache'`
entry and a `'use cache: private'` entry both report a miss on every
load, so they show the badge even on a warm reload. These are
limitations of the current dev cache behavior rather than of the
indicator, and the tests cover them with `TODO`s that point at the
follow-up changes that will fix them.
2026-06-10 11:45:07 +02:00
Hendrik Liebau 6f4d94ac88 Stream Cache Components dev render instead of restarting on cache miss (#94457)
When Cache Components is enabled, `next dev` previously simulated a
production loading experience on every cold request. The render did a
prospective pass to detect cache misses, and on any miss it waited for
every cache to fill via `cacheSignal.cacheReady()` and then restarted
the render with warm caches before streaming anything, so the browser
saw nothing until the slowest cache had filled. Every cold load blocked
on cache population.

This change replaces the restart-on-cache-miss flow with a single
non-abandoning staged render that streams immediately and fills caches
as a side effect. On a cold load the Suspense fallbacks stream right
away and the cached content resolves as its cache fills; on a warm
reload the staged progression matches the previous no-cache-miss path.
The render is split into clearly owned pieces: `setUpStagedDevRender`
builds the staged controller, cache signal, and resume cache;
`streamStagedRenderInDev{Node,Web}` runs the streaming render and
reports a result once the stream has fully finished; and
`stagedRenderWithCachesInDev{Node,Web}` returns the stream and leaves
the validation follow-up detached so it never blocks the response.

The render advances its stages in sequential tasks, and the stream is
not handed back the instant it exists: it is held until the render has
advanced through the stage whose content belongs in the shell. That is
the static stage for initial loads, HMR refreshes, and plain
navigations, or the runtime stage for client navigations to a route with
a runtime prefetch config, whose runtime-prefetchable content the
navigation's prefetch would have settled. It never waits for the dynamic
stage. Buffering the shell before the first flush keeps the streaming
renderer from emitting a premature Suspense fallback for content that
belongs in the shell, and it mirrors production, where the static shell
(plus runtime-prefetchable content where configured) is served and the
remaining holes stream in as fallbacks.

Two internal reads that would otherwise register as synchronous IO and
wrongly force the render to the dynamic stage, the cache handler's
tag-expiry clock check and the hot reloader's module-scope dev client
id, are now read untracked: via `performance.timeOrigin +
performance.now()` like the `'use cache'` handler, and only in the
browser where the HMR connection reads it, respectively.

Cache Components rules validation now runs in that background follow-up,
once the streamed render has fully settled. `planDevValidation` inspects
the finished render and picks one of three paths: forward an invalid
dynamic usage error the streamed render already recorded and stop (for
example a request API used inside `'use cache'`); validate the streamed
render's own chunks when it neither missed caches nor hit sync IO; or,
when it did either, validate a dedicated warm-cache render instead.
Because that warm render reads the filled caches back rather than
filling them, it can surface an invalid dynamic usage error the cold
streamed render cannot, such as a nested dynamic `use cache` cache life
that propagated to a parent with no explicit `cacheLife`; that error is
forwarded and validation is skipped, just as one recorded by the
streamed render is.

Since cold loads no longer block on cache fills, the transient
cache-status indicator that reflected that wait is no longer emitted; a
follow-up will instead add an indicator that tells the user whether a
render streamed with cache misses, and so wasn't representative of
production.
2026-06-09 10:38:12 +00:00
Hendrik Liebau cd6f40c62f Add an info panel for the existing "Cache disabled" indicator (#93756)
The dev tools already showed an orange `Cache disabled` badge when
caches were bypassed in development, but offered no explanation of what
that meant or why it appeared. This adds a `Cache: Disabled` entry to
the dev tools menu (also shown only when caches are bypassed) that opens
an info panel. The panel explains the three triggers (the browser's
"Disable cache" toggle, a hard reload, or draft mode), that the loading
experience may differ from production, that React DevTools annotations
will not accurately reflect what would normally suspend, and that
Next.js cannot validate whether a navigation would be instant or
blocking while caches are bypassed.

<img width="265" height="270" alt="Screenshot 2026-05-11 at 12 14 26"
src="https://github.com/user-attachments/assets/f4ab5d25-7f20-487e-934c-dbbef7df3256"
/>

<img width="501" height="404" alt="Screenshot 2026-05-11 at 12 32 46"
src="https://github.com/user-attachments/assets/79e434fa-02d0-40e9-9f51-a6b8ffa20dcb"
/>

closes NAR-467
2026-05-11 13:18:40 +02:00
Sam Selikoff 215a08e2da Disable instant validations in draft mode (#93472)
This PR makes dev-mode cache bypass behavior consistent when `draftMode`
is enabled. Draft mode now skips Instant Insights validation the same
way a hard refresh or DevTools “Disable Cache” request does, and the
Next.js devtools badge shows the existing “Cache disabled” state for
draft-mode previews.

It also adds coverage for draft-mode cache bypass behavior in dev and
start modes, plus a devtools badge test, and updates the
`cache-bypass-in-dev` docs to explain why draft mode triggers this
state.
2026-05-06 20:59:04 +00:00
Zack Tanner 47ceda3c14 [cache components] persist cache bypass UI until it's disabled (#85190)
Previously we were only showing this during navigations when caches are
bypassed. We want to make it clearer that you are in this state and dev
might not work as intended while caches are disabled.

This will show the cache bypass status on initial load and will reset
the next time a router action occurs that doesn't bypass cache.


https://github.com/user-attachments/assets/f82a98c6-b5be-44d0-ba86-e4f7ca7010e4
2025-10-21 16:04:48 -07:00
Zack Tanner 20cf4c07da add new devtools indicator loading state (#85083)
This removes the Next logo animation/dimming and replaces it with more
explicit states. We also use this UI to show when caches are being
warmed (when using Cache Components). When caches are being bypassed in
DevTools, we also will show this in the indicator with warning text,
indicating that we cannot accurately reflect what can be statically
prerendered.



https://github.com/user-attachments/assets/665d4f8b-8c01-4def-a60e-bc4ecdd1f879
2025-10-20 12:42:43 -07:00
Sebastian "Sebbie" Silbermann f8f24aa479 Plumbing for cache indicator (#84955) 2025-10-19 17:25:50 +00:00