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:

TypeScript
// vite.config.ts
import { defineConfig } from 'vite';
import sigx from '@sigx/vite';

export default defineConfig({
    plugins: [sigx()],
});

To turn it off, pass hmr: false:

TypeScript
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:

TypeScript
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 hmr option is true (the default).
  • The file is a first-party .ts, .tsx, or .jsx file — files in node_modules and /dist/ are skipped.
  • The file's source text contains a component declaration, matched by the pattern component< or component(.

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:

TypeScript
// --- 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#