fix(runtime): make initHmrBridge idempotent (#176)
initHmrBridge registered three Vite HMR listeners (vite:error, vite:beforeUpdate, vite:beforeFullReload) with no idempotency guard and is called once per createApp() (dev block in render.ts). createApp() can run multiple times in one dev process — two apps, an app that unmounts and is re-created, a tool that restarts the UI, or a test run — and Vite's Node HMR runtime APPENDS listeners with no dedup, so N calls leaked N copies of every handler permanently. Every later HMR event then ran each handler N times. Add a module-level boolean guard so the listeners register at most once for the module's lifetime, regardless of how many times initHmrBridge is called. Also parameterize the hot context (defaulting to import.meta.hot) so the body is reachable under vitest, where import.meta.hot is undefined. HotContext is a local structural type and import.meta.hot is read via a structural cast so the module type-checks even when imported directly from runtime-tests, whose tsconfig doesn't pick up env.d.ts's ambient ImportMeta.hot augmentation. Out of scope: the setTimeout stale-timer in the vite:beforeUpdate handler is a separate bug left exactly as-is. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<typeof vi.fn<(event: string, cb: (payload: unknown) => void) => void>>;
|
||||
send: ReturnType<typeof vi.fn<(event: string, data?: unknown) => void>>;
|
||||
handlers: Map<string, (payload: unknown) => void>;
|
||||
};
|
||||
|
||||
function makeFakeHot(): FakeHot {
|
||||
const handlers = new Map<string, (payload: unknown) => 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");
|
||||
});
|
||||
@@ -15,15 +15,41 @@ export const DevStateKey: InjectionKey<ShallowRef<DevState>> = Symbol("DevState"
|
||||
|
||||
export const devState = shallowRef<DevState>({ 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");
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user