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:
HotReloadconnects tows://localhost:3001(configurable) and listens forreload,css-update,hmr-update, andpingmessages. It only activates whenwindow.location.hostnameislocalhostor127.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 @PreserveStatedecorator: 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,HMRErrorBoundarytriggers a full-page reload as a safe fallback - CSS-only updates: CSS file changes are handled by cache-busting the
<link>element'shrefattribute — no page reload needed - Production guards:
isDevelopment(),devOnly(),devOnlyAsync(), and theHMR_AVAILABLEconstant 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
HotReloadusesWebSocketanddocument, which are browser-only APIs. The class checkswindow.location.hostnameinternally and is a no-op when not running on localhostStatePreservationManager.saveAllStates()andrestoreAllStates()are synchronous in this version (they return{ saved, failed }/{ restored, failed }objects, not Promises)- The Vite plugin (
udiotHMRPlugin) requires Vite as a peer dependency excludePatternsinHotReloadConfigtakesRegExp[], not glob strings — use[/node_modules/, /\.git/]syntax- The
HMR_AVAILABLEconstant is evaluated at module load time. For reliable dead-code elimination with Webpack, setmode: 'production'(which definesprocess.env.NODE_ENV = 'production') rather than relying only onDefinePlugin
Pitfalls
useModuleRuntime: true(the default) requiresHotModuleRuntimeto have module metadata available. If a module update cannot be applied surgically, the runtime falls back to a full page reload viaHMRErrorBoundaryexcludePatternsmust beRegExp[], notstring[]— passing strings will silently fail to exclude files becauseRegExp.test(string)will not be calledinitHotReload()creates a singleton; calling it a second time with different config returns the existing instance without applying the new config. UsegetHotReload()?.updateConfig(newConfig)to change configuration after initialisationgetHotReload()returnsnullifinitHotReload()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