From 0f4c32f03e9fc842a8a99ab985aa5c0a96399b7b Mon Sep 17 00:00:00 2001 From: Cristian Necula Date: Mon, 16 Feb 2026 06:32:01 +0000 Subject: [PATCH] feat: add HMR support for pion components via Vite plugin Add hot module replacement infrastructure that enables swapping pion component renderers on live DOM instances without full page reloads. - Add HMR runtime (src/hmr.ts) with component registry, instance tracking, and renderer hot-swap via patched customElements.define - Add Vite plugin (src/vite-plugin.ts) that injects enableHMR() preamble and appends import.meta.hot.accept() to component files - Modify component.ts to store renderer on Element class via Symbol and track/untrack instances in connected/disconnected callbacks - Add rendererSymbol and hmrTagSymbol to symbols.ts - Add package.json exports for ./hmr and ./vite-plugin subpaths - Add HMR test suite covering registration, instance tracking, renderer replacement, state preservation, and re-define handling --- package.json | 18 ++++ src/component.ts | 21 ++++ src/core.ts | 2 + src/hmr.ts | 215 ++++++++++++++++++++++++++++++++++++++++ src/symbols.ts | 14 +++ src/vite-plugin.ts | 158 ++++++++++++++++++++++++++++++ test/hmr.test.ts | 238 +++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 666 insertions(+) create mode 100644 src/hmr.ts create mode 100644 src/vite-plugin.ts create mode 100644 test/hmr.test.ts 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"); + } + }); + }); +});