File size: 8,003 Bytes
fa9c65f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 | // 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<void> => {
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<typeof setInterval>);
}
};
}
|