Docs/Developer toolsv1.0.0-rc.1

Hot Module Replacement (HMR)

Overview

UDIOT's HMR system enables instant module updates without a full page reload, preserving application state across code changes. It consists of a client-side HotReload class that maintains a WebSocket connection to a development server, a server-side HotReloadServer that watches the filesystem for changes, and supporting utilities including StatePreservationManager, the @PreserveState decorator, a Vite plugin, and HMRErrorBoundary for graceful error recovery. Source: src/dev/.

All HMR exports live under @tagna/udiot/dev and are tree-shaken from production bundles automatically when NODE_ENV=production.

Key Concepts

  • Client-server WebSocket channel: HotReload connects to ws://localhost:3001 (configurable) and listens for reload, css-update, hmr-update, and ping messages. It only activates when window.location.hostname is localhost or 127.0.0.1
  • State preservation: Before accepting a module update, StatePreservationManager.saveAllStates() serialises registered state. After the module is replaced, restoreAllStates() restores it so UI state survives hot swaps
  • @PreserveState decorator: Annotates a class so the HMR runtime automatically saves and restores its instance fields when the module is hot-reloaded
  • Surgical module updates: When useModuleRuntime: true (default), HotModuleRuntime.applyHotUpdate() attempts a targeted module swap; if it fails, HMRErrorBoundary triggers a full-page reload as a safe fallback
  • CSS-only updates: CSS file changes are handled by cache-busting the <link> element's href attribute — no page reload needed
  • Production guards: isDevelopment(), devOnly(), devOnlyAsync(), and the HMR_AVAILABLE constant are provided for explicit tree-shaking control

API Reference

HotReloadConfig

interface HotReloadConfig {
  enabled?: boolean;           // Master switch (default: true)
  port?: number;               // WebSocket server port (default: 3001)
  host?: string;               // WebSocket server host (default: 'localhost')
  reconnectInterval?: number;  // Ms between reconnect attempts (default: 2000)
  showNotifications?: boolean; // Show toast notifications (default: true)
  watchCSS?: boolean;          // Apply CSS updates without full reload (default: true)
  watchJS?: boolean;           // Handle JavaScript updates (default: true)
  watchHTML?: boolean;         // Trigger full reload on HTML changes (default: true)
  excludePatterns?: RegExp[];  // Regex patterns for files to ignore
  preserveState?: boolean;     // Save/restore component state across updates (default: true)
  useModuleRuntime?: boolean;  // Use HotModuleRuntime for surgical updates (default: true)
  showErrorOverlay?: boolean;  // Show visual overlay on HMR errors (default: true)
}

HotReload

class HotReload {
  constructor(config?: HotReloadConfig)

  // Manually trigger a full page reload
  reload(): void

  // Reload all CSS stylesheets without a page reload
  reloadStyles(): void

  // Disconnect from the dev server and disable reconnection
  disconnect(): void

  // Merge new config values and reconnect if needed
  updateConfig(config: Partial<HotReloadConfig>): void
}

// Create or return the global HotReload instance
function initHotReload(config?: HotReloadConfig): HotReload

// Return the global instance (null if never initialised)
function getHotReload(): HotReload | null

HotReloadServerConfig

The actual shape is exported from src/dev/HotReloadServer.ts. Refer to that file for the full interface; commonly used options are:

interface HotReloadServerConfig {
  port?: number;               // WebSocket listen port (default: 3001)
  host?: string;               // Interface to bind (default: 'localhost')
  allowedOrigins?: string[];   // Extra browser origins allowed to connect
  // Additional fields vary by version — consult source directly
}

The server binds to localhost and accepts browser connections only from localhost pages and allowedOrigins, so neither the network nor another website open in your browser can subscribe to file-change events. Set host: '0.0.0.0' only inside a container or VM you control.

HotReloadServer

class HotReloadServer { /* start, stop, broadcast */ }

function startHotReloadServer(config?: HotReloadServerConfig): Promise<HotReloadServer>
function stopHotReloadServer(): Promise<void>
function getHotReloadServer(): HotReloadServer | null

Production safeguard helpers

