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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user