Skip to content

feat: native Hot Module Replacement (import.meta.hot) - #34944

Closed
bartlomieju wants to merge 6 commits into
mainfrom
feat/native-hmr-reload-engine
Closed

feat: native Hot Module Replacement (import.meta.hot)#34944
bartlomieju wants to merge 6 commits into
mainfrom
feat/native-hmr-reload-engine

Conversation

@bartlomieju

@bartlomieju bartlomieju commented Jun 5, 2026

Copy link
Copy Markdown
Member

Deno's --watch-hmr today isn't really module replacement. It drives the V8
inspector and calls Debugger.setScriptSource, which can only swap function
bodies 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, no
dependency-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.hot API. It is built in three layers.

The first layer is a reload engine in deno_core. Given a changed module it
evicts that specifier from the module map (a new ModuleNameTypeMap::remove /
ModuleMapData::evict_modules, tombstoning the append-only handle/info slots so
existing ModuleId indices stay valid) and re-drives a load so the module
recompiles into a fresh v8::Module. Its dependencies are skipped and keep
their 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_module and to JS as Deno.core.reloadEsModule (evict
plus a dynamic import(), reusing the existing dynamic-import pipeline).

The second layer is import.meta.hot. It is attached natively rather than via a
source transform: when HMR is enabled, the import-meta callback invokes a
registered JS factory for each file: module, so in non-HMR runs the property
is simply undefined and bundling / compiling are unaffected. The JS runtime
(in 01_core.js) implements HotContext (accept, dispose, decline,
invalidate, data, on/off), a specifier-keyed boundary registry, and
applyHmrUpdate, which walks up the importer graph (queried from Rust) to the
nearest accepting boundary, runs dispose handlers capturing hot.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.data is
preserved across the reload. A change with no accepting boundary, a
decline(), or an invalidate() reports "not handled" so the caller can fall
back to a full reload.

The third layer wires this into deno run --watch-hmr, replacing and removing
the 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 worker
selects 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 no
boundary 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 would
otherwise 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.hot semantics are covered by unit tests
in libs/core/modules/tests.rs (singleton preservation across reload, self-
accept with dispose to data hand-off, dependency-accept boundary, and the
no-boundary full-reload signal). The --watch-hmr flow is covered by
integration tests in tests/integration/watcher_tests.rs: server-handler and
JSX dependency hot-swaps through accepting boundaries, self-accept with
hot.data hand-off (also under symlinked temp dirs), the transpile-failure
fallback, and the uncaught-error / unhandled-rejection restart flows. Note the
pre-existing run_hmr_server / run_hmr_jsx / run_hmr_compile_error tests
encoded 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 serve integration; and a browser WebSocket ESM-HMR
protocol for frontend frameworks.

The design is written up in docs/designs/native-hmr.md. Note that committing
it required adding docs to the allowed top-level entries in tools/lint.js
(matching upstream's docs/ layout) -- flagging that here since the allowlist
asks for changes to be discussed.

This is a draft for early feedback on the approach.

Related issues

Directly addressed:

Enabled / related (follow-ups noted above):

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.
@bartlomieju bartlomieju added this to the 2.9.0 milestone Jun 6, 2026
@petamoriken

petamoriken commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Node.js is considering the addition of module.clearCache, which can handle a broader range of cases than HMR.
nodejs/node#61767

This proposal was discussed at the TC39 module meeting, and code reviews for module.clearCache were conducted based on that. Although it is currently stalled due to other issues, it seems better to implement module.clearCache rather than import.meta.hot. While we need to keep an eye on Node.js's next moves...

…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.
@bartlomieju bartlomieju removed this from the 2.9.0 milestone Jun 18, 2026
@bartlomieju

Copy link
Copy Markdown
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.

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.

2 participants