// ── A poll that keeps what it already had ───────────────────────────────── // // **Why this is not `useAsync`.** Core's hook (MODULE_API.md §3.4, and // `client/src/lib/useAsync.js` in core) is `useState({loading:true,error:null,data:null})` // re-run on a dependency change — and the first thing it does on every run is // blank `data` and set `loading`. That is right for a page load and wrong for a // poll: bumping a dependency every twenty seconds would clear the killfeed, // render `` in its place and re-fill it, four times a minute, for ever. // // So a poll needs a hook whose refresh is INVISIBLE when it succeeds. It keeps // the previous rows on screen, replaces them when the new ones arrive, and keeps // them *and* reports the error when the fetch fails — because a site whose whole // premise is "it renders while the game is off" must not blank the page the // first time a request does. // // `useAsync` is still the right hook for everything that loads once, and the // pages here use it for exactly that. Bundling this beside it is the kit working // as intended: the nine shared members are the chrome every module must share, // not a ceiling on what a module may write. // // ── Two behaviours worth knowing ────────────────────────────────────────── // // 1. **A backgrounded tab does not poll.** Page Visibility, plus an immediate // refresh when the viewer comes back — which is also the moment stale rows // are most visible. A tab left open overnight is otherwise a request every // twenty seconds until the laptop dies. // 2. **`key` resets, dependencies do not.** Switching server or wipe SHOULD // blank the rows: what is on screen belongs to a different question. That is // what `key` is for, and it is separate from the interval. import { useCallback, useEffect, useRef, useState } from 'react' /** * @param {() => Promise} fetcher called with no arguments; must not throw synchronously * @param {object} options * @param {string} options.key changes when the QUESTION changes, blanking the answer * @param {number} options.intervalMs 0 disables polling — the hook then loads once * @param {boolean} options.enabled false while the page has nothing to ask about yet */ export function usePolled(fetcher, { key = '', intervalMs = 20000, enabled = true } = {}) { const [state, setState] = useState({ data: null, error: null, loading: enabled, at: null }) // The fetcher is rebuilt on every render — it closes over props — and a hook // that listed it as a dependency would restart its interval every render. The // ref is how the timer keeps calling the CURRENT one without depending on it. const latest = useRef(fetcher) latest.current = fetcher // Guards a reply from a question nobody is asking any more: a slow request // whose page has moved on, or one still in flight at unmount. const generation = useRef(0) const run = useCallback( async (mine) => { try { const data = await latest.current() if (mine !== generation.current) return setState({ data, error: null, loading: false, at: Date.now() }) } catch (error) { if (mine !== generation.current) return // `data` is carried forward deliberately. A failed refresh is a page that // says "this is what we last knew, and it did not refresh", which is the // same promise the server list makes about a game server being down. setState((prev) => ({ data: prev.data, error, loading: false, at: prev.at })) } }, [], ) const refresh = useCallback(() => run(generation.current), [run]) useEffect(() => { generation.current += 1 const mine = generation.current if (!enabled) { setState({ data: null, error: null, loading: false, at: null }) return undefined } setState({ data: null, error: null, loading: true, at: null }) run(mine) if (!intervalMs) return () => { generation.current += 1 } let timer = null const visible = () => typeof document === 'undefined' || document.visibilityState === 'visible' const start = () => { if (timer === null) timer = setInterval(() => run(mine), intervalMs) } const stop = () => { if (timer !== null) { clearInterval(timer); timer = null } } const onVisibility = () => { if (visible()) { run(mine); start() } else stop() } if (visible()) start() if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibility) return () => { // Bumping the generation on teardown is what makes an in-flight reply from // the old question land nowhere. Clearing the timer alone would not. generation.current += 1 stop() if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibility) } }, [key, intervalMs, enabled, run]) return { ...state, refresh } } export default usePolled