"use client"; import { useCallback, useEffect, useRef, useState } from "react"; // Shared client polling primitives. Extracted from the monitor widget so the // dashboard cockpit could reuse the ~1s poll it proved out, and moved up to // lib/ in one-core phase 3 slice 2 when it became THE client poller: the // widget, the dashboard, /jobs, the operations board and the sync console all // poll `/api/view/` through this one hook, each at its own cadence (the // cadence is a call-site argument on purpose — there is no batch endpoint). // Live wall-clock that re-renders once a second; null until mounted so SSR and // the first client render agree (no Date.now() hydration mismatch). export function useNow(): number | null { const [now, setNow] = useState(null); useEffect(() => { setNow(Date.now()); const id = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(id); }, []); return now; } // Generic poller: fetches `url` every `pollMs` while enabled, swallowing // transient errors. Disabled (enabled=false) stops the timer and KEEPS the last // value — it never nulls `data` — which /jobs relies on: its last poll's // `recent` rows hold a just-finished job on screen after polling stops. // Returns the latest payload plus a `refetch` so a control action can refresh // it immediately instead of waiting for the next poll tick. // // The next tick is scheduled only after the previous fetch settles, so a slow // response can never stack requests behind it. // // `immediate: false` skips the fetch on (re)subscribe and waits one interval // first — for surfaces whose SSR seed is by construction fresh, where the // first poll would re-fetch what the server just rendered. export type PollOptions = { immediate?: boolean }; // How long one poll may take before it is abandoned and the next is scheduled: // three intervals, never under 10 s (a cold dev-server compile of a route can // legitimately take several seconds, and a timeout that fires on that would // turn the first poll of every page into a guaranteed miss). export function pollTimeoutMs(pollMs: number): number { return Math.max(10_000, pollMs * 3); } export function usePolledPayload( url: string, enabled: boolean, pollMs: number, initial: T | null, { immediate = true }: PollOptions = {}, ): { data: T | null; refetch: () => Promise } { const [data, setData] = useState(initial); // A `refetch` awaited by a control action can settle after the surface // unmounted (the operations board and the sync console both guarded this by // hand before they folded onto the hook). const mounted = useRef(true); useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); const refetch = useCallback(async () => { try { const res = await fetch(url, { cache: "no-store", signal: AbortSignal.timeout(pollTimeoutMs(pollMs)), }); if (!res.ok) return; const next = (await res.json()) as T; if (mounted.current) setData(next); } catch { // transient — ignore } }, [url, pollMs]); useEffect(() => { if (!enabled) return; let cancelled = false; let timer: ReturnType | null = null; // One controller for this subscription: the cleanup aborts whatever tick // is in flight, so an unmount or a disable never leaves a request running. const ctrl = new AbortController(); async function tick() { try { // THE TIMEOUT IS WHAT MAKES SERIAL POLLING SAFE. The next tick is only // scheduled once this one settles, so a fetch that never settled would // stop the poll for good — the old setInterval loops recovered from a // hang by firing again regardless. A tick that times out is a failed // tick like any other: it lands in `catch` and the next is scheduled. const res = await fetch(url, { cache: "no-store", signal: AbortSignal.any([ ctrl.signal, AbortSignal.timeout(pollTimeoutMs(pollMs)), ]), }); if (res.ok && !cancelled) setData((await res.json()) as T); } catch { // transient, aborted or timed out — keep polling } finally { if (!cancelled) timer = setTimeout(tick, pollMs); } } // By default fetch immediately on (re)subscribe, then poll on the // interval. Sections seeded from the server get a harmless refresh; those // with a null initial render on the first tick instead of after a full // interval. if (immediate) void tick(); else timer = setTimeout(tick, pollMs); return () => { cancelled = true; ctrl.abort(); if (timer) clearTimeout(timer); }; }, [url, enabled, pollMs, immediate]); return { data, refetch }; }