fix(runtime): clear stale HMR update timer so newer updates aren't reset early (#177)
Each vite:beforeUpdate scheduled an unconditional setTimeout to reset the dev status from "update" back to "ok" after 2s, but never stored or cleared the handle. Rapid successive updates stacked independent timers; an earlier update's timer firing while a later update was still showing would reset the newer status line early (its guard only checked type === "update", which is still true for the newer update). Track the pending timer in a module-level variable, clear it at the top of vite:beforeUpdate before scheduling a new one (so only the latest update's timer is ever live), and clear it on vite:error (an error supersedes a pending update->ok reset). Also unref() the timer so it doesn't hold the event loop open; .unref is optional since the DOM number handle lacks it. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
// SEQUENTIAL: this test uses vi.useFakeTimers(), which mutates the process-global
|
||||
// setTimeout/clearTimeout. File-level parallelism could perturb other tests'
|
||||
// timer assertions, so it must run in a *.sequential.test.* file. It also
|
||||
// re-imports hmr.ts via vi.resetModules() to reset the module-level
|
||||
// idempotency guard + pending-timer state between cases (the module cache is
|
||||
// process-global), matching unit/hmr-bridge-idempotent.sequential.test.ts.
|
||||
import { afterEach, expect, test, vi } from "vite-plus/test";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
// `on`/`send` are typed with their real signatures so FakeHot structurally
|
||||
// satisfies the HotContext param of initHmrBridge. `handlers` is test-only
|
||||
// state for firing the registered 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) => {
|
||||
handlers.set(event, cb);
|
||||
});
|
||||
const send = vi.fn<(event: string, data?: unknown) => void>();
|
||||
return { on, send, handlers };
|
||||
}
|
||||
|
||||
function updatePayload(paths: string[]): { updates: Array<{ path: string }> } {
|
||||
return { updates: paths.map((path) => ({ path })) };
|
||||
}
|
||||
|
||||
test("a stale update timer does not reset a newer update's status early", async () => {
|
||||
vi.resetModules();
|
||||
const { initHmrBridge, devState } = await import("../../runtime/src/hmr.ts");
|
||||
const hot = makeFakeHot();
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
initHmrBridge(hot);
|
||||
const beforeUpdate = hot.handlers.get("vite:beforeUpdate");
|
||||
expect(beforeUpdate).toBeTypeOf("function");
|
||||
|
||||
// t=0: update #1 schedules a reset for t=2000.
|
||||
beforeUpdate!(updatePayload(["a"]));
|
||||
expect(devState.value).toEqual({ type: "update", paths: ["a"] });
|
||||
|
||||
// t=1500: update #2 schedules a reset for t=3500. The bug: update #1's
|
||||
// timer is still live and will fire at t=2000.
|
||||
vi.advanceTimersByTime(1500);
|
||||
beforeUpdate!(updatePayload(["b"]));
|
||||
expect(devState.value).toEqual({ type: "update", paths: ["b"] });
|
||||
|
||||
// t=2000: update #1's stale timer fires. With the bug it sees type==="update"
|
||||
// (now update #2) and resets to "ok", clearing update #2's status 1500ms early.
|
||||
// After the fix update #1's timer was cleared, so the status must persist.
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(devState.value).toEqual({ type: "update", paths: ["b"] });
|
||||
|
||||
// t=3500: update #2's own timer fires and resets to "ok".
|
||||
vi.advanceTimersByTime(1500);
|
||||
expect(devState.value).toEqual({ type: "ok" });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("vite:error clears a pending update→ok reset so the error status persists", async () => {
|
||||
vi.resetModules();
|
||||
const { initHmrBridge, devState } = await import("../../runtime/src/hmr.ts");
|
||||
const hot = makeFakeHot();
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
initHmrBridge(hot);
|
||||
const beforeUpdate = hot.handlers.get("vite:beforeUpdate");
|
||||
const onError = hot.handlers.get("vite:error");
|
||||
expect(beforeUpdate).toBeTypeOf("function");
|
||||
expect(onError).toBeTypeOf("function");
|
||||
|
||||
// An update schedules a reset for t=2000...
|
||||
beforeUpdate!(updatePayload(["a"]));
|
||||
expect(devState.value).toEqual({ type: "update", paths: ["a"] });
|
||||
|
||||
// ...but an error supersedes it. The pending reset must be cleared so it can't
|
||||
// later overwrite the error status with "ok".
|
||||
onError!({ err: { message: "boom" } });
|
||||
expect(devState.value).toEqual({ type: "error", error: { message: "boom" } });
|
||||
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(devState.value).toEqual({ type: "error", error: { message: "boom" } });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
@@ -37,6 +37,15 @@ const realHot = (import.meta as { hot?: HotContext }).hot;
|
||||
// calls would leak N copies of every handler — firing each HMR event N times.
|
||||
let initialized = false;
|
||||
|
||||
// Handle for the pending "update → ok" reset. At most ONE may be live at a time:
|
||||
// rapid successive updates would otherwise STACK independent timers, and an
|
||||
// earlier update's timer firing while a later update is still showing would wipe
|
||||
// the newer status line early (its guard only checks type === "update", which is
|
||||
// still true for the newer update). We therefore clear the previous timer before
|
||||
// scheduling a new one. setTimeout's return type differs (number in DOM,
|
||||
// Timeout in Node), so use ReturnType<typeof setTimeout>.
|
||||
let pendingResetTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
// `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 {
|
||||
@@ -45,21 +54,38 @@ export function initHmrBridge(hot: HotContext | undefined = realHot): void {
|
||||
initialized = true;
|
||||
|
||||
hot.on("vite:error", (payload: unknown) => {
|
||||
// An error supersedes any pending update → ok reset; clear it so a stale
|
||||
// timer can't later overwrite the error status with "ok".
|
||||
if (pendingResetTimer !== undefined) {
|
||||
clearTimeout(pendingResetTimer);
|
||||
pendingResetTimer = undefined;
|
||||
}
|
||||
const p = payload as { err: DevErrorInfo };
|
||||
devState.value = { type: "error", error: p.err };
|
||||
});
|
||||
|
||||
hot.on("vite:beforeUpdate", (payload: unknown) => {
|
||||
// Cancel the previous update's reset so only the LATEST update's timer is
|
||||
// live — otherwise a stacked earlier timer resets this newer status early.
|
||||
if (pendingResetTimer !== undefined) {
|
||||
clearTimeout(pendingResetTimer);
|
||||
pendingResetTimer = undefined;
|
||||
}
|
||||
const p = payload as { updates: Array<{ path: string }> };
|
||||
devState.value = {
|
||||
type: "update",
|
||||
paths: p.updates.map((u) => u.path),
|
||||
};
|
||||
setTimeout(() => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingResetTimer = undefined;
|
||||
if (devState.value.type === "update") {
|
||||
devState.value = { type: "ok" };
|
||||
}
|
||||
}, 2000);
|
||||
pendingResetTimer = timer;
|
||||
// Don't hold the event loop open for a transient status reset. .unref() only
|
||||
// exists on Node's Timeout (not the DOM number), so call it optionally.
|
||||
timer.unref?.();
|
||||
});
|
||||
|
||||
hot.on("vite:beforeFullReload", () => {
|
||||
|
||||
Reference in New Issue
Block a user