feat: native Hot Module Replacement (import.meta.hot) - #34944
Closed
bartlomieju wants to merge 6 commits into
Closed
feat: native Hot Module Replacement (import.meta.hot)#34944bartlomieju wants to merge 6 commits into
bartlomieju wants to merge 6 commits into
Conversation
Adds a deno_core reload engine that recompiles and re-evaluates a single ES module in place while preserving its dependencies' instances, the foundation for true Hot Module Replacement (replacing the limited CDP setScriptSource approach behind --watch-hmr). Mechanics: evict the module's by_name entry (new ModuleNameTypeMap::remove and ModuleMapData::evict_modules, tombstoning the append-only handles/info slots so existing ModuleIds stay valid), then re-drive a load so it recompiles into a fresh v8::Module. Its imports resolve through by_name to the surviving instances, so shared dependencies keep their singletons and only the reloaded module's top-level code re-runs. Surfaces: - JsRuntime::reload_es_module(specifier) (Rust API, evict + side load). - op_reload_module_evict + Deno.core.reloadEsModule(specifier) (JS escape hatch: evict + dynamic import, reusing the dynamic-import pipeline). - compute_importer_closure: importer-graph walk, infrastructure for the Phase 2 import.meta.hot boundary bubbling (currently unused). Design and phasing documented in docs/designs/native-hmr.md. import.meta.hot (the headline API) and the browser WebSocket protocol are later phases. Tested in libs/core/modules/tests.rs (Rust and JS paths), both asserting the edited module re-runs, resolves to its fresh namespace, and the shared dependency's singleton is preserved.
Builds the ESM-HMR / Vite-shaped import.meta.hot API on top of the Phase 1 reload engine, so modules can define HMR boundaries and hand off state across reloads instead of triggering a full restart. import.meta.hot is attached natively (no source transform): when HMR is enabled, host_initialize_import_meta_object_callback invokes a registered JS factory for each file: module. In non-HMR runs the factory is never registered, so import.meta.hot is undefined and production / bundle / compile are unaffected. The JS HMR runtime (libs/core/01_core.js) provides a HotContext (accept, dispose, decline, invalidate, data, on/off), a specifier-keyed boundary registry, and applyHmrUpdate(changedSpecifier): it walks up the importer graph (op_hmr_module_importers, backed by ModuleMapData::direct_importers) to the nearest accepting boundary, runs dispose handlers capturing hot.data, reloads the changed module once, and invokes accept handlers with the fresh namespace. hot.data survives the reload (the factory reuses the context object). A change with no accepting boundary up to the entry point, a decline(), or an invalidate() reports "not handled" so the caller can fall back to a full reload. Relative accept(['./dep']) specifiers are resolved via op_hmr_resolve to match the absolute importer graph. Exposed as Deno.core.enableHmr() (registers the factory, returns applyHmrUpdate) and Deno.core.applyHmrUpdate(specifier). Promoting to a user-facing Deno.reloadModule and wiring the FileWatcher are Phase 3. Tested in libs/core/modules/tests.rs: self-accept with dispose -> data hand-off, dependency-accept boundary (importer not re-evaluated), and no-boundary change reporting full-reload-required. The Phase 1 design note under docs/ is removed (this repo disallows a top-level docs/ directory; the design lives in the PR description).
When `--watch-hmr` is set, enable the native HMR runtime (`Deno.core.enableHmr()`) before the main module is loaded, so the entry module and its whole graph get an `import.meta.hot` attached during instantiation. This makes the Phase 2 `import.meta.hot` API available to user code under `deno run --watch-hmr`. Wiring file-change notifications to `applyHmrUpdate` (replacing the CDP setScriptSource runner) and refreshing module source on change are the next part of Phase 3, so accept/dispose handlers do not fire on edits yet -- the existing CDP runner still drives reloads for now.
Replaces the CDP `Debugger.setScriptSource` HMR runner with the native import.meta.hot engine, so `deno run --watch-hmr` performs true module replacement: on a file change the worker reads and transpiles the new source, registers it as a loader override, and drives `applyHmrUpdate` in JS. Accept boundaries re-run with the fresh module, `dispose` hands state to the new instance via `hot.data`, and the program is not restarted. When no accepting boundary handles the change (or a path can't be hot-replaced), it falls back to the watcher's full restart. - module_loader: add `HmrSourceOverrides` (Arc<Mutex<HashMap<specifier,String>>>) checked first in `load_code_source`, so the post-evict `import()` recompiles from the freshly transpiled source instead of the stale module-graph source. Exposed from CliModuleLoaderFactory and shared with the worker. - worker: `NativeHmrState` carries the emitter, watcher communicator, and the override map. `run()` runs `run_event_loop_with_native_hmr`, which selects the event loop against `watch_for_changed_paths`; on change `apply_native_hmr` emits + overrides + `run_apply_hmr_update` (drives Deno.core.applyHmrUpdate and reads the handled bool), falling back to `force_restart` otherwise. - factory: build `NativeHmrState` instead of the CDP `create_hmr_runner`. - Remove the CDP HMR runner (cli/tools/run/hmr.rs) and its now-unused cdp.rs types (SetScriptSourceResponse, Status, ScriptParsed). Verified end-to-end: editing a self-accepting module under --watch-hmr re-runs it with new source, preserves state across the reload via hot.data, fires the accept callback, and does not restart the process. Known limitation: under symlinked paths (e.g. macOS /tmp -> /private/tmp) the watcher's canonical path can differ from the module specifier; the lookup misses and it falls back to a full restart (safe degradation).
Design and implementation notes for native Hot Module Replacement (the reload engine, the import.meta.hot boundary API, and the --watch-hmr CLI integration) at docs/designs/native-hmr.md. Adds `docs` to the allowed top-level entries in tools/lint.js so the design doc can live in-tree (matching upstream Deno's docs/ layout). Flagging for discussion since the allowlist asks for it.
Contributor
|
Node.js is considering the addition of This proposal was discussed at the TC39 module meeting, and code reviews for |
…lback
Three fixes to the native --watch-hmr runner plus test coverage:
The watcher reports OS-canonicalized paths (macOS /tmp -> /private/tmp)
while the module map holds the original specifier, so the boundary
lookup missed and every update degraded to a full restart. The runner
now maps changed paths back to registered specifiers by comparing
canonicalized paths against all loaded file: modules (new
JsRuntime::loaded_module_specifiers accessor).
Self-accept callbacks received the bare module namespace while
dependency-accept callbacks received the ESM-HMR { module } shape; both
now receive { module }, matching the design doc. Noted the deliberate
divergence from Vite in the doc.
A changed file that fails to transpile (saved mid-edit) errored the
worker; it now falls back to a full restart so the new process surfaces
the diagnostic.
The run_hmr_server, run_hmr_jsx and run_hmr_compile_error integration
tests encoded the old CDP function-patching semantics (hot replacement
with no import.meta.hot boundary) and are rewritten for boundary
semantics; new run_hmr_self_accept_preserves_state covers the hot.data
hand-off and the symlinked temp-dir mapping.
Member
Author
|
Closing for now. Pivoting to a full-stack / browser-facing HMR approach (dev server + WS push + browser client runtime with import.meta.hot boundary propagation) rather than the server-side native reload engine. The reload-engine work here may be revisited, but the immediate plan is the browser half. |
This was referenced Aug 4, 2026
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.
Deno's
--watch-hmrtoday isn't really module replacement. It drives the V8inspector and calls
Debugger.setScriptSource, which can only swap functionbodies in place; any structural change (a new or removed export, a changed
top-level binding, a new import) is rejected and the whole process is
restarted. There's no per-module boundary, no
accept/dispose, nodependency-aware propagation, and no way to hand state across a reload.
This PR replaces that with a native HMR engine and the ESM-HMR / Vite
import.meta.hotAPI. It is built in three layers.The first layer is a reload engine in
deno_core. Given a changed module itevicts that specifier from the module map (a new
ModuleNameTypeMap::remove/ModuleMapData::evict_modules, tombstoning the append-only handle/info slots soexisting
ModuleIdindices stay valid) and re-drives a load so the modulerecompiles into a fresh
v8::Module. Its dependencies are skipped and keeptheir live instances, so shared singletons survive and only the changed
module's top-level code re-runs. This is exposed to Rust as
JsRuntime::reload_es_moduleand to JS asDeno.core.reloadEsModule(evictplus a dynamic
import(), reusing the existing dynamic-import pipeline).The second layer is
import.meta.hot. It is attached natively rather than via asource transform: when HMR is enabled, the import-meta callback invokes a
registered JS factory for each
file:module, so in non-HMR runs the propertyis simply
undefinedand bundling / compiling are unaffected. The JS runtime(in
01_core.js) implementsHotContext(accept,dispose,decline,invalidate,data,on/off), a specifier-keyed boundary registry, andapplyHmrUpdate, which walks up the importer graph (queried from Rust) to thenearest accepting boundary, runs
disposehandlers capturinghot.data,reloads the changed module once, and invokes the accept handlers with the fresh
namespace. Both accept forms receive the ESM-HMR
{ module }payload shape(this deliberately diverges from Vite, whose self-accept callback receives the
bare namespace; recorded in the design doc's open decisions).
hot.dataispreserved across the reload. A change with no accepting boundary, a
decline(), or aninvalidate()reports "not handled" so the caller can fallback to a full reload.
The third layer wires this into
deno run --watch-hmr, replacing and removingthe CDP runner. HMR is enabled before the main module loads so the whole graph
gets
import.meta.hot. While the program's event loop is live the workerselects it against the file watcher; on a change it transpiles the new source,
registers it in a per-specifier override map that the module loader consults
first (so the reload recompiles from fresh bytes, not the stale module graph),
and drives
applyHmrUpdate, falling back to the watcher's full restart when noboundary handles it. Watcher paths are mapped back to the specifiers modules
were registered under by comparing canonicalized paths (the OS may
canonicalize watched paths, eg. macOS
/tmp->/private/tmp, which wouldotherwise miss the boundary lookup and degrade every update to a restart). A
changed file that fails to transpile (eg. saved mid-edit) falls back to a
restart so the new process surfaces the diagnostic.
The reload engine and the
import.meta.hotsemantics are covered by unit testsin
libs/core/modules/tests.rs(singleton preservation across reload, self-accept with
disposetodatahand-off, dependency-accept boundary, and theno-boundary full-reload signal). The
--watch-hmrflow is covered byintegration tests in
tests/integration/watcher_tests.rs: server-handler andJSX dependency hot-swaps through accepting boundaries, self-accept with
hot.datahand-off (also under symlinked temp dirs), the transpile-failurefallback, and the uncaught-error / unhandled-rejection restart flows. Note the
pre-existing
run_hmr_server/run_hmr_jsx/run_hmr_compile_errortestsencoded the CDP function-patching semantics (hot replacement with no boundary)
and were rewritten for boundary semantics.
A few things are intentionally left for follow-ups: a user-facing unstable
Deno.reloadModule;deno serveintegration; and a browser WebSocket ESM-HMRprotocol for frontend frameworks.
The design is written up in
docs/designs/native-hmr.md. Note that committingit required adding
docsto the allowed top-level entries intools/lint.js(matching upstream's
docs/layout) -- flagging that here since the allowlistasks for changes to be discussed.
This is a draft for early feedback on the approach.
Related issues
Directly addressed:
--watch-hmrseems to always restart instead of hot-reloading #30293 ----watch-hmrseems to always restart instead of hot-reloading--unstable-hmrEnabled / related (follow-ups noted above):
builds on)
engine /
Deno.reloadModule)DENO_HMR)Deno.restart()(discussion touchesimport.meta.hot)deno serve --watch-hmrdoes not emit an hmr event (the legacyglobal
"hmr"event is kept;deno servewiring is a follow-up)