Skip to content

Keep the dev validation worker alive across HMR updates - #96988

Open
unstubbable wants to merge 3 commits into
canaryfrom
hl/fix-missing-code-frame
Open

Keep the dev validation worker alive across HMR updates#96988
unstubbable wants to merge 3 commits into
canaryfrom
hl/fix-missing-code-frame

Conversation

@unstubbable

@unstubbable unstubbable commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

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.

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 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 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-default and hmr-rsc-cancellation. Run with BENCH_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-pool subscribes 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.

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.
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Stats from current PR

🔴 1 regression

Metric Canary PR Change Trend
node_modules Size 553 MB 553 MB 🔴 +161 kB (+0%) ▇████
📊 All Metrics
📖 Metrics Glossary

Dev Server Metrics:

  • Listen = TCP port starts accepting connections
  • First Request = HTTP server returns successful response
  • Cold = Fresh build (no cache)
  • Warm = With cached build artifacts

Build Metrics:

  • Fresh = Clean build (no .next directory)
  • Cached = With existing .next directory

Change Thresholds:

  • Time: Changes < 50ms AND < 10%, OR < 2% are insignificant
  • Size: Changes < 1KB AND < 1% are insignificant
  • All other changes are flagged to catch regressions

⚡ Dev Server

Metric Canary PR Change Trend
Cold (Listen) 814ms 812ms ▁▂▂▂▂
Cold (Ready in log) 786ms 782ms ▁▁▂▂▁
Cold (First Request) 1.293s 1.284s ▁▂▂▂▁
Warm (Listen) 813ms 811ms ▁▂▂▂▁
Warm (Ready in log) 788ms 784ms ▁▂▂▂▁
Warm (First Request) 1.291s 1.269s ▁▃▂▂▁
📦 Dev Server (Webpack) (Legacy)

📦 Dev Server (Webpack)

Metric Canary PR Change Trend
Cold (Listen) 811ms 762ms ████▁
Cold (Ready in log) 772ms 768ms ▃▄▆▄▂
Cold (First Request) 3.329s 3.313s ▁▁▃▃▁
Warm (Listen) 811ms 811ms █████
Warm (Ready in log) 774ms 774ms ▄▅▇▄▄
Warm (First Request) 3.335s 3.366s ▂▃▆▄▂

⚡ Production Builds

Metric Canary PR Change Trend
Fresh Build 5.693s 5.680s ▁▃▃▂▁
Cached Build 2.920s 2.930s ▁▄▃▃▁
📦 Production Builds (Webpack) (Legacy)

📦 Production Builds (Webpack)

Metric Canary PR Change Trend
Fresh Build 24.304s 23.829s ▂▃█▃▁
Cached Build 24.221s 23.730s ▃▃▅▄▁
node_modules Size 553 MB 553 MB 🔴 +161 kB (+0%) ▇████
📦 Bundle Sizes

Bundle Sizes

⚡ Turbopack

Client

