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:
Yunfei He
2026-06-14 21:22:30 +08:00
committed by GitHub
parent 8a2efdfeef
commit 27a63b3b2b
2 changed files with 125 additions and 1 deletions
@@ -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();
}
});