Hot module replacement

A changed module is patched into the running page instead of reloading it, so scroll position, open dialogs and half-filled forms survive a save. --no-hot opts out.

It is not tied to a framework. A module opts in with import.meta.hot:

JavaScript
import { render } from "./view.js";

render();

import.meta.hot?.accept();

If nothing on the way up accepts a change, the page reloads.

The API

accept()Re-run this module in place
accept(cb)…and call cb(newExports) after
accept(dep, cb)Re-run that dependency and call cb(itsNewExports)
accept([deps], cb)The same, for several
signalAbortSignal, aborted just before this module is replaced
keep(key, make)Made once, returned on every replacement after
dispose(cb)Runs before replacement, with data
dataAn object that survives replacement
decline()Refuse replacement — any change reaching here reloads
invalidate()Hand the update up to this module's importers

import.meta.hot is undefined in a production build, so if (import.meta.hot) is how a module says “dev only” and still typechecks.

Cleanup, without cleanup code

signal is aborted just before your module is replaced. Anything that takes one tears itself down:

JavaScript
addEventListener("resize", onResize, { signal: import.meta.hot?.signal });

In production the signal is undefined, so the listener stays registered. For APIs that do not take a signal, register cleanup only in a hot build:

JavaScript
const timer = setInterval(tick, 1000);
if (import.meta.hot) {
  import.meta.hot.signal.addEventListener("abort", () => clearInterval(timer));
}

State across replacements

JavaScript
const cache = import.meta.hot
  ? import.meta.hot.keep("cache", () => new Map())
  : new Map();

In a hot build, made once and returned on every replacement. In production, the module creates its own map.

Component refresh is a plugin

esdev implements no framework's scheme and knows the name of none. What it provides is the generic half, and it is the same for everyone:

  • import.meta.hot and the update channel, on in any hot loop;

  • ctx.hot — true for a browser build the loop is running hot, and only there;

  • the compiler's component registrations, when a plugin asks for them.

A scheme is a plugin on top of that, declared with the project's other plugins:

JSON
{
  "plugins": ["./plugins/otfw-refresh.mjs"],
  "build": { "targets": { "web": { "entry": "index.html", "outdir": "dist" } } }
}
JavaScript
export default {
  name: "otfw-refresh",
  jsx: { refresh: true },
  transform: {
    filter: { id: /\.[jt]sx$/ },
    handler: (code, id, ctx) => (ctx.hot ? { code: wrapForHot(code, id) } : null),
  },
};

The refresh itself is an ordinary accept callback, so a scheme is a consumer of the hot API rather than something esdev knows about.

React

Already wired up in the React template: edit a component and it re-renders with its useState intact. It is React Fast Refresh, implemented by the template's plugins/react-refresh.mjs and listed in its esdev.json:

JSON
"plugins": ["./plugins/react-refresh.mjs"]

React has no privileges here: delete the plugin entry and the React template loses Fast Refresh like any other framework would.

Add refresh to an existing project

@opentf/esdev-plugin-react provides a reusable refresh plugin. Its initial release is not published yet. From an ES-Runtime checkout, run npm pack ./packages/esdev-plugin-react, then install the resulting tarball and react-refresh as development dependencies in your application:

Shell
npm install --save-dev /path/to/opentf-esdev-plugin-react-0.1.0.tgz react-refresh@^0.19.0

Add the package to the project's top-level plugin list:

JSON
{
  "plugins": ["@opentf/esdev-plugin-react"],
  "jsx": { "importSource": "react" },
  "build": {
    "targets": { "web": { "entry": "index.html", "outdir": "dist" } }
  }
}

Run esdev start. The package initializes the refresh runtime itself, so the application entry needs no refresh bootstrap. When replacing an existing template plugin, remove its plugin entry and the template's refresh bootstrap import. The embedded React templates still use their local plugin.

What it costs

Dev mode turns treeshaking off, so the React template's dev bundle is larger and rebuilds take longer. Development only — nothing you ship changes.

For the server restart, port selection and watch rules around HMR, see The dev loop.

Last updated on
Edit this page