Main Bundles
Canary PR Change
00vs_o_mu20cj.js gzip 157 B N/A -
050icza-xjz0i.js gzip 5.73 kB N/A -
06bbynx6pme58.js gzip 65.6 kB N/A -
07jdby0ue616s.js gzip 450 B N/A -
09p7vxghl2k-j.js gzip 71.5 kB N/A -
0anzicvq0iwy6.js gzip 46.7 kB N/A -
0bjdc8muo74n5.js gzip 8.71 kB N/A -
0cz1d0mv5g_q7.js gzip 39.4 kB 39.4 kB
0q9myc6hpg463.js gzip 159 B N/A -
0rci1f3or1a19.js gzip 13.3 kB N/A -
0wi6-onlatv0z.js gzip 157 B N/A -
1_2x714--ii1i.js gzip 8.76 kB N/A -
1-3y752pkth5-.js gzip 10 kB N/A -
1elt1qium-r2m.css gzip 115 B 115 B
1hy0e5cihni_x.js gzip 7.54 kB N/A -
1ob24znun3v3f.js gzip 156 B N/A -
1tf1phijqlx9j.js gzip 220 B 220 B
1uzabyd1120a1.js gzip 8.71 kB N/A -
2-ufv8lc-g7gg.js gzip 10.6 kB N/A -
21kmjy_10x14f.js gzip 8.81 kB N/A -
22alyybqz5_09.js gzip 3.57 kB N/A -
22nqfrqbvmbpc.js gzip 162 B N/A -
28dhc6t85q1_p.js gzip 8.78 kB N/A -
2b8oex9sn1lty.js gzip 158 B N/A -
2f-ilvczue-tp.js gzip 9.46 kB N/A -
2f1u17u5c8iny.js gzip 8.79 kB N/A -
2ikltg_8iegxw.js gzip 10.3 kB N/A -
2jnw_mbvnay89.js gzip 157 B N/A -
2vxi673cz1-t4.js gzip 8.79 kB N/A -
2xll-xpdajhz0.js gzip 169 B N/A -
3_vkwcvy6c1on.js gzip 161 B N/A -
35v9ti6hzvb3d.js gzip 154 B N/A -
38-q43pzktqhs.js gzip 1.46 kB N/A -
3as3skuap9kll.js gzip 157 B N/A -
3mrwxzed0ylgv.js gzip 13.1 kB N/A -
3nbojhxiy1qv_.js gzip 13.7 kB N/A -
3xx08ukqns_6j.js gzip 156 B N/A -
41tg7ge9wtmne.js gzip 157 B N/A -
41u5s3oe2-erp.js gzip 2.29 kB N/A -
445s_9hf8o7ao.js gzip 8.76 kB N/A -
turbopack-1l..-5yv.js gzip 3.73 kB 3.73 kB
0-6bcj16ji2wf.js gzip N/A 10.6 kB -
01eyb-qjob6ki.js gzip N/A 155 B -
02nfvgw02u1hm.js gzip N/A 154 B -
03xplb0u3io-8.js gzip N/A 154 B -
07ryk0jced-sc.js gzip N/A 8.78 kB -
0f_3efx7usi39.js gzip N/A 157 B -
0j64ls9v56hnd.js gzip N/A 3.56 kB -
0jwho9fkrb_t3.js gzip N/A 2.29 kB -
0qumh_ssc14hn.js gzip N/A 71.5 kB -
0roh390ijzxa5.js gzip N/A 8.75 kB -
0s7ldmg9glcmx.js gzip N/A 46.7 kB -
0vex9w55ursqj.js gzip N/A 8.79 kB -
139pc8f53wwpv.js gzip N/A 155 B -
171fdo7-vntgx.js gzip N/A 168 B -
17oe55cu76cd7.js gzip N/A 450 B -
1drww5xikb-c-.js gzip N/A 9.46 kB -
1g60xde_dv17t.js gzip N/A 8.79 kB -
1uzv47btzam64.js gzip N/A 5.73 kB -
1vy7n7wxv_rh5.js gzip N/A 8.81 kB -
2-0i7pl900-ou.js gzip N/A 8.71 kB -
2-kcbngm7ik7y.js gzip N/A 8.75 kB -
253yej48lw0y2.js gzip N/A 154 B -
27r68x9xw5m1c.js gzip N/A 158 B -
29p5-xa4jmdhg.js gzip N/A 13.7 kB -
2jwfwux5a6-99.js gzip N/A 161 B -
2kf6m69v6n3sf.js gzip N/A 155 B -
2lpk5_hknut8q.js gzip N/A 13.1 kB -
2ugc69z0t0ypz.js gzip N/A 1.46 kB -
35v-knu9va3iz.js gzip N/A 150 B -
36dn8i-_3dnq5.js gzip N/A 10 kB -
3enroerykvo9c.js gzip N/A 155 B -
3fljpmwcjxqhx.js gzip N/A 10.3 kB -
3gz44skqdlsrb.js gzip N/A 8.71 kB -
3oslhuq9luvp2.js gzip N/A 7.55 kB -
3w7qb6q5gmba_.js gzip N/A 65.6 kB -
3y9tus7kb5su0.js gzip N/A 13.3 kB -
41eqd-1bupn1g.js gzip N/A 156 B -
Total 401 kB 401 kB ✅ -13 B

Server

