diff --git a/.github/workflows/deploy-qa.yml b/.github/workflows/deploy-qa.yml
index 968e89f7..a3d0e6de 100644
--- a/.github/workflows/deploy-qa.yml
+++ b/.github/workflows/deploy-qa.yml
@@ -74,6 +74,7 @@ jobs:
HUSKY: "0"
VITE_RUN_ENVIRONMENT: dev
RunEnvironment: dev
+ VITE_BUILD_HASH: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Upload Build files
uses: actions/upload-artifact@v7
diff --git a/package.json b/package.json
index dea401a2..0740ae2e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "infra-core",
- "version": "4.9.6",
+ "version": "4.9.7",
"private": true,
"type": "module",
"workspaces": [
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index 2459119f..e3f2b2c5 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -2,14 +2,16 @@ import "@mantine/core/styles.css";
import "@mantine/dropzone/styles.css";
import "@mantine/notifications/styles.css";
import "@mantine/dates/styles.css";
-import { MantineProvider } from "@mantine/core";
+import { Button, Group, MantineProvider, Text } from "@mantine/core";
import { cssVariablesResolver, theme } from "./theme";
import { useColorScheme, useLocalStorage } from "@mantine/hooks";
-import { Notifications } from "@mantine/notifications";
+import { Notifications, notifications } from "@mantine/notifications";
+import { useEffect } from "react";
import ColorSchemeContext from "./ColorSchemeContext";
import { Router } from "./Router";
import { UserResolverProvider } from "./components/NameOptionalCard";
+import { forceRefresh, startVersionPolling } from "./versionCheck";
export default function App() {
const preferredColorScheme = useColorScheme();
@@ -17,6 +19,26 @@ export default function App() {
key: "acm-manage-color-scheme",
defaultValue: preferredColorScheme,
});
+
+ useEffect(() => {
+ startVersionPolling(() => {
+ notifications.show({
+ id: "version-update",
+ title: "Update available",
+ message: (
+
+ A new version of the app is available.
+
+
+ ),
+ color: "blue",
+ autoClose: false,
+ });
+ });
+ }, []);
+
return (
json -> callback chain needs a few ticks)
+const flushPromises = async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+};
+
+function makeResponse(
+ version: string | null,
+ opts: { ok?: boolean; contentType?: string } = {},
+) {
+ const { ok = true, contentType = "application/json" } = opts;
+ return Promise.resolve({
+ ok,
+ headers: { get: () => contentType },
+ json: () => Promise.resolve(version !== null ? { version } : {}),
+ } as unknown as Response);
+}
+
+describe("versionCheck", () => {
+ let startVersionPolling: (onUpdate?: () => void) => void;
+ let forceRefresh: () => void;
+ let reloadSpy: ReturnType;
+ const registeredListeners: Array<
+ [string, EventListenerOrEventListenerObject]
+ > = [];
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.useFakeTimers();
+ vi.stubEnv("VITE_BUILD_HASH", CURRENT);
+ vi.spyOn(console, "warn").mockImplementation(() => {});
+
+ // Track visibilitychange listeners so we can remove them between tests
+ const origAdd = document.addEventListener.bind(document);
+ vi.spyOn(document, "addEventListener").mockImplementation(
+ (event, handler, ...args) => {
+ if (event === "visibilitychange") {
+ registeredListeners.push([event, handler]);
+ }
+ return origAdd(event, handler, ...(args as []));
+ },
+ );
+
+ reloadSpy = vi.fn();
+ Object.defineProperty(window, "location", {
+ configurable: true,
+ value: { reload: reloadSpy },
+ });
+ Object.defineProperty(window, "caches", {
+ configurable: true,
+ value: { keys: vi.fn().mockResolvedValue([]), delete: vi.fn() },
+ });
+
+ const mod = await import("./versionCheck");
+ startVersionPolling = mod.startVersionPolling;
+ forceRefresh = mod.forceRefresh;
+ });
+
+ afterEach(() => {
+ // Remove any listeners registered during the test to prevent cross-test bleed
+ registeredListeners.forEach(([event, handler]) =>
+ document.removeEventListener(event, handler),
+ );
+ registeredListeners.length = 0;
+ vi.useRealTimers();
+ vi.unstubAllEnvs();
+ vi.restoreAllMocks();
+ });
+
+ describe("startVersionPolling", () => {
+ it("runs an immediate check on startup", async () => {
+ global.fetch = vi.fn().mockReturnValue(makeResponse(CURRENT));
+ startVersionPolling();
+ await flushPromises();
+ expect(global.fetch).toHaveBeenCalledOnce();
+ });
+
+ it("rechecks every 5 minutes after the initial check", async () => {
+ global.fetch = vi.fn().mockReturnValue(makeResponse(CURRENT));
+ startVersionPolling();
+ await flushPromises();
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+
+ await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
+ expect(global.fetch).toHaveBeenCalledTimes(2);
+
+ await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
+ expect(global.fetch).toHaveBeenCalledTimes(3);
+ });
+
+ it("registers a visibilitychange listener", () => {
+ global.fetch = vi.fn().mockReturnValue(new Promise(() => {}));
+ const addSpy = vi.spyOn(document, "addEventListener");
+ startVersionPolling();
+ expect(addSpy).toHaveBeenCalledWith(
+ "visibilitychange",
+ expect.any(Function),
+ );
+ });
+ });
+
+ describe("checkForUpdate", () => {
+ it("does nothing when version matches current", async () => {
+ const onUpdate = vi.fn();
+ global.fetch = vi.fn().mockReturnValue(makeResponse(CURRENT));
+ startVersionPolling(onUpdate);
+ await flushPromises();
+ expect(onUpdate).not.toHaveBeenCalled();
+ expect(reloadSpy).not.toHaveBeenCalled();
+ });
+
+ it("calls onUpdate when a newer version is detected", async () => {
+ const onUpdate = vi.fn();
+ global.fetch = vi.fn().mockReturnValue(makeResponse(NEWER));
+ startVersionPolling(onUpdate);
+ await flushPromises();
+ expect(onUpdate).toHaveBeenCalledOnce();
+ expect(reloadSpy).not.toHaveBeenCalled();
+ });
+
+ it("calls forceRefresh when version differs and no callback is set", async () => {
+ global.fetch = vi.fn().mockReturnValue(makeResponse(NEWER));
+ startVersionPolling();
+ await flushPromises();
+ expect(reloadSpy).toHaveBeenCalledOnce();
+ });
+
+ it("does nothing when CURRENT_VERSION is undefined (unbuilt dev asset)", async () => {
+ vi.unstubAllEnvs();
+ vi.resetModules();
+ const mod = await import("./versionCheck");
+ const onUpdate = vi.fn();
+ global.fetch = vi.fn().mockReturnValue(makeResponse(NEWER));
+ mod.startVersionPolling(onUpdate);
+ await flushPromises();
+ expect(onUpdate).not.toHaveBeenCalled();
+ expect(reloadSpy).not.toHaveBeenCalled();
+ });
+
+ it("stops polling after an update is detected", async () => {
+ const onUpdate = vi.fn();
+ global.fetch = vi.fn().mockReturnValue(makeResponse(NEWER));
+ startVersionPolling(onUpdate);
+ await flushPromises();
+ expect(onUpdate).toHaveBeenCalledTimes(1);
+
+ await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
+ expect(onUpdate).toHaveBeenCalledTimes(1);
+ });
+
+ it("stops polling when response is not JSON", async () => {
+ global.fetch = vi
+ .fn()
+ .mockReturnValue(makeResponse(null, { contentType: "text/html" }));
+ startVersionPolling();
+ await flushPromises();
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+
+ await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it("does nothing when response is not ok", async () => {
+ const onUpdate = vi.fn();
+ global.fetch = vi
+ .fn()
+ .mockReturnValue(makeResponse(NEWER, { ok: false }));
+ startVersionPolling(onUpdate);
+ await flushPromises();
+ expect(onUpdate).not.toHaveBeenCalled();
+ expect(reloadSpy).not.toHaveBeenCalled();
+ });
+
+ it("keeps polling after fewer than 10 fetch failures", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("network error"));
+ startVersionPolling();
+
+ for (let i = 0; i < 5; i++) {
+ await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
+ }
+
+ // 1 immediate + 5 interval = 6 total calls, polling still active
+ expect(global.fetch).toHaveBeenCalledTimes(6);
+ });
+
+ it("stops polling after 10 consecutive failures", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("network error"));
+ startVersionPolling();
+
+ // 1 immediate + 10 interval ticks exhausts the counter
+ for (let i = 0; i < 11; i++) {
+ await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
+ }
+
+ const callCount = (global.fetch as ReturnType).mock.calls
+ .length;
+ await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
+ expect((global.fetch as ReturnType).mock.calls.length).toBe(
+ callCount,
+ );
+ });
+ });
+
+ describe("visibilitychange listener", () => {
+ it("triggers a check when the tab becomes visible", async () => {
+ global.fetch = vi.fn().mockReturnValue(makeResponse(CURRENT));
+ startVersionPolling();
+ await flushPromises();
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+
+ Object.defineProperty(document, "visibilityState", {
+ configurable: true,
+ value: "visible",
+ });
+ document.dispatchEvent(new Event("visibilitychange"));
+ await flushPromises();
+
+ expect(global.fetch).toHaveBeenCalledTimes(2);
+ });
+
+ it("does not trigger a check when the tab becomes hidden", async () => {
+ global.fetch = vi.fn().mockReturnValue(makeResponse(CURRENT));
+ startVersionPolling();
+ await flushPromises();
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+
+ Object.defineProperty(document, "visibilityState", {
+ configurable: true,
+ value: "hidden",
+ });
+ document.dispatchEvent(new Event("visibilitychange"));
+ await flushPromises();
+
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("forceRefresh", () => {
+ it("reloads the page", () => {
+ forceRefresh();
+ expect(reloadSpy).toHaveBeenCalledOnce();
+ });
+
+ it("deletes all caches before reloading", async () => {
+ const deleteSpy = vi.fn();
+ Object.defineProperty(window, "caches", {
+ configurable: true,
+ value: {
+ keys: vi.fn().mockResolvedValue(["v1", "v2"]),
+ delete: deleteSpy,
+ },
+ });
+
+ forceRefresh();
+ await flushPromises();
+
+ expect(deleteSpy).toHaveBeenCalledWith("v1");
+ expect(deleteSpy).toHaveBeenCalledWith("v2");
+ expect(reloadSpy).toHaveBeenCalledOnce();
+ });
+ });
+});
diff --git a/src/ui/versionCheck.ts b/src/ui/versionCheck.ts
new file mode 100644
index 00000000..708eeb54
--- /dev/null
+++ b/src/ui/versionCheck.ts
@@ -0,0 +1,72 @@
+const CURRENT_VERSION = import.meta.env.VITE_BUILD_HASH;
+
+let intervalId: any;
+let fetchFailures = 0;
+let onUpdateCallback: (() => void) | null = null;
+
+async function checkForUpdate() {
+ try {
+ const res = await fetch("/version.json", { cache: "no-store" });
+
+ if (!res.ok) {
+ return;
+ }
+
+ const contentType = res.headers.get("content-type") || "";
+ if (!contentType.includes("application/json")) {
+ // SPA fallback returned HTML or something else — version.json isn't deployed
+ console.warn("Invalid response for version.json");
+ stopPolling();
+ return;
+ }
+
+ const { version } = await res.json();
+ fetchFailures = 0;
+ if (version && CURRENT_VERSION && version !== CURRENT_VERSION) {
+ stopPolling();
+ if (onUpdateCallback) {
+ onUpdateCallback();
+ } else {
+ forceRefresh();
+ }
+ }
+ } catch (e) {
+ fetchFailures++;
+ if (fetchFailures > 10) {
+ console.warn("Failed to fetch version 10 times; giving up.");
+ stopPolling();
+ } else {
+ console.warn("Failed to fetch version of current system: ", e);
+ }
+ }
+}
+
+export function forceRefresh() {
+ if ("caches" in window) {
+ caches.keys().then((keys) => keys.forEach((k) => caches.delete(k)));
+ }
+ window.location.reload();
+}
+
+function stopPolling() {
+ if (intervalId !== null) {
+ clearInterval(intervalId);
+ intervalId = null;
+ }
+ document.removeEventListener("visibilitychange", onVisibilityChange);
+}
+
+function onVisibilityChange() {
+ if (document.visibilityState === "visible") {
+ checkForUpdate();
+ }
+}
+
+export function startVersionPolling(onUpdate?: () => void) {
+ if (onUpdate) {
+ onUpdateCallback = onUpdate;
+ }
+ checkForUpdate();
+ intervalId = setInterval(checkForUpdate, 5 * 60 * 1000);
+ document.addEventListener("visibilitychange", onVisibilityChange);
+}
diff --git a/src/ui/vite.config.mjs b/src/ui/vite.config.mjs
index 8559ae6e..a0bae05f 100644
--- a/src/ui/vite.config.mjs
+++ b/src/ui/vite.config.mjs
@@ -3,9 +3,24 @@ import react from "@vitejs/plugin-react";
import "dotenv/config";
import path from "path";
+const VERSION_PLUGIN = {
+ name: "emit-version-json",
+ apply: "build",
+ generateBundle() {
+ const version = process.env.VITE_BUILD_HASH;
+ if (!version) return;
+
+ this.emitFile({
+ type: "asset",
+ fileName: "version.json",
+ source: JSON.stringify({ version }),
+ });
+ },
+};
+
export default defineConfig({
define: { "process.env": { AWS_REGION: process.env.AWS_REGION } },
- plugins: [react()],
+ plugins: [react(), VERSION_PLUGIN],
resolve: {
tsconfigPaths: true,
preserveSymlinks: true,