feat(FN-3333): fix spurious version reloads with deterministic build
Fixes spurious new-version reloads by making the build version deterministic using the git hash and adding a 60-second cooldown to the version check. Includes unit tests for the version check logic. Fusion-Task-Id: FN-3333
This commit is contained in:
171
packages/dashboard/app/__tests__/versionCheck.test.ts
Normal file
171
packages/dashboard/app/__tests__/versionCheck.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
isStaleChunkError,
|
||||
handleChunkLoadError,
|
||||
reloadOnce,
|
||||
checkVersion,
|
||||
consumeVersionUpdateFlag,
|
||||
_resetCheckState,
|
||||
MIN_CHECK_INTERVAL_MS,
|
||||
} from "../versionCheck";
|
||||
|
||||
// Mock __BUILD_VERSION__ (declared as const in the module)
|
||||
vi.stubGlobal("__BUILD_VERSION__", "test-build-abc123");
|
||||
|
||||
describe("isStaleChunkError", () => {
|
||||
it("returns true for known chunk error patterns", () => {
|
||||
expect(isStaleChunkError(new Error("Failed to fetch dynamically imported module: ./foo.js"))).toBe(true);
|
||||
expect(isStaleChunkError(new Error("error loading dynamically imported module"))).toBe(true);
|
||||
expect(isStaleChunkError(new Error("Importing a module script failed"))).toBe(true);
|
||||
expect(isStaleChunkError(new Error("text/html is not a valid JavaScript MIME type"))).toBe(true);
|
||||
expect(isStaleChunkError(new Error("ChunkLoadError: loading chunk foo failed"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for unrelated errors", () => {
|
||||
expect(isStaleChunkError(new Error("Network request failed"))).toBe(false);
|
||||
expect(isStaleChunkError(new Error("TypeError: Cannot read property"))).toBe(false);
|
||||
expect(isStaleChunkError("some random string")).toBe(false);
|
||||
expect(isStaleChunkError(null)).toBe(false);
|
||||
expect(isStaleChunkError(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleChunkLoadError", () => {
|
||||
const reloadSpy = vi.fn();
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("location", { reload: reloadSpy });
|
||||
window.sessionStorage.clear();
|
||||
reloadSpy.mockClear();
|
||||
});
|
||||
|
||||
it("returns true and calls reloadOnce for chunk errors", () => {
|
||||
const result = handleChunkLoadError(new Error("Failed to fetch dynamically imported module: ./foo.js"));
|
||||
expect(result).toBe(true);
|
||||
expect(reloadSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns false for non-chunk errors", () => {
|
||||
const result = handleChunkLoadError(new Error("Network error"));
|
||||
expect(result).toBe(false);
|
||||
expect(reloadSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reloadOnce", () => {
|
||||
const reloadSpy = vi.fn();
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("location", { reload: reloadSpy });
|
||||
window.sessionStorage.clear();
|
||||
reloadSpy.mockClear();
|
||||
});
|
||||
|
||||
it("sets sessionStorage flag and calls window.location.reload()", () => {
|
||||
reloadOnce("test reason");
|
||||
expect(window.sessionStorage.getItem("fusion:version-reload")).toBe("1");
|
||||
expect(reloadSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("suppresses duplicate calls", () => {
|
||||
reloadOnce("first");
|
||||
reloadOnce("second");
|
||||
expect(reloadSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("consumeVersionUpdateFlag", () => {
|
||||
beforeEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
it("returns true once then false (consumes the flag)", () => {
|
||||
window.sessionStorage.setItem("fusion:version-update", "1");
|
||||
expect(consumeVersionUpdateFlag()).toBe(true);
|
||||
expect(consumeVersionUpdateFlag()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when flag is not set", () => {
|
||||
expect(consumeVersionUpdateFlag()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkVersion cooldown", () => {
|
||||
const reloadSpy = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("location", { reload: reloadSpy });
|
||||
window.sessionStorage.clear();
|
||||
reloadSpy.mockClear();
|
||||
_resetCheckState();
|
||||
// Ensure tab is visible
|
||||
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("respects MIN_CHECK_INTERVAL_MS — second call within cooldown is suppressed", async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
json: () => Promise.resolve({ version: "different-version" }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
// First call should go through
|
||||
await checkVersion();
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second call immediately after — should be suppressed by cooldown
|
||||
await checkVersion();
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows check after cooldown elapses", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchSpy = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
json: () => Promise.resolve({ version: "different-version" }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
await checkVersion();
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance time past cooldown
|
||||
vi.advanceTimersByTime(MIN_CHECK_INTERVAL_MS + 1);
|
||||
|
||||
await checkVersion();
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not reload when remote version matches build version", async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
json: () => Promise.resolve({ version: "test-build-abc123" }), // matches stub __BUILD_VERSION__
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
await checkVersion();
|
||||
expect(reloadSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reload when fetch returns null", async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
headers: new Headers(),
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
await checkVersion();
|
||||
expect(reloadSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -59,10 +59,20 @@ async function fetchRemoteVersion(): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
export const MIN_CHECK_INTERVAL_MS = 60_000; // 1 minute
|
||||
let lastCheckTime = 0;
|
||||
let checkInFlight = false;
|
||||
|
||||
async function checkVersion(): Promise<void> {
|
||||
/** Exported for testing — resets internal cooldown state */
|
||||
export function _resetCheckState(): void {
|
||||
lastCheckTime = 0;
|
||||
checkInFlight = false;
|
||||
}
|
||||
|
||||
export async function checkVersion(): Promise<void> {
|
||||
if (checkInFlight || document.visibilityState !== "visible") return;
|
||||
if (Date.now() - lastCheckTime < MIN_CHECK_INTERVAL_MS) return;
|
||||
lastCheckTime = Date.now();
|
||||
checkInFlight = true;
|
||||
try {
|
||||
const remote = await fetchRemoteVersion();
|
||||
|
||||
@@ -1,10 +1,47 @@
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { resolve } from "node:path";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { writeFileSync, readFileSync } from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
const buildVersion = `${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`;
|
||||
/**
|
||||
* Generate a deterministic build version string.
|
||||
*
|
||||
* Uses the short git commit hash + a content hash of key files so the version
|
||||
* only changes when the actual source (or uncommitted changes to those files)
|
||||
* changes. Falls back to package.json version when git is unavailable.
|
||||
*/
|
||||
function computeBuildVersion(): string {
|
||||
// Get git short hash or fall back to package.json version
|
||||
let prefix: string;
|
||||
try {
|
||||
prefix = execSync("git rev-parse --short HEAD", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
||||
} catch {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(resolve(__dirname, "package.json"), "utf-8"));
|
||||
prefix = typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
||||
} catch {
|
||||
prefix = "0.0.0";
|
||||
}
|
||||
}
|
||||
|
||||
// Content hash of key source files — changes when source changes
|
||||
const filesToHash = [resolve(__dirname, "app/main.tsx"), resolve(__dirname, "package.json")];
|
||||
const hasher = createHash("sha1");
|
||||
for (const f of filesToHash) {
|
||||
try {
|
||||
hasher.update(readFileSync(f));
|
||||
} catch {
|
||||
// file may not exist during certain builds — skip
|
||||
}
|
||||
}
|
||||
const contentHash = hasher.digest("hex").slice(0, 8);
|
||||
|
||||
return `${prefix}-${contentHash}`;
|
||||
}
|
||||
|
||||
const buildVersion = computeBuildVersion();
|
||||
|
||||
function emitVersionJson(): Plugin {
|
||||
return {
|
||||
@@ -13,6 +50,7 @@ function emitVersionJson(): Plugin {
|
||||
closeBundle() {
|
||||
const outFile = resolve(__dirname, "dist/client/version.json");
|
||||
writeFileSync(outFile, `${JSON.stringify({ version: buildVersion })}\n`);
|
||||
console.log(`[fusion] build version: ${buildVersion}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user