Skip to content

Hot Module Replacement

The dev server applies changes in place instead of reloading the page. CSS hot-swaps with no reload, and JS/TS modules that opt in update without losing state. Everything runs over a typed, bidirectional WebSocket protocol (RFC 6455, implemented in-house — no ws dependency).

CSS: nothing to opt into

Editing any stylesheet — plain CSS, CSS Modules, Sass, or Less — swaps the <style> in place with no reload and no lost state. This is automatic; you don't write any code for it. See CSS.

JS/TS: opt in with import.meta.hot.accept

A JavaScript or TypeScript module becomes a hot boundary by calling import.meta.hot.accept. A change with no boundary above it in the import graph safely falls back to a full page reload.

ts
export function setup() {
  /* … */
}
setup();

// Self-accept: re-run this module on change without reloading the page.
if (import.meta.hot) {
  import.meta.hot.accept((mod) => {
    mod?.setup();
  });
  import.meta.hot.dispose(() => {
    // tear down timers / listeners before the next version runs
  });
}

Always guard with if (import.meta.hot)

import.meta.hot exists only in dev. Guarding usage means the whole block is statically dead in a production build and tree-shakes away. Never rely on it at runtime in production.

How a change becomes an update

  1. You save a file. The dev server's file watcher sees the change.
  2. Vantris consults its server module graph — importer edges and accept boundaries recorded as modules are served, using a real ES-module parser (so an accept call inside a string or comment is never mistaken for a boundary).
  3. It walks up from the changed module looking for the nearest accept boundary.
    • Boundary found → it sends a type: "update" message; the client re-imports the updated module and runs the accept callback. No reload.
    • No boundary → it sends a type: "reload" message; the page reloads.
  4. If a module was removed from the graph, a type: "prune" message fires the module's prune callback.

The import.meta.hot API

The full API is available in dev, typed via vantris/client:

MemberPurpose
accept()Self-accept: re-execute this module on change.
accept(cb)Self-accept with a handler receiving the updated module.
accept(dep, cb)Accept updates from a single dependency.
accept(deps, cb)Accept updates from several dependencies.
dispose(cb)Run before the module is replaced — tear down side effects.
prune(cb)Run when the module is removed from the graph.
invalidate(msg?)Give up on HMR for this update and reload.
decline()Opt this module out of HMR entirely (always full-reload).
dataState persisted across updates of this module.
on(event, cb)Listen for a custom event from the server or a plugin.
off(event, cb)Remove a custom-event listener.
send(event, data?)Send a custom event to the server (and any server-side plugin).

The full reference with signatures is on the HMR API page.

Accepting a dependency

A module can accept updates from a dependency instead of itself. When ./state.ts changes, this module re-runs the callback with the new namespace, without reloading:

ts
import { store } from "./state.ts";

if (import.meta.hot) {
  import.meta.hot.accept("./state.ts", (newState) => {
    // rewire against newState?.store
  });
}

Persisting state across updates

import.meta.hot.data survives the swap, so you can carry values from the old module version to the new one:

ts
if (import.meta.hot) {
  // restore from the previous version
  let count = (import.meta.hot.data.count as number) ?? 0;

  import.meta.hot.dispose((data) => {
    data.count = count; // hand it to the next version
  });
}

Custom events

on/off/send form a two-way channel shared with the dev server and any server-side plugin. A plugin's handleHotUpdate or configureServer can server.send("my:event", data), and the client receives it via import.meta.hot.on("my:event", …) — and vice versa. This is how framework integrations coordinate custom refresh logic.

The message protocol

Every WebSocket frame is a JSON object with a discriminating type, so the channel is self-describing and forward compatible:

typeDirectionMeaning
connectedserver → clientHandshake complete.
updateserver → clientApply an in-place update to accepted modules.
reloadserver → clientFull page reload (no boundary).
pruneserver → clientA module left the graph; run its prune.
errorserver → clientBuild/transform error → show the overlay.
custombothA plugin/user custom event.
ping/pongbothLiveness.

The client also reconnects and reloads when the dev server restarts, so a crash-and-restart cycle recovers on its own.

Framework Fast Refresh

Plain import.meta.hot.accept re-runs a whole module. Frameworks want something finer — React, for instance, wants to swap a component's render function while preserving its state. That's what the handleHotUpdate plugin hook is for, and what the official @vantris/react plugin implements: edits to a component update in place with state intact.

ts
// vantris.config.ts
import { defineConfig } from "vantris";

export default defineConfig({
  plugins: ["@vantris/react"],
});

See React & JSX for the full setup.

Error overlay

Transform and build errors are pushed as type: "error" and rendered as a dismissable in-browser overlay, cleared automatically on the next successful update. See Dev Server → Error overlay.

Released under the MIT License.