diff --git a/frontend/packages/agent/src/hooks/useAgentState.ts b/frontend/packages/agent/src/hooks/useAgentState.ts index d41bb94a..4893ecb1 100644 --- a/frontend/packages/agent/src/hooks/useAgentState.ts +++ b/frontend/packages/agent/src/hooks/useAgentState.ts @@ -23,6 +23,15 @@ export function useAgentState(): UseAgentStateResult { const [error, setError] = useState(null); const timerRef = useRef | null>(null); const stoppedRef = useRef(false); + // Holds the mount effect's self-rescheduling `poll` so `refresh()` can + // invoke the *same* function instead of a parallel one-off fetch — a + // fresh call still runs through `poll`'s own `setTimeout(poll, …)` tail, + // so the loop stays alive afterwards instead of dying silently the + // first time something calls `refresh()` (see the review discussion on + // this file's PR: an earlier version of `refresh()` did its own + // one-off fetch with no reschedule, which permanently killed periodic + // polling the first time a caller invoked it). + const pollRef = useRef<() => void>(() => {}); useEffect(() => { stoppedRef.current = false; @@ -42,6 +51,7 @@ export function useAgentState(): UseAgentStateResult { timerRef.current = setTimeout(poll, RETRY_MS); } } + pollRef.current = poll; poll(); return () => { @@ -52,20 +62,8 @@ export function useAgentState(): UseAgentStateResult { function refresh() { if (timerRef.current) clearTimeout(timerRef.current); - // Re-trigger the same poll loop immediately; the effect's closure - // owns `poll`, so the simplest safe re-trigger from outside it is a - // fresh fetch here rather than reaching back into the effect. stoppedRef.current = false; - fetch('api/state') - .then((resp) => { - if (!resp.ok) throw new Error(`http ${resp.status}`); - return resp.json(); - }) - .then((s: AgentState) => { - setState(s); - setError(null); - }) - .catch((err) => setError(err instanceof Error ? err.message : String(err))); + pollRef.current(); } // A backgrounded/hidden tab has its `setTimeout` chain throttled by the @@ -74,15 +72,19 @@ export function useAgentState(): UseAgentStateResult { // stalls, and everything derived from `state` (not just the fetch // itself) goes stale until it happens to fire again. Force a resync the // moment the tab becomes visible so switching back doesn't leave a - // reading that's minutes old. `refresh` closes over stable refs/setters - // only, so a fresh instance each render is safe to use here. + // reading that's minutes old. Empty deps: `refresh` only closes over + // refs and setState setters, both stable across renders, so the first + // render's closure stays correct for the component's whole lifetime — + // and, unlike re-subscribing on every render, doesn't re-attach this + // listener every second now that `Root`'s own 1s ticker re-renders the + // component that calls this hook. useEffect(() => { function onVisible() { if (document.visibilityState === 'visible') refresh(); } document.addEventListener('visibilitychange', onVisible); return () => document.removeEventListener('visibilitychange', onVisible); - }); + }, []); return { state, error, refresh }; }