Skip to content

bake: dispose a self-accepting HMR boundary once per hot update - #41944

Open
robobun wants to merge 1 commit into
mainfrom
robobun/b003d276/hmr-dispose-once
Open

robobun wants to merge 1 commit into
mainfrom
robobun/b003d276/hmr-dispose-once

Conversation

@robobun

@robobun robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • In the dev server, a self-accepting module that registered import.meta.hot.dispose(cb) breaks any hot update whose payload carries two or more modules that reach it. The HMR runtime throws TypeError: mod.onDispose is not iterable in replaceModules, logs it, and does a full page reload. Page state and import.meta.hot.data are lost. Triggers: two imports saved together, editing an imported JSON file (the payload carries the importer too), a plugin that emits several records per file (Bun dev server: K.onDispose is not iterable during HMR when index.html links a stylesheet + entry has both accept() and dispose() #29418, svelte).
  • Cause: replaceModules (src/runtime/bake/hmr-module.ts:622) walks importers once per payload key with a per-key visited set, and toDispose was a plain array. A boundary reached from two keys was pushed twice. The first dispose pass ran the callbacks and set mod.onDispose = null, the second iterated null.

Fix

  • toDispose becomes a Set<HMRModule>, like toReload next to it. A boundary is disposed once per update no matter how many payload keys reach it. Which importers are walked and when a root triggers a reload is unchanged.
  • Verified: test/bake/dev/hot.test.ts ("import.meta.hot.dispose runs once when one update changes several imported files") fails on bun 1.4.3-canary and without the src/ change (TypeError: mod.onDispose is not iterable, client exits), passes with it. All of hot.test.ts and esm.test.ts pass on the debug build.

Background

  • replaceModules applies one hot update on the client. For each changed module it walks up the importer graph until it finds a boundary: a module that called import.meta.hot.accept() or used import.meta.hot.data. Boundaries are re-evaluated; their dispose callbacks run first.
  • One update payload can carry several changed modules. The dev server batches file events, and some edits re-emit importers along with the changed file.

Fixes #29418

Notes

no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/bake/dev/hot.test.ts

replaceModules walks the importers of every module in a hot update
payload with a per-module visited set, and pushed each self-accepting
boundary with dispose callbacks onto a plain array. When one update
carries two or more modules that reach the same boundary (two imports
saved together, a JSON import, a plugin that emits several records per
file), the boundary was queued twice. The first dispose pass ran its
callbacks and set onDispose to null, the second threw
"TypeError: mod.onDispose is not iterable" and the client fell back to
a full page reload, losing import.meta.hot.data and page state.

The dispose list is now a Set, like the reload list next to it.

Fixes #29418
@robobun

robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on bun 1.4.3-canary (f42e980) with the new test/bake/dev/hot.test.ts case: saving two imports of a self-accepting module that registered import.meta.hot.dispose() in one batch throws TypeError: mod.onDispose is not iterable in replaceModules and the client reloads. The test fails with src/ at main and passes with the fix on a debug build; the rest of hot.test.ts and esm.test.ts pass.

CI (build 112743): the new test and the rest of test/bake/ pass on every lane. The one red job is test/js/node/test/parallel/test-crypto-dh-leak.js on debian x64-asan, which also fails on main and does not touch this change.

@coderabbitai

coderabbitai Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 448975e2-564b-481c-97bc-5c4e9678959f

📥 Commits

Reviewing files that changed from the base of the PR and between 333863f and 981246a.

📒 Files selected for processing (2)
  • src/runtime/bake/hmr-module.ts
  • test/bake/dev/hot.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.


Walkthrough

Changes

HMR disposal deduplication

Layer / File(s) Summary
Deduplicate HMR disposal
src/runtime/bake/hmr-module.ts
replaceModules collects boundary modules in a Set and disposes each module once.
Validate batched updates
test/bake/dev/hot.test.ts
The test verifies single disposal and re-evaluation for batched updates, plus persisted hot data and updated imported values.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal — Impact reflects medium issue severity.

Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to 98124

Batched HMR updates now dispose a shared accepting boundary once, preventing the dispose-callback error and preserving hot-update state. The targeted behavior is covered with no current merge-blocking risk identified.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR addresses issue #29418 by preventing duplicate disposal of the same HMR boundary and adds a regression test for the reported failure mode.
Out of Scope Changes check ✅ Passed The changes are limited to the HMR disposal fix and its regression test. They align with the linked issue and stated objectives.
Title check ✅ Passed The title clearly describes the primary change: disposing a self-accepting HMR boundary once per hot update.
Description check ✅ Passed The description explains the problem, cause, fix, verification results, background, and linked issue. It does not use the exact template headings, but it provides the required information in equivalen…
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — minimal, correct fix that mirrors the toReload Set right next to it.

What was reviewed:

  • replaceModules in hmr-module.ts: for..of iteration and .size work identically on the Set; mod.onDispose = null after the first pass is exactly what made the array version throw on the duplicate entry.
  • Sibling collections: toReload is already a Set; toAccept duplicates don't crash (callbacks aren't nulled) and are out of scope for this crash fix.
  • New test: uses the established batchChanges()/expectMessage harness (event-driven, no sleeps), asserts strict order for both the batched and follow-up single-file update, and defers the state assertion to after eval 3 so it doesn't depend on the ordering fix in #31944.
Extended reasoning...

Overview

The PR changes toDispose in replaceModules (src/runtime/bake/hmr-module.ts) from HMRModule[] to Set<HMRModule>, with the corresponding .push → .add and .length → .size updates. The bug: the outer loop walks importers once per payload key with a per-key visited set, so a self-accepting boundary reached from two changed files was pushed onto toDispose twice; the dispose loop nulls mod.onDispose after running callbacks, so the second iteration threw TypeError: mod.onDispose is not iterable and forced a full page reload. toReload immediately above is already a Set for the same reason, so this brings toDispose in line. A new devTest in test/bake/dev/hot.test.ts batches two dependency writes into one hot update, asserts the boundary disposes exactly once, then verifies a subsequent single-file update still works and the final module state is correct.

Security risks

None. This is the client-side HMR runtime for the dev server; no auth, crypto, path handling, or untrusted-input parsing is involved. The only observable change is deduplicating which already-registered dispose callbacks run per hot update.

Level of scrutiny

Low-to-moderate. The source change is four lines and mechanical (array → Set), matching the pattern already used by toReload on the line above. Set iteration preserves insertion order and for..of works unchanged, so dispose ordering for the non-duplicate case is identical. I audited the sibling collections in the same function: toReload already dedupes; toAccept can in principle collect the same {cb, key} twice in diamond import graphs, but accept callbacks are not nulled after firing so there is no crash — that pre-existing behavior is reasonably left to the broader rewrite the PR references.

Other factors

The test is placed next to the existing import.meta.hot.dispose cleanup test in hot.test.ts, uses the harness's event-driven batchChanges() / expectMessage() (no sleeps), and covers both the batched multi-file case and a follow-up single-file update per the variant-matrix rule. The final toEqual on globalThis.state is deliberately taken after eval 3 so it does not depend on the separate reload-ordering fix. The PR description states the test fails on the unfixed build with the exact onDispose is not iterable error and that the rest of hot.test.ts/esm.test.ts still pass. No CODEOWNERS entries cover src/runtime/bake/ or test/bake/. Exit reason was dry_streak and there are no prior reviews or open threads on the timeline.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bun dev server: K.onDispose is not iterable during HMR when index.html links a stylesheet + entry has both accept() and dispose()

2 participants