// Notices a new server build while the page is open, so the user can reload (phase 10). // The page remembers the build at load and asks again on window focus, when the event stream reconnects // (a deploy restarts the server and drops the stream) and every few minutes. It never reloads by itself: // a form may be half filled. import { useEffect, useState } from "react"; import { request } from "../api/client"; export const BUILD_CHECK_MS = 5 * 60_000; /** Focus and reconnects can come in bursts. One check per this many ms is enough. */ export const BUILD_MIN_GAP_MS = 10_000; /** The build id from /api/version: `build`, else `version`. Null when neither is there. */ export function buildOf(raw: unknown): string | null { if (!raw || typeof raw !== "object") return null; const o = raw as Record; for (const v of [o.build, o.version]) if (typeof v === "string" && v.trim() !== "") return v.trim(); return null; } const reconnectListeners = new Set<() => void>(); /** The event stream calls this when it opens again after a drop. */ export function streamReconnected(): void { for (const fn of reconnectListeners) fn(); } export function onStreamReconnect(fn: () => void): () => void { reconnectListeners.add(fn); return () => { reconnectListeners.delete(fn); }; } export interface BuildWatchOptions { intervalMs?: number; minGapMs?: number; } /** True once /api/version answers with a build other than the one the page loaded with. Stays true. */ export function useBuildChanged({ intervalMs = BUILD_CHECK_MS, minGapMs = BUILD_MIN_GAP_MS }: BuildWatchOptions = {}): boolean { const [changed, setChanged] = useState(false); useEffect(() => { const ctl = new AbortController(); let base: string | null = null; let busy = false; let done = false; let last = Number.NEGATIVE_INFINITY; const check = async () => { const now = Date.now(); if (busy || done || now - last < minGapMs) return; busy = true; last = now; try { const b = buildOf(await request("/api/version", { signal: ctl.signal })); if (!b || ctl.signal.aborted) return; if (base === null) base = b; else if (b !== base) { done = true; setChanged(true); } } catch { // Offline or restarting. The next focus, reconnect or tick asks again, with no wait. last = Number.NEGATIVE_INFINITY; } finally { busy = false; } }; const kick = () => void check(); const onVisible = () => { if (document.visibilityState === "visible") kick(); }; kick(); window.addEventListener("focus", kick); document.addEventListener("visibilitychange", onVisible); const offReconnect = onStreamReconnect(kick); const timer = window.setInterval(kick, intervalMs); return () => { ctl.abort(); window.removeEventListener("focus", kick); document.removeEventListener("visibilitychange", onVisible); offReconnect(); window.clearInterval(timer); }; }, [intervalMs, minGapMs]); return changed; }