Files
dan d792fcf5e7 Fix use cache over- and under-invalidation in dev (#96235)
In dev, `"use cache"` entries are keyed by an HMR refresh hash so that
edits invalidate cached data.

This hash was managed wrong in both directions:

- webpack refreshed the hash at moments when no code changed: on dev
server startup, and when a page file was added or removed. Each refresh
threw away all cached data for nothing.

- The hash never distinguished one dev server run from another. That
matters with a custom cache handler that persists entries: a restarted
server would serve data cached by the previous run, whose code may have
changed while the server was down.

To fix this:

- Adding or removing a page no longer refreshes webpack's hash. Edits
and env file changes still do.
- The hash now includes a random per-run value.

This fixes the `cache-components-dev-warmup` flakes.

## How to reproduce

Here's the first problem in action, on webpack. Render a cached
`Math.random()` on a page, then create an unrelated page file:

```
value: 0.199426
value: 0.199426
$ echo '...' > app/unrelated/page.tsx    # never visited
value: 0.990973                          # cached entry gone a second later
```

Adding the page threw the cached data away.

Starting the dev server throws it away too, once, shortly after boot.
That one is a race: normally the cache is still empty when it fires, so
nobody notices — but if the first page request gets in before it,
everything that request cached is thrown away and the next reload is
silently cold. That race is the cause of the `dev-warmup` flakes on
loaded machines, and it's how I found this.

The hash refresh is specific to webpack, so Turbopack doesn't lose
cached data this way. On Turbopack, these events instead clear the
server's module cache. Whether the next render picks up a fresh module
instance is a race, so module-scope state can reset. Turbopack also has
a separate bug in this area — it advances its hash once per
change-subscription emission, and an added or removed page can produce
an emission without any edit — which #96248, stacked on this PR, fixes
by deriving the hash from compiled output.

## Underlying cause

Next computes a "client router filter" (a bloom filter over the app's
routes, used by the client-side router), and a filter change is treated
like an env file change. An env change means cached data might be stale,
so the reaction is strong: the browser is told to re-fetch, and on
webpack the hash is refreshed, which discards every cached entry. But
the filter is derived from the routes, so it "changes" whenever you add
or remove a page — and once right after startup, because the first
computation has nothing to compare against. Neither makes cached data
stale.

The second problem is the opposite: dev server runs share cache keys.
The hash starts from the same value on every run (unset on webpack,
`"0"` on Turbopack), so a restarted server generates the same keys as
the old one. With the default in-memory handler that's moot, but a
custom cache handler that persists entries will serve entries written by
the previous run. webpack can also hit this after edits: its hash is
derived from compiled output, so re-applying an edit that a previous run
also made lands on that run's keys.

Judging by the code (?), dev cache reuse was only ever meant to last a
single run: every dev cache mechanism is in-memory, and #75474 takes it
for granted that restarting the dev server clears cached content. So I
treat cross-run reuse as a bug. Not sure I got this constraint right —
in particular, #75474 deliberately made reverting an edit reuse
previously cached data, and this PR keeps that working within a run but
stops it from working across runs.

## Fix

Two changes:

- A filter change is no longer treated as an env change. It still
updates the compiled-in filter value (the part that's actually needed),
but it no longer refreshes the hash, and on Turbopack it no longer wipes
the server's module cache. Real env file changes still trigger the full
reaction.

- The hash now starts from a random per-run value on both bundlers. So
it exists before the first request and never matches another run's keys.

The per-run value is deliberately conservative: it also prevents reuse
across restarts where *nothing* changed. Allowing exactly that reuse
safely needs keys derived from the cached function's implementation (the
existing TODO in `use-cache-wrapper.ts`), which would replace the
per-run value.

## Test plan

The first commit adds five tests
(`test/development/app-dir/cache-components-spurious-cache-invalidation`).
They run on both bundlers, except the module-state test, which is
Turbopack-only: on webpack, adding a page recompiles the server bundle,
which replaces the module instances regardless. So Turbopack runs five
and webpack four.

Before the fix, these fail:

```
webpack:    ✕ discards use cache entries when a previous run made the same edit
            ✕ discards use cache entries across dev server restarts   (most runs — see below)
Turbopack:  ✕ keeps module state when an unrelated page is added
            ✕ discards use cache entries across dev server restarts
```

The webpack restart test does not fail every run. The start-time refresh
replaces the unset hash with a content-derived one at an unpredictable
moment. When one server run caches before its refresh and the other
reads after its own, the keys don't collide, so there is no cross-run
reuse for the test to catch that run.

The webpack side of the over-invalidation fix is covered by the refetch
count below rather than by a cached-value test: the equivalent test on
Turbopack depends on emission timing, so it lands with #96248, which
removes that dependence.

The same commit also tightens the refetch count in the test from #96250
below: since a page add is no longer an env change, adding a page
refetches an open tab once instead of twice, on both bundlers. Before
the fix, both bundlers fail that expectation (`Expected: 1, Received:
2`).

To reproduce the flake this PR fixes, run the dev-warmup suites under
CPU load (`for i in $(seq 1 12); do yes > /dev/null & done`). Before the
fix, 6–8 of the 11 tests fail per run on webpack (`Prerender` flips to
`Server` on warm reloads). After the fix, all 11 pass. Turbopack passes
before and after. Also still green: `typed-env` (env definitions were
previously only written at boot via the false positive this removes; now
they're written on the initial scan explicitly), `env-config`,
`use-cache-custom-handler`, and `pages-to-app-routing` (it exercises the
router filter update path).
2026-08-06 14:25:52 +01:00
..