// From @tagna/udiot/dev (src/dev/ProductionSafeguards.ts)

function isDevelopment(): boolean               // process.env.NODE_ENV !== 'production'
function isProduction(): boolean                // process.env.NODE_ENV === 'production'

// Execute callback only in development; returns undefined in production
function devOnly<T>(callback: () => T): T | undefined

// Same, but async
function devOnlyAsync<T>(callback: () => Promise<T>): Promise<T | undefined>

// Throws in production to catch misconfiguration early
function ensureDevOnly(feature: string): void

// Compile-time constant for dead-code elimination
const HMR_AVAILABLE: boolean

// Conditional dynamic import (returns undefined in production)
function conditionalImport<T>(importFn: () => Promise<T>): Promise<T | undefined>

// For build scripts — checks NODE_ENV is defined
function checkBuildEnvironment(): void

// For staging — verifies HMR modules are absent from the production bundle
function validateProductionBuild(): void

// Returns human-readable memory footprint estimate
function getHMRMemoryFootprint(): string

// Returns bundle size impact report
function getBundleSizeImpact(): { development: string; production: string; savings: string }

@PreserveState decorator

import { PreserveState } from '@tagna/udiot/dev';

function PreserveState(options?: PreserveStateOptions): ClassDecorator
function preserveState(id: string, state: object, options?: { onRestore?, include?, exclude?, hot? }): void
function createStateProxy<T extends object>(id: string, initialState: T, hot?: PreserveStateHot): T
function usePreservedState<T>(id: string, initialState: T, hot?: PreserveStateHot): [T, (newState: T) => void]

interface PreserveStateOptions {
  componentId?: string;        // Stable id across reloads. Default: the class name
  include?: string[];          // Only these fields
  exclude?: string[];          // Never these fields
  callLifecycleMethods?: boolean;
  lifecycleMethods?: string[]; // Called on the instance after a restore
  hot?: PreserveStateHot;      // Default: udiot's HMR runtime for the id
}

State is saved through udiot's HMR runtime, which the HotReload client drives. With another bundler's HMR, pass that module's handle, for example @PreserveState({ hot: import.meta.hot }). Give classes with the same name distinct componentIds.

StatePreservationManager

import { getStatePreservationManager, extractState, restoreState } from '@tagna/udiot/dev';

class StatePreservationManager {
  // Save all registered component states; returns { saved: number, failed: number }
  saveAllStates(): { saved: number; failed: number }

  // Restore all registered component states; returns { restored: number, failed: number }
  restoreAllStates(): { restored: number; failed: number }
}

function getStatePreservationManager(): StatePreservationManager

interface StatePreservationOptions { /* see source */ }

Vite Plugin

import { udiotHMRPlugin, vitePlugin } from '@tagna/udiot/dev';

interface UdiotHMRPluginOptions {
  useContentHash?: boolean;     // Content-hash change detection (default: true)
  preserveState?: boolean;      // State preservation across updates (default: true)
  useErrorBoundary?: boolean;   // Wrap updates in HMRErrorBoundary (default: true)
  verbose?: boolean;            // Verbose logging (default: false)
  exclude?: RegExp[];           // File patterns to exclude (default: [/node_modules/, /\.git/])
}

function udiotHMRPlugin(options?: UdiotHMRPluginOptions): Plugin  // Vite Plugin

The plugin skips updates for files saved without changes, and appends hooks to each JS/TS module that save registered component state before the module is replaced and restore it after the new version runs. It does not accept updates itself, so Vite propagates them to importers as usual. vitePlugin is an alias.

In-browser DevTools (HMR panel)

import { initDevTools, quickStart, HMRDevTools } from '@tagna/udiot/dev';

// Full initialisation
const devtools = initDevTools({
  position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right',
  theme?: 'light' | 'dark' | 'auto',
  updateInterval?: number,
  autoOpen?: boolean
});

// One-liner: opens the panel immediately
quickStart();

// Show/hide/toggle programmatically
import { showHMRDevTools, hideHMRDevTools, toggleHMRDevTools } from '@tagna/udiot/dev';

Basic Usage

