HMR for Components
Hot Module Replacement (HMR) lets you edit a component and see the change in the
browser without a full page reload. With @sigx/vite, component HMR preserves
each live instance's signal state — so editing a counter does not reset its
value.
You do not write any HMR code yourself. The plugin injects everything during the dev server's transform step.
Enabling HMR
HMR is on by default. Simply having the plugin in your config is enough:
// vite.config.ts
import { defineConfig } from 'vite';
import sigx from '@sigx/vite';
export default defineConfig({
plugins: [sigx()],
});
To turn it off, pass hmr: false:
import sigx from '@sigx/vite';
export default defineConfig({
plugins: [sigx({ hmr: false })],
});
The HMR websocket port
In a middleware-mode dev server — the standard SignalX SSR setup, where Vite
runs inside your own HTTP server — the HMR websocket cannot piggyback on the HTTP
server and Vite falls back to a fixed default port (24678). Run two dev servers
on one machine and they collide: the browser connects to the other server's
websocket, gets a 400 token mismatch, and HMR silently breaks.
@sigx/vite fixes this automatically. When you do not pin a port, the plugin
asks the OS for a free port and uses it for the HMR websocket — so concurrent dev
servers never clash. To pin one explicitly, pass hmrPort:
import sigx from '@sigx/vite';
export default defineConfig({
server: { middlewareMode: true },
plugins: [sigx({ hmrPort: 24700 })],
});
Any explicit server.hmr settings in your Vite config always take precedence —
the plugin only steps in when you haven't pinned a port yourself, and only in
middleware mode (when the websocket shares the HTTP server's port there is nothing
to fix).
When the Transform Fires
The HMR transform only runs when all of these are true:
- The dev server is running (
vite/command === 'serve') — never during a production build. - The plugin's
hmroption istrue(the default). - The file is a first-party
.ts,.tsx, or.jsxfile — files innode_modulesand/dist/are skipped. - The file's source text contains a component declaration, matched by the
pattern
component<orcomponent(.
If a file has no component( or component< usage, it is left untouched.
What Gets Injected
For each matching module, the plugin prepends a module registration and appends Vite's hot-accept call. Conceptually, your file is wrapped like this:
// --- injected at the top ---
import { registerHMRModule } from '@sigx/vite/hmr';
registerHMRModule('<module-id>');
// --- your original source ---
import { component, signal } from 'sigx';
export const Counter = component(() => {
const count = signal(0);
return () => <button onClick={() => count.value++}>{count.value}</button>;
});
// --- injected at the bottom ---
if (import.meta.hot) {
import.meta.hot.accept();
}
You never write these lines — they are added automatically. The
registerHMRModule and installHMRPlugin functions are re-exported from
@sigx/vite only so the injected code can reference them; you rarely call them
by hand.
How State Survives a Reload
Each component gets a stable id of the form moduleId:definitionIndex.
registerHMRModule resets the per-module component index to 0 at the top of
every re-execution, so the first component(...) in a file always keeps the
same id even after edits.
When a module reloads, the browser-side HMR plugin (installed once via
installHMRPlugin) re-binds each live instance to the new setup/render
function: it replaces ctx.renderFn and calls ctx.update() against the
existing context. Because the existing context is reused, signals created in
setup keep their current values across the reload. Instances are tracked on
define and cleaned up on ctx.onUnmounted.
When a Full Reload Happens
If an HMR update throws, the runtime logs:
[sigx] HMR failed for <name>
and falls back to a full page reload — the clean recovery path. This typically happens when an edit changes the shape of a component in a way that cannot be patched in place.
Why This Matters
The deeper guarantee behind HMR is a single physical copy of
@sigx/reactivity. In dev the plugin aliases the SignalX runtime packages to
their src entries and excludes all @sigx/* packages from optimizeDeps
pre-bundling — the core tier plus every @sigx/* dependency declared in your
package.json (store, router, daisyui, …) — so no pre-bundled chunk can carry a
second reactivity copy. It also sets ssr.noExternal: ['sigx', /^@sigx\//] so the
whole family stays in the SSR module graph instead of splitting between Vite's
module runner and Node's resolver. In build it dedupes the runtime tier and forces
it into a single sigx chunk. Without that single copy, signal mutations from
onMounted, observers, setTimeout, or event handlers would silently fail to
re-render after hydration. Your own optimizeDeps.exclude / ssr.noExternal
entries are merged with the plugin's, never replaced.
Next Steps
- Library Builds — build
@sigx/*packages - API Reference —
installHMRPlugin,registerHMRModule, and options
