app/src/utils/staleUiGuard.tstypescript
/**
* Stale UI detection utility
* @module Util:StaleUiGuard
*/
import env from "./env";
const VERSION_URL = "/version.json";
const RELOAD_GUARD_KEY = "choreo-stale-ui-reloaded";
interface BuildVersionMarker {
version?: string;
}
/**
* Reloads the app when the running bundle is older than the one the server
* actually serves.
*
* This is the safety net for clients whose service worker serves a stale
* precached index.html: the guard compares the version baked into this bundle
* against the build marker emitted into dist by vite, so it only fires when the
* deployed frontend really moved on. It deliberately does not use the backend
* /version endpoint - an API-only deploy would otherwise purge the caches and
* reload the app on every single page view.
*/
export async function reloadIfUiIsStale(): Promise<void> {
if (!env.PROD || typeof window === "undefined") return;
// Only a controlled client can be holding a stale precached shell.
if (!("serviceWorker" in navigator) || !navigator.serviceWorker.controller)
return;
// Hard guard: at most one forced reload per tab session, so a marker that
// stays stale for any reason cannot turn into a reload loop.
try {
if (sessionStorage.getItem(RELOAD_GUARD_KEY)) return;
} catch (e) {
// ignore
}
const currentVersion = env.VITE_VERSION;
if (!currentVersion) return;
let deployedVersion: string | undefined;
try {
const response = await fetch(VERSION_URL, { cache: "no-store" });
if (!response.ok) return;
const marker = (await response.json()) as BuildVersionMarker;
deployedVersion = marker.version;
} catch (e) {
return;
}
if (!deployedVersion || deployedVersion === currentVersion) return;
let guardPersisted = false;
try {
sessionStorage.setItem(RELOAD_GUARD_KEY, "1");
guardPersisted = true;
} catch (e) {
// ignore
}
// If the guard cannot be persisted we cannot prove the reload is a one-off,
// so leave the client alone rather than risk a reload loop.
if (!guardPersisted) return;
try {
const keys = await caches.keys();
await Promise.all(
keys
.filter((key) => key.startsWith("workbox-"))
.map((key) => caches.delete(key))
);
} catch (e) {
// ignore
}
try {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map((r) => r.unregister()));
} catch (e) {
// ignore
}
window.location.reload();
}