Vite integration

// vite.config.ts
import { defineConfig } from 'vite';
import { udiotHMRPlugin } from '@tagna/udiot/dev';

export default defineConfig({
  plugins: [
    udiotHMRPlugin({
      preserveState:    true,
      useErrorBoundary: true,
      verbose:          false,
    }),
  ],
});

Starting the WebSocket dev server (custom setup)

// dev-server.ts
import { startHotReloadServer } from '@tagna/udiot/dev';

const server = await startHotReloadServer({ port: 3001 });
console.log('HMR server running on :3001');

Connecting the browser client

// main.ts
import { devOnly } from '@tagna/udiot/dev';

devOnly(() => {
  import('@tagna/udiot/dev').then(({ initHotReload }) => {
    initHotReload({
      port:             3001,
      preserveState:    true,
      showErrorOverlay: true,
      showNotifications: true,
    });
  });
});

Preserving class state across hot reloads

import { PreserveState } from '@tagna/udiot/dev';

@PreserveState()
class AppStore {
  count = 0;
  items: string[] = [];
}

Using the in-browser DevTools panel

// dev/setup.ts — only imported in development
import { quickStart } from '@tagna/udiot/dev';

quickStart(); // Opens the HMR DevTools panel in the bottom-right corner

Common Patterns

Conditional HMR in main.ts

// main.ts
import { isDevelopment, initializeHMR } from '@tagna/udiot/dev';

if (isDevelopment()) {
  await initializeHMR({ port: 3001 });
}

Wrapping a dev-only server start

import { devOnlyAsync, startHotReloadServer } from '@tagna/udiot/dev';

await devOnlyAsync(async () => {
  await startHotReloadServer({ port: 3001 });
});

Manual CSS reload

import { getHotReload } from '@tagna/udiot/dev';

// Reload only CSS (useful after theme switches)
getHotReload()?.reloadStyles();

Conditional dynamic import

import { conditionalImport } from '@tagna/udiot/dev';

const hmrModule = await conditionalImport(() => import('@tagna/udiot/dev'));
if (hmrModule) {
  const { initHotReload } = hmrModule;
  initHotReload({ port: 3001 });
}

Validating a production build

// staging-check.ts
import { validateProductionBuild } from '@tagna/udiot/dev';

validateProductionBuild(); // Logs if HMR modules are unexpectedly present

Integration Notes

  • HotReload uses WebSocket and document, which are browser-only APIs. The class checks window.location.hostname internally and is a no-op when not running on localhost
  • StatePreservationManager.saveAllStates() and restoreAllStates() are synchronous in this version (they return { saved, failed } / { restored, failed } objects, not Promises)
  • The Vite plugin (udiotHMRPlugin) requires Vite as a peer dependency
  • excludePatterns in HotReloadConfig takes RegExp[], not glob strings — use [/node_modules/, /\.git/] syntax
  • The HMR_AVAILABLE constant is evaluated at module load time. For reliable dead-code elimination with Webpack, set mode: 'production' (which defines process.env.NODE_ENV = 'production') rather than relying only on DefinePlugin

Pitfalls

  • useModuleRuntime: true (the default) requires HotModuleRuntime to have module metadata available. If a module update cannot be applied surgically, the runtime falls back to a full page reload via HMRErrorBoundary
  • excludePatterns must be RegExp[], not string[] — passing strings will silently fail to exclude files because RegExp.test(string) will not be called
  • initHotReload() creates a singleton; calling it a second time with different config returns the existing instance without applying the new config. Use getHotReload()?.updateConfig(newConfig) to change configuration after initialisation
  • getHotReload() returns null if initHotReload() has not been called yet — always guard with ?. or a null check
  • The HMR DevTools panel (initDevTools, quickStart) injects DOM elements and styles. If your application uses a strict Content Security Policy that blocks inline <style> tags, the panel will not render correctly. Relax your CSP for development origins
  • ensureDevOnly(feature) throws synchronously. Do not call it inside request handlers or event listeners where an unhandled throw would be caught silently
From the canonical UDIOT public documentation.Try it in the playground ↗