Keep the dev validation worker alive across HMR updates - #96988
Open
unstubbable wants to merge 3 commits into
Open
Keep the dev validation worker alive across HMR updates#96988unstubbable wants to merge 3 commits into
unstubbable wants to merge 3 commits into
Conversation
The test loads a route so that its chunk is evaluated and then edits the page while the dev server runs, which is the sequence a user hits when adding uncached data to a page that is already open. Under Turbopack the validation worker cannot resolve a source map for the updated module, so the overlay reports no source and the CLI frame points at the chunk instead of the page. The snapshots record that behind a `TODO`, beside the Webpack output that shows what both frames should look like.
The `dev-validation` fixture's routes produce no validation errors, so a run measures the validation renders and never the work that follows one: encoding the errors, and printing them with a source-mapped stack and code frame. Setting `BENCH_DEV_VALIDATION_INSIGHTS=1` now generates leaves with an uncached access below the family's heavy subtree, so validation does that work and then reports one insight per navigation. The access sits below the subtree rather than at the top of the page, because a page that suspends before it returns leaves the subtree unrendered in the validation pass. Off by default, so the numbers the README describes stay comparable.
Contributor
Stats from current PR🔴 1 regression
📊 All Metrics📖 Metrics GlossaryDev Server Metrics:
Build Metrics:
Change Thresholds:
⚡ Dev Server
📦 Dev Server (Webpack) (Legacy)📦 Dev Server (Webpack)
⚡ Production Builds
📦 Production Builds (Webpack) (Legacy)📦 Production Builds (Webpack)
📦 Bundle SizesBundle Sizes⚡ TurbopackClient Main Bundles
Server Middleware
Build DetailsBuild Manifests
Build Cache
📦 WebpackClient Main Bundles
Polyfills
Pages
Server Edge SSR
Middleware
Build DetailsBuild Manifests
Build Cache
🔄 Shared (bundler-independent)Runtimes
📝 Changed Files (4 files)Files with changes:
View diffsdev-validati..ntime.dev.jsDiff too large to display dev-validati..ntime.dev.jsDiff too large to display dev-validati..ntime.dev.jsDiff too large to display dev-validati..ntime.dev.jsDiff too large to display 📎 Tarball URLCommit: b2ab268 |
Contributor
Tests PassedCommit: b2ab268 |
Cache Components dev validation reported stack frames that pointed at build output whenever a module had been updated while the dev server ran. This affected both the static shell validation and the instant-navigation validation, since both run on the same worker. The overlay showed a raw `file:` URL and the terminal named the chunk rather than the page, and because the frame never resolved to a source position there was no code frame either, so nothing indicated which line caused the error. Turbopack's server HMR evaluates an updated module as a script of its own, named `<chunk>?<module id>` and carrying its source map inline rather than on disk, so only the isolate that ran that `eval` can resolve a frame in it. The validation worker never ran it, and the map beside the chunk describes the chunk's lines, not the running module's, so nothing the worker could reach described the frame. React then wrote the frame in its form for scripts without a source map, which encodes an already-encoded URL a second time, leaving a frame no reader reverses. The worker now mirrors what the dev server does to its own module state rather than being dropped whenever that state changes. The dev server reports each applied update, the manifest cache entries it cleared, and the paths it evicted, and the worker replays them in the same order, so its module state is the dev server's module state by construction. That leaves each updated module's inline source map in the worker's own Node.js cache, which is what makes the frame resolvable there. Not dropping the worker helps beyond the frames. Dropping it meant the next validation had to spawn a worker thread and run `loadComponents` again before it could start, and it paid that on every edit, which delayed the insight at exactly the moment the user is waiting for it. The case in the test suite that covers this went from around 870ms to around 240ms. The worker needs no coordination around a validation in flight. It runs one call at a time, in the order the calls were made, so an update is replayed before any validation requested after it, and never in the middle of one. The dev server does not hold its own updates back for a validation running in process either. Where it gives up and re-evaluates every module from disk the worker is dropped, so that case keeps the behaviour it had. The suite now also edits the same module twice and edits a module the page imports, since a worker that quietly falls behind the dev server would otherwise report a plausible but stale line.
unstubbable
force-pushed
the
hl/fix-missing-code-frame
branch
from
August 9, 2026 13:16
050f88e to
b2ab268
Compare
unstubbable
marked this pull request as ready for review
August 9, 2026 13:43
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Cache Components dev validation reported stack frames that pointed at build output whenever a module had been updated while the dev server ran. This affected both the static shell validation and the instant-navigation validation, since both run on the same worker. The overlay showed a raw
file:URL and the terminal named the chunk rather than the page, and because the frame never resolved to a source position there was no code frame either, so nothing indicated which line caused the error.Turbopack's server HMR evaluates an updated module as a script of its own, named
<chunk>?<module id>and carrying its source map inline rather than on disk, so only the isolate that ran thatevalcan resolve a frame in it. The validation worker never ran it, and the map beside the chunk describes the chunk's lines, not the running module's, so nothing the worker could reach described the frame. React then wrote the frame in its form for scripts without a source map, which encodes an already-encoded URL a second time, leaving a frame no reader reverses.The worker now mirrors what the dev server does to its own module state rather than being dropped whenever that state changes. The dev server reports each applied update, the manifest cache entries it cleared, and the paths it evicted, and the worker replays them in the same order, so its module state is the dev server's module state by construction. That leaves each updated module's inline source map in the worker's own Node.js cache, which is what makes the frame resolvable there.
The worker needs no coordination around a validation in flight. It runs one call at a time, in the order the calls were made, so an update is replayed before any validation requested after it, and never in the middle of one. The dev server does not hold its own updates back for a validation running in process either. Where it gives up and re-evaluates every module from disk the worker is dropped, so that case keeps the behaviour it had.
Not dropping the worker helps beyond the frames. Dropping it meant the next validation had to spawn a worker thread and run
loadComponentsagain before it could start, and it paid that on every edit, which delayed the insight at exactly the moment the user is waiting for it. The case in the test suite that covers this went from around 870ms to around 240ms.The simpler fix was to revive the transported errors on the main thread and print them there, where the scripts already are. It works, and it is why this PR also touches the benchmark: the fixture produced no validation errors, so nothing in the benchmark reached the error reporting at all, and the cost of moving it was invisible. With insights generated, the cost showed plainly. Printing an error costs around 218ms the first time a source map is read and about a millisecond after that, and moving it to the main thread cut the worker's p95 advantage on the heaviest route from around 15ms to between 2ms and 5ms. Mirroring the updates keeps the printing on the worker and leaves that advantage intact.
The three commits are worth reading in order. The first adds the test with the broken output snapshotted, so its snapshots deliberately record what a user saw, a frame naming the chunk with no code frame beneath it. The second is the benchmark change above. The third is the fix, and its diff turns those snapshots into resolved frames, adds cases that edit the same module twice, edit a module the page imports, and validate a route that another route's update did not touch, and rewrites the suite's header comment, which described the mechanism this replaces.
Verified on both bundlers, since the worker is gated on Turbopack and Webpack validates in process, along with
instant-validation-scheduling,instant-validation/{server-errors,parallel-slots},instant-validation-causes,instant-validation-level-defaultandhmr-rsc-cancellation. Run withBENCH_DEV_VALIDATION_INSIGHTS=1, the benchmark shows no steady-state regression: the worker column matches canary at 106ms sprite p95 against 110ms and 109ms, and keeps its margin over in-process.Two things are deliberately left out. The benchmark still cannot measure the edit case, because it never edits, so the timing above comes from a test's wall clock rather than a purpose-built measurement. And
use-cache-probe-poolsubscribes to the same invalidation and tears down the same way, which is the obvious follow-up if this holds up.One known gap remains. A worker dropped by its own failure, rather than by the dev server giving up, cannot obtain the scripts the dev server evaluated from earlier updates, so frames naming them stay unresolved until those modules change again. The validation itself is unaffected, because the worker loads the current code from disk.