Middleware
Canary PR Change
middleware-b..fest.js gzip 1.06 kB 1.06 kB
Total 1.06 kB 1.06 kB
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 882 B 877 B
Total 882 B 877 B ✅ -5 B
Build Cache
Canary PR Change
00000001.sst gzip 13.3 MB 12.1 MB 🟢 1.13 MB (-8%)
00000002.sst gzip 14.4 MB 12.7 MB 🟢 1.68 MB (-12%)
00000003.sst gzip 13.8 MB 14.4 MB 🔴 +588 kB (+4%)
00000004.sst gzip 13.6 MB 15.8 MB 🔴 +2.23 MB (+16%)
00000005.sst gzip 10.2 MB 2.81 MB 🟢 7.41 MB (-73%)
00000006.sst gzip 2.81 MB 10.2 MB 🔴 +7.38 MB (+263%)
00000007.sst gzip 59 B 59 B
00000008.meta gzip 89 B 89 B
00000009.meta gzip 298 kB 298 kB
00000010.meta gzip 298 kB 298 kB
00000011.meta gzip 298 kB 298 kB
00000012.sst gzip 51.5 kB 51.8 kB
00000013.sst gzip 1.89 MB 1.86 MB 🟢 35.2 kB (-2%)
00000014.sst gzip 59 B 59 B
00000015.meta gzip 116 B 116 B
00000016.meta gzip 325 kB 325 kB
00000017.meta gzip 405 kB 405 kB
00000018.sst gzip 52.2 kB 52.7 kB
00000019.sst gzip 1.41 MB 1.4 MB 🟢 15.8 kB (-1%)
00000020.sst gzip 59 B 59 B
00000021.meta gzip 116 B 116 B
00000022.meta gzip 325 kB 325 kB
00000023.meta gzip 371 kB 371 kB
00000024.sst gzip 52.4 kB 52.8 kB
00000025.sst gzip 1.41 MB 1.4 MB 🟢 15.3 kB (-1%)
00000026.sst gzip 59 B 59 B
00000027.meta gzip 116 B 116 B
00000028.meta gzip 325 kB 325 kB
00000029.meta gzip 371 kB 371 kB
00000030.sst gzip 51.5 kB 52.1 kB 🔴 +633 B (+1%)
00000031.sst gzip 1.41 MB 1.4 MB 🟢 15.8 kB (-1%)
00000032.sst gzip 59 B 59 B
00000033.meta gzip 116 B 116 B
00000034.meta gzip 325 kB 325 kB
00000035.meta gzip 371 kB 371 kB
00000036.sst gzip 52.3 kB 52.6 kB
00000037.sst gzip 1.41 MB 1.4 MB 🟢 15.3 kB (-1%)
00000038.sst gzip 59 B 59 B
00000039.meta gzip 116 B 116 B
00000040.meta gzip 325 kB 325 kB
00000041.meta gzip 371 kB 371 kB
CURRENT gzip 24 B 24 B
LOG gzip 673 B 661 B 🟢 12 B (-2%)
Total 80.4 MB 80.2 MB ✅ -115 kB

📦 Webpack

Client

Main Bundles
Canary PR Change
3322-HASH.js gzip 65.7 kB N/A -
4191.HASH.js gzip 169 B N/A -
7920-HASH.js gzip 4.67 kB N/A -
9784-HASH.js gzip 5.63 kB N/A -
b1ad9f4c-HASH.js gzip 63.2 kB N/A -
framework-HASH.js gzip 59.7 kB 59.7 kB
main-app-HASH.js gzip 253 B 253 B
main-HASH.js gzip 40.1 kB 40.1 kB
webpack-HASH.js gzip 1.68 kB 1.68 kB
3577.HASH.js gzip N/A 168 B -
578-HASH.js gzip N/A 66.4 kB -
8590-HASH.js gzip N/A 5.61 kB -
9750-HASH.js gzip N/A 4.68 kB -
a8984546-HASH.js gzip N/A 63.2 kB -
Total 241 kB 242 kB ⚠️ +654 B
Polyfills
Canary PR Change
polyfills-HASH.js gzip 39.4 kB 39.4 kB
Total 39.4 kB 39.4 kB
Pages
Canary PR Change
_app-HASH.js gzip 194 B 193 B
_error-HASH.js gzip 181 B 182 B
css-HASH.js gzip 334 B 331 B
dynamic-HASH.js gzip 1.81 kB 1.81 kB
edge-ssr-HASH.js gzip 255 B 253 B
head-HASH.js gzip 349 B 351 B
hooks-HASH.js gzip 382 B 384 B
image-HASH.js gzip 581 B 582 B
index-HASH.js gzip 260 B 259 B
link-HASH.js gzip 2.48 kB 2.48 kB
routerDirect..HASH.js gzip 317 B 318 B
script-HASH.js gzip 384 B 386 B
withRouter-HASH.js gzip 316 B 315 B
1afbb74e6ecf..834.css gzip 106 B 106 B
Total 7.95 kB 7.96 kB ⚠️ +4 B

Server

