diff --git a/packages/runtime-tests/unit/hmr-bridge-idempotent.sequential.test.ts b/packages/runtime-tests/unit/hmr-bridge-idempotent.sequential.test.ts new file mode 100644 index 0000000..64e8c05 --- /dev/null +++ b/packages/runtime-tests/unit/hmr-bridge-idempotent.sequential.test.ts @@ -0,0 +1,70 @@ +// SEQUENTIAL: initHmrBridge guards registration with a MODULE-LEVEL boolean. +// Each test re-imports the module via vi.resetModules() so the guard starts +// fresh; the module cache is process-global, so this must not run concurrently. +import { afterEach, expect, test, vi } from "vite-plus/test"; + +// Internal module not in package exports — import via relative source path, +// matching the convention in unit/animation-scheduler.sequential.test.ts. The +// dynamic import() path must be a string LITERAL so the bundler resolves it +// relative to this file (a variable path resolves against the project root). + +afterEach(() => { + vi.resetModules(); +}); + +// `on`/`send` are typed with their real signatures so FakeHot structurally +// satisfies the HotContext param of initHmrBridge (vi.fn's default loose +// signature would not). `handlers` is test-only state for firing callbacks back. +type FakeHot = { + on: ReturnType void) => void>>; + send: ReturnType void>>; + handlers: Map void>; +}; + +function makeFakeHot(): FakeHot { + const handlers = new Map void>(); + const on = vi.fn((event: string, cb: (payload: unknown) => void) => { + // Mirror Vite's APPEND-without-dedup semantics: last registration wins in + // this map, but `on` is still *called* once per registration so the spy + // count reflects accumulation exactly as the real runtime would leak it. + handlers.set(event, cb); + }); + const send = vi.fn<(event: string, data?: unknown) => void>(); + return { on, send, handlers }; +} + +test("initHmrBridge registers each listener AT MOST ONCE across repeated createApp() calls", async () => { + vi.resetModules(); + const { initHmrBridge } = await import("../../runtime/src/hmr.ts"); + const hot = makeFakeHot(); + + // Simulate two createApp() calls in one dev process (two apps, or unmount + + // re-create). Vite appends listeners without dedup, so without an idempotency + // guard the second call would re-register all three handlers (6 total). + initHmrBridge(hot); + initHmrBridge(hot); + + // Exactly 3 = one each for vite:error, vite:beforeUpdate, vite:beforeFullReload. + expect(hot.on).toHaveBeenCalledTimes(3); +}); + +test("a registered handler still works after the idempotency refactor", async () => { + vi.resetModules(); + const { initHmrBridge, devState } = await import("../../runtime/src/hmr.ts"); + const hot = makeFakeHot(); + + initHmrBridge(hot); + + // Firing vite:error must drive devState to an error — proves the refactor to + // a parameterized `hot` kept the handler wiring intact. + const errHandler = hot.handlers.get("vite:error"); + expect(errHandler).toBeTypeOf("function"); + errHandler!({ err: { message: "boom" } }); + expect(devState.value).toEqual({ type: "error", error: { message: "boom" } }); + + // vite:beforeFullReload must send the reload request through the injected hot. + const reloadHandler = hot.handlers.get("vite:beforeFullReload"); + expect(reloadHandler).toBeTypeOf("function"); + reloadHandler!(undefined); + expect(hot.send).toHaveBeenCalledWith("vue-tui:request-reload"); +}); diff --git a/packages/runtime/src/hmr.ts b/packages/runtime/src/hmr.ts index e117f9b..fe59bb9 100644 --- a/packages/runtime/src/hmr.ts +++ b/packages/runtime/src/hmr.ts @@ -15,15 +15,41 @@ export const DevStateKey: InjectionKey> = Symbol("DevState" export const devState = shallowRef({ type: "ok" }); -export function initHmrBridge(): void { - if (!import.meta.hot) return; +// The minimal Vite HMR context shape we use. Declared STRUCTURALLY (not derived +// from ImportMeta["hot"]) so this module type-checks even when imported from a +// package whose tsconfig doesn't pick up env.d.ts's ambient augmentation — e.g. +// runtime-tests imports ../runtime/src/hmr.ts directly. Keep it in sync with the +// ImportMeta.hot declaration in env.d.ts. +interface HotContext { + on(event: string, cb: (payload: unknown) => void): void; + send(event: string, data?: unknown): void; +} - import.meta.hot.on("vite:error", (payload: unknown) => { +// Typed access to import.meta.hot relies on env.d.ts's ambient augmentation, +// which isn't visible to every importing package; read it through a structural +// cast so the default param below type-checks anywhere this module is imported. +const realHot = (import.meta as { hot?: HotContext }).hot; + +// Registration must happen AT MOST ONCE per module lifetime. createApp() can run +// multiple times in one dev process (two apps, unmount + re-create, a tool that +// restarts the UI, a test run) and each call reaches here. Vite's Node HMR +// runtime APPENDS listeners with no dedup, so without this guard N createApp() +// calls would leak N copies of every handler — firing each HMR event N times. +let initialized = false; + +// `hot` is injectable (defaulting to the real import.meta.hot) purely for tests: +// import.meta.hot is undefined under vitest, so the body is otherwise unreachable. +export function initHmrBridge(hot: HotContext | undefined = realHot): void { + if (!hot) return; + if (initialized) return; + initialized = true; + + hot.on("vite:error", (payload: unknown) => { const p = payload as { err: DevErrorInfo }; devState.value = { type: "error", error: p.err }; }); - import.meta.hot.on("vite:beforeUpdate", (payload: unknown) => { + hot.on("vite:beforeUpdate", (payload: unknown) => { const p = payload as { updates: Array<{ path: string }> }; devState.value = { type: "update", @@ -36,7 +62,7 @@ export function initHmrBridge(): void { }, 2000); }); - import.meta.hot.on("vite:beforeFullReload", () => { - import.meta.hot!.send("vue-tui:request-reload"); + hot.on("vite:beforeFullReload", () => { + hot.send("vue-tui:request-reload"); }); }