diff --git a/package.json b/package.json
index 5d7120c..ad0ebef 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,24 @@
"module": "lib/haunted.js",
"type": "module",
"customElements": "custom-elements.json",
+ "exports": {
+ ".": {
+ "types": "./lib/haunted.d.ts",
+ "default": "./lib/haunted.js"
+ },
+ "./hmr": {
+ "types": "./lib/hmr.d.ts",
+ "default": "./lib/hmr.js"
+ },
+ "./vite-plugin": {
+ "types": "./lib/vite-plugin.d.ts",
+ "default": "./lib/vite-plugin.js"
+ },
+ "./core": {
+ "types": "./lib/core.d.ts",
+ "default": "./lib/core.js"
+ }
+ },
"scripts": {
"build": "tsc",
"changeset": "changeset",
diff --git a/src/component.ts b/src/component.ts
index ba3b75b..013fc02 100644
--- a/src/component.ts
+++ b/src/component.ts
@@ -1,6 +1,12 @@
import { GenericRenderer, RenderFunction, RenderResult } from "./core";
import { BaseScheduler } from "./scheduler";
+import { rendererSymbol, hmrTagSymbol } from "./symbols";
import { sheets } from "./util";
+import {
+ isHMRActive,
+ trackInstance,
+ untrackInstance,
+} from "./hmr";
const toCamelCase = (val = ""): string =>
val.replace(/-+([a-z])?/g, (_, char) => (char ? char.toUpperCase() : ""));
@@ -127,12 +133,24 @@ function makeComponent(render: RenderFunction): Creator {
this._scheduler.resume();
this._scheduler.update();
this._scheduler.renderResult?.setConnected(true);
+
+ // HMR: track this instance
+ if (isHMRActive()) {
+ const tag = (this as any)[hmrTagSymbol];
+ if (tag) trackInstance(tag, this);
+ }
}
disconnectedCallback(): void {
this._scheduler.pause();
this._scheduler.teardown();
this._scheduler.renderResult?.setConnected(false);
+
+ // HMR: untrack this instance
+ if (isHMRActive()) {
+ const tag = (this as any)[hmrTagSymbol];
+ if (tag) untrackInstance(tag, this);
+ }
}
attributeChangedCallback(
@@ -209,6 +227,9 @@ function makeComponent(render: RenderFunction): Creator {
Object.setPrototypeOf(Element.prototype, proto);
+ // Store the renderer on the class for HMR access
+ (Element as any)[rendererSymbol] = renderer;
+
return Element as unknown as Constructor
;
}
diff --git a/src/core.ts b/src/core.ts
index 1a65cbb..bb00b9f 100644
--- a/src/core.ts
+++ b/src/core.ts
@@ -62,6 +62,8 @@ export type { Options as ComponentOptions } from "./component";
export type { StateUpdater } from "./use-state";
+export { rendererSymbol, hmrTagSymbol } from "./symbols";
+
/**
* Represents any value that can be rendered by lit-html.
*
diff --git a/src/hmr.ts b/src/hmr.ts
new file mode 100644
index 0000000..e8ad10e
--- /dev/null
+++ b/src/hmr.ts
@@ -0,0 +1,215 @@
+/**
+ * HMR (Hot Module Replacement) runtime for pion components.
+ *
+ * This module provides a registry that tracks component renderers and their
+ * live DOM instances. When a module is hot-replaced, the new renderer function
+ * can be swapped into all existing instances without re-registering the custom
+ * element (which the browser forbids).
+ *
+ * ## How it works
+ *
+ * 1. `enableHMR()` is called once at startup (by the Vite plugin preamble).
+ * This patches `customElements.define` to intercept pion component
+ * registrations and track them in a registry.
+ *
+ * 2. When a pion component file is first loaded, `customElements.define` is
+ * called normally. The patched `define` detects pion components (via the
+ * `rendererSymbol` on the class), registers them, and sets up instance
+ * tracking on the prototype.
+ *
+ * 3. When Vite hot-replaces a module, the module re-executes. The new call to
+ * `customElements.define` for the same tag name is intercepted: instead of
+ * failing, it extracts the new renderer from the new class and calls
+ * `replaceRenderer()` to swap it into all live instances.
+ *
+ * 4. `replaceRenderer()` iterates all connected instances, swaps
+ * `_scheduler.renderer`, and calls `_scheduler.update()` to re-render.
+ */
+
+import { rendererSymbol, hmrTagSymbol } from "./symbols";
+
+export interface ComponentEntry {
+ /** The current renderer function */
+ renderer: Function;
+ /** The registered custom element class */
+ elementClass: CustomElementConstructor;
+ /** The tag name this component is registered under */
+ tagName: string;
+ /** All currently connected instances of this component */
+ instances: Set;
+}
+
+/**
+ * Global registry mapping tag names to their component metadata.
+ */
+const componentRegistry = new Map();
+
+/**
+ * Whether HMR is currently active.
+ */
+let hmrActive = false;
+
+/**
+ * Enable HMR mode. Call this once at startup.
+ *
+ * This patches `customElements.define` to intercept pion component
+ * registrations, enabling hot replacement without page reloads.
+ */
+export function enableHMR(): void {
+ if (hmrActive) return;
+ hmrActive = true;
+ patchCustomElementsDefine();
+}
+
+/**
+ * Check if HMR mode is active.
+ */
+export function isHMRActive(): boolean {
+ return hmrActive;
+}
+
+/**
+ * Patch `customElements.define` to support HMR for pion components.
+ *
+ * For pion components (detected by `rendererSymbol` on the class):
+ * - First registration: registers normally + adds to registry + sets up
+ * instance tracking on the prototype via `hmrTagSymbol`.
+ * - Subsequent registrations (same tag): extracts the new renderer and
+ * hot-swaps it into all live instances.
+ *
+ * Non-pion components pass through to the original `define` unchanged.
+ */
+function patchCustomElementsDefine(): void {
+ const originalDefine = customElements.define.bind(customElements);
+
+ customElements.define = function (
+ name: string,
+ constructor: CustomElementConstructor,
+ options?: ElementDefinitionOptions,
+ ) {
+ const newRenderer = (constructor as any)[rendererSymbol];
+
+ // Not a pion component — pass through
+ if (!newRenderer) {
+ // Guard against re-definition of non-pion components too
+ if (customElements.get(name)) return;
+ originalDefine(name, constructor, options);
+ return;
+ }
+
+ // Set the HMR tag on the prototype so instances can self-register
+ // in connectedCallback/disconnectedCallback
+ constructor.prototype[hmrTagSymbol] = name;
+
+ if (componentRegistry.has(name)) {
+ // Hot replacement — swap renderer on all live instances
+ replaceRenderer(name, newRenderer);
+ return;
+ }
+
+ // First-time registration
+ const entry: ComponentEntry = {
+ renderer: newRenderer,
+ elementClass: constructor,
+ tagName: name,
+ instances: new Set(),
+ };
+ componentRegistry.set(name, entry);
+ originalDefine(name, constructor, options);
+ } as typeof customElements.define;
+}
+
+/**
+ * Track a component instance being connected to the DOM.
+ */
+export function trackInstance(tagName: string, instance: HTMLElement): void {
+ const entry = componentRegistry.get(tagName);
+ if (entry) {
+ entry.instances.add(instance);
+ }
+}
+
+/**
+ * Untrack a component instance being disconnected from the DOM.
+ */
+export function untrackInstance(tagName: string, instance: HTMLElement): void {
+ const entry = componentRegistry.get(tagName);
+ if (entry) {
+ entry.instances.delete(instance);
+ }
+}
+
+/**
+ * Replace the renderer function for a component and re-render all live instances.
+ *
+ * This is the core HMR operation. It:
+ * 1. Finds the registry entry by tag name
+ * 2. Updates the renderer reference in the registry
+ * 3. Walks all connected instances and swaps `_scheduler.renderer`
+ * 4. Triggers `_scheduler.update()` on each instance to re-render
+ *
+ * @param tagName - The custom element tag name
+ * @param newRenderer - The new renderer function from the updated module
+ * @returns The number of instances that were updated
+ */
+export function replaceRenderer(tagName: string, newRenderer: Function): number {
+ const entry = componentRegistry.get(tagName);
+ if (!entry) {
+ console.warn(`[pion:hmr] No component registered with tag "${tagName}"`);
+ return 0;
+ }
+
+ // Update the registry
+ entry.renderer = newRenderer;
+
+ // Swap renderer on all live instances and trigger re-render
+ let count = 0;
+ for (const instance of entry.instances) {
+ const scheduler = (instance as any)._scheduler;
+ if (scheduler) {
+ scheduler.renderer = newRenderer;
+ scheduler.update();
+ count++;
+ }
+ }
+
+ if (count > 0) {
+ console.log(
+ `[pion:hmr] Hot replaced <${tagName}> (${count} instance${count !== 1 ? 's' : ''})`,
+ );
+ } else {
+ console.log(
+ `[pion:hmr] Updated <${tagName}> renderer (no live instances)`,
+ );
+ }
+
+ return count;
+}
+
+/**
+ * Get a registry entry by tag name.
+ */
+export function getComponentEntry(tagName: string): ComponentEntry | undefined {
+ return componentRegistry.get(tagName);
+}
+
+/**
+ * Check if a tag name is registered in the HMR registry.
+ */
+export function isRegistered(tagName: string): boolean {
+ return componentRegistry.has(tagName);
+}
+
+/**
+ * Get all registered tag names. Useful for debugging.
+ */
+export function getRegisteredTags(): string[] {
+ return Array.from(componentRegistry.keys());
+}
+
+/**
+ * Clear the entire registry. Mainly useful for testing.
+ */
+export function clearRegistry(): void {
+ componentRegistry.clear();
+}
diff --git a/src/symbols.ts b/src/symbols.ts
index ef1526c..08a0b04 100644
--- a/src/symbols.ts
+++ b/src/symbols.ts
@@ -11,6 +11,18 @@ type Phase = typeof updateSymbol | typeof commitSymbol | typeof effectsSymbol;
const contextEvent = "haunted.context";
+/**
+ * Symbol used to store the renderer function on the Element class.
+ * Used by HMR runtime to extract and replace renderers.
+ */
+const rendererSymbol = Symbol("pion.renderer");
+
+/**
+ * Symbol used to store the HMR tag name on element instances.
+ * Used by HMR runtime for instance tracking.
+ */
+const hmrTagSymbol = Symbol("pion.hmrTag");
+
export {
phaseSymbol,
hookSymbol,
@@ -19,6 +31,8 @@ export {
effectsSymbol,
layoutEffectsSymbol,
contextEvent,
+ rendererSymbol,
+ hmrTagSymbol,
Phase,
EffectsSymbols,
};
diff --git a/src/vite-plugin.ts b/src/vite-plugin.ts
new file mode 100644
index 0000000..f1a8ec3
--- /dev/null
+++ b/src/vite-plugin.ts
@@ -0,0 +1,158 @@
+/**
+ * Vite plugin for pion HMR (Hot Module Replacement).
+ *
+ * This plugin enables hot module replacement for pion web components.
+ * When a file containing `customElements.define(...)` with `component(...)`
+ * is saved, the plugin:
+ *
+ * 1. Injects an HMR preamble that calls `enableHMR()` from the pion runtime
+ * 2. Transforms component files to add `import.meta.hot.accept()` handlers
+ * that swap renderers on live instances instead of reloading the page
+ *
+ * ## Usage
+ *
+ * ```ts
+ * // vite.config.ts
+ * import { defineConfig } from 'vite';
+ * import pion from '@pionjs/pion/vite-plugin';
+ *
+ * export default defineConfig({
+ * plugins: [pion()],
+ * });
+ * ```
+ *
+ * ## How the transform works
+ *
+ * Given a file like:
+ * ```js
+ * import { component, html } from '@pionjs/pion';
+ *
+ * const MyEl = (host) => html`Hello
`;
+ * customElements.define('my-el', component(MyEl));
+ * ```
+ *
+ * It transforms it to:
+ * ```js
+ * import { component, html } from '@pionjs/pion';
+ *
+ * const MyEl = (host) => html`Hello
`;
+ * customElements.define('my-el', component(MyEl));
+ *
+ * if (import.meta.hot) {
+ * import.meta.hot.accept((newModule) => {
+ * // The module re-executes on accept, and the patched
+ * // customElements.define handles the hot swap automatically.
+ * });
+ * }
+ * ```
+ *
+ * The heavy lifting happens in the patched `customElements.define` (from
+ * `enableHMR()` in the pion runtime). When the module re-executes:
+ * - `component(NewRenderer)` creates a new class with `rendererSymbol`
+ * - `customElements.define('my-el', ...)` detects the tag is already registered
+ * - It extracts the new renderer and swaps it on all live instances
+ */
+
+interface PionPluginOptions {
+ /**
+ * File patterns to include for HMR transformation.
+ * Defaults to common JS/TS file extensions.
+ */
+ include?: RegExp;
+
+ /**
+ * File patterns to exclude from HMR transformation.
+ * Defaults to node_modules.
+ */
+ exclude?: RegExp;
+}
+
+/**
+ * Regex to detect files that define pion components.
+ *
+ * Matches patterns like:
+ * - `customElements.define('tag-name', component(Fn))`
+ * - `customElements.define("tag-name", component(Fn))`
+ * - `customElements.define("tag-name", component(Fn, opts))`
+ *
+ * We look for:
+ * 1. `customElements.define` call
+ * 2. A string literal for the tag name (single or double quotes)
+ * 3. `component(` somewhere in the second argument
+ */
+const DEFINE_PATTERN =
+ /customElements\.define\(\s*(['"`])([a-z][\w-]*)\1\s*,\s*component\s*\(/;
+
+/**
+ * Regex to extract all define calls with their tag names and renderer identifiers.
+ * Used to generate targeted HMR accept handlers.
+ *
+ * Captures:
+ * - Group 1: quote type
+ * - Group 2: tag name
+ */
+const DEFINE_PATTERN_GLOBAL =
+ /customElements\.define\(\s*(['"`])([a-z][\w-]*)\1\s*,\s*component\s*\(/g;
+
+export default function pionHMR(options: PionPluginOptions = {}) {
+ const {
+ include = /\.(js|ts|jsx|tsx|mjs|mts)$/,
+ exclude = /node_modules/,
+ } = options;
+
+ let isServe = false;
+
+ return {
+ name: 'pion:hmr',
+ enforce: 'post' as const,
+
+ config(_config: any, env: { command: string }) {
+ isServe = env.command === 'serve';
+ },
+
+ transformIndexHtml(html: string) {
+ if (!isServe) return html;
+
+ // Inject HMR preamble that enables the pion HMR runtime.
+ // This must run before any component modules are loaded.
+ return html.replace(
+ '',
+ `\n`,
+ );
+ },
+
+ transform(code: string, id: string) {
+ // Only transform in dev mode
+ if (!isServe) return null;
+
+ // Check include/exclude
+ if (!include.test(id)) return null;
+ if (exclude.test(id)) return null;
+
+ // Only transform files that define pion components
+ if (!DEFINE_PATTERN.test(code)) return null;
+
+ // Find all component definitions in the file
+ const defines: string[] = [];
+ let match: RegExpExecArray | null;
+ const re = new RegExp(DEFINE_PATTERN_GLOBAL.source, 'g');
+ while ((match = re.exec(code)) !== null) {
+ defines.push(match[2]); // tag name
+ }
+
+ if (defines.length === 0) return null;
+
+ // Append HMR accept handler
+ const hmrCode = `
+if (import.meta.hot) {
+ import.meta.hot.accept();
+}
+`;
+
+ return {
+ code: code + hmrCode,
+ map: null, // TODO: generate proper source maps
+ };
+ },
+ };
+}
diff --git a/test/hmr.test.ts b/test/hmr.test.ts
new file mode 100644
index 0000000..3642609
--- /dev/null
+++ b/test/hmr.test.ts
@@ -0,0 +1,238 @@
+import { component, html, useState } from "../src/haunted.js";
+import { fixture, expect, nextFrame } from "@open-wc/testing";
+import {
+ enableHMR,
+ replaceRenderer,
+ getComponentEntry,
+ getRegisteredTags,
+ clearRegistry,
+} from "../src/hmr.js";
+import { later } from "./helpers.js";
+
+describe("HMR", () => {
+ before(() => {
+ // Enable HMR before any component definitions.
+ // NOTE: enableHMR patches customElements.define globally, so it must
+ // be called before components are defined. In a real app, the Vite
+ // plugin injects this in the HTML head.
+ enableHMR();
+ });
+
+ afterEach(() => {
+ // Don't clear registry between tests — customElements.define is
+ // permanent and we can't unregister elements. Each test uses unique
+ // tag names.
+ });
+
+ describe("Component registration", () => {
+ it("registers a pion component in the HMR registry", async () => {
+ const tag = "hmr-test-register";
+
+ function App() {
+ return html`Hello`;
+ }
+
+ customElements.define(tag, component(App));
+
+ expect(getRegisteredTags()).to.include(tag);
+
+ const entry = getComponentEntry(tag);
+ expect(entry).to.not.be.undefined;
+ expect(entry!.tagName).to.equal(tag);
+ });
+
+ it("tracks instances in connectedCallback", async () => {
+ const tag = "hmr-test-track";
+
+ function App() {
+ return html`Tracked`;
+ }
+
+ customElements.define(tag, component(App));
+
+ const el = await fixture(html``);
+ await later();
+
+ const entry = getComponentEntry(tag);
+ expect(entry).to.not.be.undefined;
+ expect(entry!.instances.size).to.equal(1);
+ expect(entry!.instances.has(el)).to.be.true;
+ });
+
+ it("untracks instances in disconnectedCallback", async () => {
+ const tag = "hmr-test-untrack";
+
+ function App() {
+ return html`Untracked`;
+ }
+
+ customElements.define(tag, component(App));
+
+ const el = await fixture(html``);
+ await later();
+
+ const entry = getComponentEntry(tag);
+ expect(entry!.instances.size).to.equal(1);
+
+ // Remove from DOM
+ el.remove();
+ await later();
+
+ expect(entry!.instances.size).to.equal(0);
+ });
+ });
+
+ describe("Hot replacement", () => {
+ it("replaces the renderer and re-renders live instances", async () => {
+ const tag = "hmr-test-replace";
+
+ function AppV1() {
+ return html`Version 1`;
+ }
+
+ customElements.define(tag, component(AppV1));
+
+ const el = await fixture(html``);
+ await later();
+
+ expect(el.shadowRoot!.textContent).to.equal("Version 1");
+
+ // Simulate hot replacement
+ function AppV2() {
+ return html`Version 2`;
+ }
+
+ const count = replaceRenderer(tag, AppV2);
+ await later();
+
+ expect(count).to.equal(1);
+ expect(el.shadowRoot!.textContent).to.equal("Version 2");
+ });
+
+ it("preserves hook state across hot replacement", async () => {
+ const tag = "hmr-test-state";
+ let setCount: (v: number) => void;
+
+ function AppV1() {
+ const [count, _setCount] = useState(42);
+ setCount = _setCount;
+ return html`Count: ${count}`;
+ }
+
+ customElements.define(tag, component(AppV1));
+
+ const el = await fixture(html``);
+ await later();
+
+ expect(el.shadowRoot!.textContent).to.equal("Count: 42");
+
+ // Change state
+ setCount!(100);
+ await later();
+
+ expect(el.shadowRoot!.textContent).to.equal("Count: 100");
+
+ // Hot replace — the new renderer uses the same hooks, so state
+ // should be preserved (hook state lives on the State object, not
+ // on the renderer function)
+ function AppV2() {
+ const [count] = useState(0); // default won't matter, state exists
+ return html`New Count: ${count}`;
+ }
+
+ replaceRenderer(tag, AppV2);
+ await later();
+
+ // State (100) should be preserved, only template changed
+ expect(el.shadowRoot!.textContent).to.equal("New Count: 100");
+ });
+
+ it("handles replacement with no live instances gracefully", async () => {
+ const tag = "hmr-test-no-instances";
+
+ function AppV1() {
+ return html`V1`;
+ }
+
+ customElements.define(tag, component(AppV1));
+
+ // Don't create any instances
+
+ function AppV2() {
+ return html`V2`;
+ }
+
+ const count = replaceRenderer(tag, AppV2);
+ expect(count).to.equal(0);
+ });
+
+ it("skips re-definition on second customElements.define call", async () => {
+ const tag = "hmr-test-redefine";
+
+ function AppV1() {
+ return html`First`;
+ }
+
+ customElements.define(tag, component(AppV1));
+
+ const el = await fixture(html``);
+ await later();
+
+ expect(el.shadowRoot!.textContent).to.equal("First");
+
+ // Simulate what happens when the module re-executes after HMR:
+ // component() creates a new class, and customElements.define is
+ // called again with the same tag name. The patched define should
+ // NOT throw (normally it would throw DOMException).
+ function AppV2() {
+ return html`Second`;
+ }
+
+ // This should NOT throw
+ customElements.define(tag, component(AppV2));
+ await later();
+
+ // The live instance should have been updated
+ expect(el.shadowRoot!.textContent).to.equal("Second");
+ });
+
+ it("handles multiple instances", async () => {
+ const tag = "hmr-test-multi";
+
+ function AppV1() {
+ return html`Multi V1`;
+ }
+
+ customElements.define(tag, component(AppV1));
+
+ const container = await fixture(html`
+
+
+
+
+
+ `);
+ await later();
+
+ const instances = container.querySelectorAll(tag);
+ expect(instances.length).to.equal(3);
+
+ for (const el of instances) {
+ expect(el.shadowRoot!.textContent).to.equal("Multi V1");
+ }
+
+ // Hot replace
+ function AppV2() {
+ return html`Multi V2`;
+ }
+
+ const count = replaceRenderer(tag, AppV2);
+ await later();
+
+ expect(count).to.equal(3);
+ for (const el of instances) {
+ expect(el.shadowRoot!.textContent).to.equal("Multi V2");
+ }
+ });
+ });
+});