FN-6124: add dashboard bundle version polling reload

Add periodic bundle version polling so open dashboard tabs refresh after new deployments.

- add a production polling interval to version checks for visible dashboard tabs
- clear installed polling when version-check state resets to avoid duplicate timers in tests/runtime
- expand versionCheck coverage for poll-triggered mismatch detection, cooldown behavior, and cleanup

Files changed:
 packages/dashboard/app/__tests__/versionCheck.test.ts | 115 +++++++++++++++++++++
 packages/dashboard/app/versionCheck.ts             |  27 ++++-
 2 files changed, 140 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-6124

Fusion-Task-Lineage: a211fc03-06fd-4142-8878-98ac60b70c13
This commit is contained in:
gsxdsm
2026-06-09 14:07:27 -07:00
parent c780470378
commit 19f8d86152
2 changed files with 140 additions and 2 deletions

View File

@@ -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();

View File

@@ -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<void> {
}
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<void> {
export async function checkVersion(trigger: VersionCheckTrigger = "initial"): Promise<void> {
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);
}
}