Edge SSR
Canary PR Change
edge-ssr.js gzip 129 kB 128 kB
page.js gzip 294 kB 295 kB
Total 423 kB 423 kB ⚠️ +427 B
Middleware
Canary PR Change
middleware-b..fest.js gzip 618 B 617 B
middleware-r..fest.js gzip 156 B 156 B
middleware.js gzip 45.7 kB 45.3 kB
edge-runtime..pack.js gzip 842 B 842 B
Total 47.3 kB 46.9 kB ✅ -352 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 717 B 718 B
Total 717 B 718 B ⚠️ +1 B
Build Cache
Canary PR Change
0.pack gzip 4.8 MB 4.8 MB
index.pack gzip 123 kB 122 kB
index.pack.old gzip 122 kB 122 kB
Total 5.04 MB 5.04 MB ✅ -5.07 kB

🔄 Shared (bundler-independent)

Runtimes
Canary PR Change
app-page-exp...dev.js gzip 375 kB 375 kB
app-page-exp..prod.js gzip 207 kB 207 kB
app-page-tur...dev.js gzip 374 kB 374 kB
app-page-tur..prod.js gzip 207 kB 207 kB
app-page-tur...dev.js gzip 371 kB 371 kB
app-page-tur..prod.js gzip 205 kB 205 kB
app-page.run...dev.js gzip 371 kB 371 kB
app-page.run..prod.js gzip 205 kB 205 kB
app-route-ex...dev.js gzip 83.3 kB 83.3 kB
app-route-ex..prod.js gzip 56.4 kB 56.4 kB
app-route-tu...dev.js gzip 83.3 kB 83.3 kB
app-route-tu..prod.js gzip 56.4 kB 56.4 kB
app-route-tu...dev.js gzip 82.9 kB 82.9 kB
app-route-tu..prod.js gzip 56.2 kB 56.2 kB
app-route.ru...dev.js gzip 82.8 kB 82.8 kB
app-route.ru..prod.js gzip 56.2 kB 56.2 kB
dev-validati...dev.js gzip 133 kB 134 kB
dev-validati...dev.js gzip 133 kB 134 kB
dev-validati...dev.js gzip 131 kB 131 kB
dev-validati...dev.js gzip 131 kB 131 kB
dist_client_...dev.js gzip 324 B 324 B
dist_client_...dev.js gzip 326 B 326 B
dist_client_...dev.js gzip 318 B 318 B
dist_client_...dev.js gzip 317 B 317 B
pages-api-tu...dev.js gzip 46 kB 46 kB
pages-api-tu..prod.js gzip 34.3 kB 34.3 kB
pages-api.ru...dev.js gzip 46 kB 46 kB
pages-api.ru..prod.js gzip 34.3 kB 34.3 kB
pages-turbo....dev.js gzip 54.8 kB 54.8 kB
pages-turbo...prod.js gzip 39.9 kB 39.9 kB
pages.runtim...dev.js gzip 54.8 kB 54.8 kB
pages.runtim..prod.js gzip 39.9 kB 39.9 kB
server.runti..prod.js gzip 67 kB 67 kB
use-cache-pr...dev.js gzip 72.3 kB 72.3 kB
use-cache-pr...dev.js gzip 72.4 kB 72.4 kB
use-cache-pr...dev.js gzip 70.6 kB 70.6 kB
use-cache-pr...dev.js gzip 70.6 kB 70.6 kB
Total 4.1 MB 4.11 MB ⚠️ +2.63 kB
📝 Changed Files (4 files)

Files with changes:

  • dev-validati..ntime.dev.js
  • dev-validati..ntime.dev.js
  • dev-validati..ntime.dev.js
  • dev-validati..ntime.dev.js
View diffs
dev-validati..ntime.dev.js

Diff too large to display

dev-validati..ntime.dev.js

Diff too large to display

dev-validati..ntime.dev.js

Diff too large to display

dev-validati..ntime.dev.js

Diff too large to display

📎 Tarball URL
https://vercel-packages.vercel.app/next/commits/b2ab26884eecc3aa4d4a1845dce0598a34c49abc/next

Commit: b2ab268

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Tests Passed

Commit: 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
unstubbable force-pushed the hl/fix-missing-code-frame branch from 050f88e to b2ab268 Compare August 9, 2026 13:16
@unstubbable
unstubbable marked this pull request as ready for review August 9, 2026 13:43
@unstubbable
unstubbable requested a review from timneutkens August 9, 2026 18:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant