|
| 1 | +/** |
| 2 | + * The sidebar's ONE polling loop: every timed client fetch — subagent live |
| 3 | + * previews, side-chat transcript deltas, git status, session ops — funnels |
| 4 | + * here so the cancellation-safety rules are written once instead of four |
| 5 | + * times. The contract every poller gets: |
| 6 | + * |
| 7 | + * - While `enabled`, the task runs on the chosen cadence; the loop restarts |
| 8 | + * (and the old one is torn down) whenever `enabled`, the task identity, or |
| 9 | + * any option primitive changes — callers express "scope changed" through |
| 10 | + * the task's `useCallback` deps, exactly like an effect's dep array. |
| 11 | + * - Each enabled run owns ONE AbortSignal for its whole lifetime; teardown |
| 12 | + * (unmount, `enabled` flip, identity change) aborts it AND stops all |
| 13 | + * scheduling. A task whose fetch settles late must check the signal |
| 14 | + * before writing state: an aborted fetch is NOT guaranteed to reject |
| 15 | + * (the transport may deliver the response anyway), so the signal is the |
| 16 | + * only reliable staleness guard. |
| 17 | + * - A rejected task never breaks the loop — the scheduler swallows it and |
| 18 | + * the next tick retries. Sites that surface errors (setError banners and |
| 19 | + * friends) do so inside the task body; the scheduler always stays silent. |
| 20 | + */ |
| 21 | +import { useEffect } from 'react' |
| 22 | + |
| 23 | +/** One poll tick. See the module doc for the signal contract. */ |
| 24 | +export type PollingTask = (signal: AbortSignal) => Promise<void> |
| 25 | + |
| 26 | +export interface UsePollingOptions { |
| 27 | + /** Tick cadence in milliseconds. */ |
| 28 | + intervalMs: number |
| 29 | + /** |
| 30 | + * Scheduling mode. `'fixed-interval'` (default) fires ticks on a plain |
| 31 | + * `setInterval` cadence — an in-flight task never delays the next tick, |
| 32 | + * overlapping tasks guard their own writes (abort controllers or |
| 33 | + * generation counters inside the task). `'self-scheduling'` arms the next |
| 34 | + * tick only after the previous task settles: at most ONE request in |
| 35 | + * flight, ever, so a slow host never sees request storms. |
| 36 | + */ |
| 37 | + mode?: 'fixed-interval' | 'self-scheduling' |
| 38 | + /** Run one task immediately when the poller (re)starts, before the first |
| 39 | + * scheduled tick. */ |
| 40 | + immediate?: boolean |
| 41 | +} |
| 42 | + |
| 43 | +export function usePolling( |
| 44 | + enabled: boolean, |
| 45 | + task: PollingTask, |
| 46 | + { intervalMs, mode = 'fixed-interval', immediate = false }: UsePollingOptions, |
| 47 | +): void { |
| 48 | + useEffect(() => { |
| 49 | + if (!enabled) return |
| 50 | + const controller = new AbortController() |
| 51 | + if (mode === 'self-scheduling') { |
| 52 | + let disposed = false |
| 53 | + let timer: number | undefined |
| 54 | + const tick = async (): Promise<void> => { |
| 55 | + if (disposed) return |
| 56 | + try { |
| 57 | + await task(controller.signal) |
| 58 | + } catch { |
| 59 | + // A failed poll keeps the last view; the next tick retries. |
| 60 | + } |
| 61 | + if (!disposed) timer = window.setTimeout(() => { void tick() }, intervalMs) |
| 62 | + } |
| 63 | + if (immediate) void tick() |
| 64 | + else timer = window.setTimeout(() => { void tick() }, intervalMs) |
| 65 | + return () => { |
| 66 | + disposed = true |
| 67 | + if (timer !== undefined) window.clearTimeout(timer) |
| 68 | + controller.abort() |
| 69 | + } |
| 70 | + } |
| 71 | + const run = (): void => { |
| 72 | + // A failed tick keeps the last view; the next tick retries. |
| 73 | + task(controller.signal).catch(() => {}) |
| 74 | + } |
| 75 | + if (immediate) run() |
| 76 | + const timer = window.setInterval(run, intervalMs) |
| 77 | + return () => { |
| 78 | + window.clearInterval(timer) |
| 79 | + controller.abort() |
| 80 | + } |
| 81 | + // Option primitives only: a churned options object must not restart the loop. |
| 82 | + }, [enabled, task, intervalMs, mode, immediate]) |
| 83 | +} |
0 commit comments