diff --git a/packages/dashboard/app/__tests__/versionCheck.test.ts b/packages/dashboard/app/__tests__/versionCheck.test.ts index c28444cc5e..a823306518 100644 --- a/packages/dashboard/app/__tests__/versionCheck.test.ts +++ b/packages/dashboard/app/__tests__/versionCheck.test.ts @@ -7,12 +7,14 @@ import { handleChunkLoadError, reloadOnce, checkVersion, + installVersionCheck, consumeVersionUpdateFlag, _resetCheckState, _resetState, setAutoReloadEnabled, _isAutoReloadEnabled, MIN_CHECK_INTERVAL_MS, + POLL_INTERVAL_MS, _resetMismatchState, } from "../versionCheck"; import { clearTraces, getTraces } from "../utils/dashboardTraceBuffer"; @@ -259,6 +261,119 @@ describe("checkVersion cooldown + mismatch gating", () => { }); }); +describe("installVersionCheck periodic polling", () => { + const reloadSpy = vi.fn(); + + function versionResponse(version: string) { + return { + ok: true, + headers: new Headers({ "content-type": "application/json" }), + json: () => Promise.resolve({ version }), + }; + } + + function settingsResponse() { + return { + ok: true, + headers: new Headers({ "content-type": "application/json" }), + json: () => Promise.resolve({ autoReloadOnVersionChange: true }), + }; + } + + beforeEach(() => { + vi.useFakeTimers(); + vi.stubEnv("PROD", true); + vi.stubGlobal("location", { reload: reloadSpy }); + window.sessionStorage.clear(); + reloadSpy.mockClear(); + _resetState(); + clearTraces(); + Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true }); + }); + + afterEach(() => { + _resetState(); + vi.useRealTimers(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it("sets up a periodic interval that calls checkVersion with the poll trigger", async () => { + const fetchSpy = vi.fn() + .mockResolvedValueOnce(settingsResponse()) + .mockResolvedValueOnce(versionResponse("test-build-abc123")) + .mockResolvedValueOnce(versionResponse("different-version")); + vi.stubGlobal("fetch", fetchSpy); + + installVersionCheck(); + await vi.advanceTimersByTimeAsync(2_000); + clearTraces(); + + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS - 2_000); + + expect(fetchSpy).toHaveBeenCalledTimes(3); + const mismatchTrace = getTraces().find((entry) => entry.event === "mismatch"); + expect(mismatchTrace?.detail).toMatchObject({ trigger: "poll", remote: "different-version" }); + expect(reloadSpy).not.toHaveBeenCalled(); + }); + + it("polling detects a confirmed version mismatch and triggers reload", async () => { + const fetchSpy = vi.fn() + .mockResolvedValueOnce(settingsResponse()) + .mockResolvedValueOnce(versionResponse("test-build-abc123")) + .mockResolvedValueOnce(versionResponse("different-version")) + .mockResolvedValueOnce(versionResponse("different-version")); + vi.stubGlobal("fetch", fetchSpy); + + installVersionCheck(); + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS); + expect(reloadSpy).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS); + + expect(reloadSpy).toHaveBeenCalledTimes(1); + expect(window.sessionStorage.getItem("fusion:version-update")).toBe("1"); + const confirmedTrace = getTraces().find((entry) => entry.event === "mismatch-confirmed"); + expect(confirmedTrace?.detail).toMatchObject({ trigger: "poll", remote: "different-version" }); + }); + + it("cleans up the polling interval when state is reset", async () => { + const fetchSpy = vi.fn() + .mockResolvedValueOnce(settingsResponse()) + .mockResolvedValueOnce(versionResponse("test-build-abc123")); + vi.stubGlobal("fetch", fetchSpy); + + installVersionCheck(); + await vi.advanceTimersByTimeAsync(2_000); + expect(fetchSpy).toHaveBeenCalledTimes(2); + + _resetState(); + fetchSpy.mockClear(); + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("polling respects MIN_CHECK_INTERVAL_MS cooldown", async () => { + const fetchSpy = vi.fn() + .mockResolvedValueOnce(settingsResponse()) + .mockResolvedValue(versionResponse("test-build-abc123")); + vi.stubGlobal("fetch", fetchSpy); + + installVersionCheck(); + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS - 1); + expect(fetchSpy).toHaveBeenCalledTimes(2); // settings + initial check + + await checkVersion("focus"); + expect(fetchSpy).toHaveBeenCalledTimes(3); + + await vi.advanceTimersByTimeAsync(1); + + expect(fetchSpy).toHaveBeenCalledTimes(3); + expect(reloadSpy).not.toHaveBeenCalled(); + }); +}); + describe("autoReloadOnVersionChange setting", () => { const reloadSpy = vi.fn(); diff --git a/packages/dashboard/app/versionCheck.ts b/packages/dashboard/app/versionCheck.ts index 6a0e1f4b0a..00465cf40d 100644 --- a/packages/dashboard/app/versionCheck.ts +++ b/packages/dashboard/app/versionCheck.ts @@ -25,11 +25,15 @@ export function _isAutoReloadEnabled(): boolean { return autoReloadEnabled; } -/** Exported for testing — resets internal state. */ +/** Exported for testing — resets internal state and clears installed polling. */ export function _resetState(): void { lastCheckTime = 0; checkInFlight = false; autoReloadEnabled = true; + if (pollIntervalId !== null) { + window.clearInterval(pollIntervalId); + pollIntervalId = null; + } _resetMismatchState(); } @@ -121,10 +125,15 @@ async function bootstrapAutoReloadSetting(): Promise { } export const MIN_CHECK_INTERVAL_MS = 60_000; // 1 minute +export const POLL_INTERVAL_MS = 5 * 60_000; // 5 minutes + +type VersionCheckTrigger = "visibilitychange" | "focus" | "initial" | "poll"; + let lastCheckTime = 0; let checkInFlight = false; let lastMismatchedRemote: string | null = null; let lastMismatchAt = 0; +let pollIntervalId: number | null = null; /** Exported for testing — resets internal cooldown state */ export function _resetCheckState(): void { @@ -138,7 +147,7 @@ export function _resetMismatchState(): void { lastMismatchAt = 0; } -export async function checkVersion(trigger: "visibilitychange" | "focus" | "initial" = "initial"): Promise { +export async function checkVersion(trigger: VersionCheckTrigger = "initial"): Promise { if (checkInFlight || document.visibilityState !== "visible") return; if (Date.now() - lastCheckTime < MIN_CHECK_INTERVAL_MS) return; lastCheckTime = Date.now(); @@ -195,6 +204,15 @@ export async function checkVersion(trigger: "visibilitychange" | "focus" | "init } } +/** + * Install production version-change detection. + * + * The checker runs on initial load, tab visibility/focus events, and a + * lightweight periodic poll so foreground tabs detect newly deployed bundles + * even when the user never switches away. `checkVersion()` applies visibility + * and cooldown guards, so the interval does not fetch for hidden tabs or more + * frequently than the minimum check interval. + */ export function installVersionCheck(): void { if (!import.meta.env.PROD) return; // Fetch settings to apply auto-reload guard before first version check. @@ -209,4 +227,9 @@ export function installVersionCheck(): void { }); // Initial check after load to catch tabs restored from bfcache. window.setTimeout(() => void checkVersion("initial"), 2_000); + if (pollIntervalId === null) { + pollIntervalId = window.setInterval(() => { + void checkVersion("poll"); + }, POLL_INTERVAL_MS); + } }