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.
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
- You save a file. The dev server's file watcher sees the change.
- Vantris consults its server module graph — importer edges and accept boundaries recorded as modules are served, using a real ES-module parser (so an
acceptcall inside a string or comment is never mistaken for a boundary). - 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.
- Boundary found → it sends a
- If a module was removed from the graph, a
type: "prune"message fires the module'sprunecallback.
The import.meta.hot API
The full API is available in dev, typed via vantris/client:
| Member | Purpose |
|---|---|
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). |
data | State 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:
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:
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:
type | Direction | Meaning |
|---|---|---|
connected | server → client | Handshake complete. |
update | server → client | Apply an in-place update to accepted modules. |
reload | server → client | Full page reload (no boundary). |
prune | server → client | A module left the graph; run its prune. |
error | server → client | Build/transform error → show the overlay. |
custom | both | A plugin/user custom event. |
ping/pong | both | Liveness. |
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.
// 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.