// Force-reload tabs running a stale frontend bundle when a newer deploy is // live. Catches the class of bug where users keep a tab open across a // wire-shape change (e.g. PR #3466 fixing the setPreferences CONFLICT // propagation) and end up in a permanent retry loop against the new server // because their JS doesn't understand the new response shape. // // Mechanism: on tab focus, fetch /build-hash.txt (a static asset emitted by // the Vite plugin in vite.config.ts at build time, content = the deployed // SHA) and compare against __BUILD_HASH__ baked into the running bundle. // Mismatch → hard reload. // // /build-hash.txt is intentionally NOT under /api/* so installWebApiRedirect // does NOT rewrite it to the canonical API host — it stays same-origin with // the bundle, which is the correct comparison target. interface EventTargetLike { addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void; removeEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void; } interface DocumentLike { addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void; removeEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void; visibilityState?: string; } interface StaleBundleCheckOptions { /** Hash baked into the running bundle (default: __BUILD_HASH__). */ currentHash?: string; /** Override fetch (for tests). Defaults to global fetch. */ fetch?: typeof globalThis.fetch; /** Override window-level event target (default: window). */ eventTarget?: EventTargetLike; /** Override document for visibilitychange (default: document). */ documentTarget?: DocumentLike; /** Override setInterval (for tests). Default: globalThis.setInterval. */ setInterval?: (cb: () => void, ms: number) => unknown; /** Override reload (for tests). Default: window.location.reload(). */ reload?: () => void; /** Override clock (for tests). Default: Date.now. */ now?: () => number; /** * Minimum interval between checks. Multiple events within this window * collapse to one fetch. */ minIntervalMs?: number; /** * Wall-clock cadence of the periodic background check. Catches stuck * tabs that never fire focus/visibilitychange (e.g. a tab pinned in * the background of another window). Browsers throttle background * setIntervals to ~1min minimum resolution, so values below that are * effectively the same as 60_000ms. */ periodicIntervalMs?: number; } const DEFAULT_MIN_INTERVAL_MS = 60_000; /** Wall-clock periodic check. 10min is plenty for stale-bundle detection * (we don't need second-level latency to reload an old bundle) and * respects browser background-tab throttling. */ const DEFAULT_PERIODIC_INTERVAL_MS = 10 * 60_000; /** * Install listeners that compare the running bundle's hash against the * deployed hash and reload on mismatch. Three trigger paths: * 1. window `focus` — user switches BACK to the tab * 2. document `visibilitychange` — tab goes background→foreground * (fires for some background→foreground transitions that don't * raise window focus, e.g. tab activation within the same window) * 3. periodic setInterval — catches tabs pinned in the background of * another window that never receive focus/visibilitychange. Browser * background throttling means actual cadence is ~1min minimum, but * that's fine for stale-bundle detection. * * All three paths funnel through a `check()` that's deduped by * `minIntervalMs` — multiple triggers within the dedupe window collapse * to one fetch. * * The `focus`-only design from PR #3499 missed background-tab users: * one user (Sentry user_id user_3Cu7uZZJEeVSoUjv9SBn4BEv1...) hammered * setPreferences at ~16 calls/min from 2026-04-30 04:20 UTC onward with * a constant `actualSyncVersion: 20`, never refocusing the tab and so * never triggering the reload. Adding visibilitychange + setInterval * closes the gap. * * Returns a disposer function that clears the periodic timer (used in * tests). */ export function installStaleBundleCheck(options: StaleBundleCheckOptions = {}): () => void { const currentHash = options.currentHash ?? (typeof __BUILD_HASH__ !== 'undefined' ? __BUILD_HASH__ : 'dev'); // Arrow-function wrapper instead of fetch.bind(globalThis) (banned per // AGENTS.md §Critical Conventions). Same effect — preserves the global // `this` for fetch — without the brittle .bind() form. const fetchImpl: typeof globalThis.fetch = options.fetch ?? ((...args) => globalThis.fetch(...args)); const eventTarget = options.eventTarget ?? window; const documentTarget = options.documentTarget ?? (typeof document !== 'undefined' ? document : undefined); const setIntervalImpl = options.setInterval ?? ((cb: () => void, ms: number) => globalThis.setInterval(cb, ms)); const reload = options.reload ?? (() => window.location.reload()); const now = options.now ?? Date.now; const minIntervalMs = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS; const periodicIntervalMs = options.periodicIntervalMs ?? DEFAULT_PERIODIC_INTERVAL_MS; // 'dev' marker means we're running a local build that didn't get a real // SHA injected. Skip the check entirely in that case — comparing 'dev' // against any deployed SHA would force-reload every dev tab on focus. if (currentHash === 'dev') { return () => {}; } let lastCheckedAt = 0; let inflight = false; const check = async (): Promise => { const t = now(); if (t - lastCheckedAt < minIntervalMs) return; if (inflight) return; lastCheckedAt = t; inflight = true; try { // Cache-bust to defeat any intermediate proxy that might serve a // stale build-hash.txt (the file itself is emitted with the deploy). const res = await fetchImpl(`/build-hash.txt?t=${t}`, { cache: 'no-store' }); if (!res.ok) return; const deployedHash = (await res.text()).trim(); if (!deployedHash || deployedHash === 'dev') return; if (deployedHash !== currentHash) { // eslint-disable-next-line no-console console.warn('[stale-bundle] reload:', currentHash, '→', deployedHash); reload(); } } catch { // Offline, network error, or non-OK response — silently skip. // The next trigger will retry. } finally { inflight = false; } }; const focusHandler: EventListener = () => { void check(); }; eventTarget.addEventListener('focus', focusHandler); // visibilitychange fires when the tab becomes visible again. We only // want to trigger on the visible side (not on hide), so gate with the // documentTarget's visibilityState. Closure references documentTarget so // the predicate uses the live state at fire time, not at install time. const visibilityHandler: EventListener = documentTarget ? () => { if (documentTarget.visibilityState === 'visible') void check(); } : () => {}; if (documentTarget) { documentTarget.addEventListener('visibilitychange', visibilityHandler); } // Periodic safety net for background tabs that never receive // focus/visibilitychange (e.g. pinned in a background window). const intervalHandle = setIntervalImpl(() => void check(), periodicIntervalMs); return () => { // Full cleanup. Production currently calls installStaleBundleCheck // exactly once at boot, but a complete disposer protects against // future hot-reload / test-helper reuse where double-install would // otherwise leave orphaned listeners firing against stale targets. eventTarget.removeEventListener('focus', focusHandler); if (documentTarget) { documentTarget.removeEventListener('visibilitychange', visibilityHandler); } if (intervalHandle && typeof clearInterval === 'function') { clearInterval(intervalHandle as unknown as ReturnType); } }; }