mirror of
https://github.com/vercel/next.js.git
synced 2026-09-20 02:25:18 +08:00
d792fcf5e7
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).