// + `useRefreshInterval` — the shared "grafana- // like" polling pattern from the design-language discussion: the // operator picks a cadence, the page re-fetches on it, and the timer // pauses while the tab is backgrounded rather than burning requests for // no visible benefit. Deliberately small (per mara: "component should // not be too big") — a preset cadence list and a pause-on-hidden timer, // nothing more. The UI (picker) and the behaviour (hook) live in one // file since neither is useful without the other and splitting them // would be two files for one concern. // // The hook, not the picker, owns re-fetch safety: a caller passes its // own fetch callback, and it's the CALLER's job to make sure that // callback doesn't clobber an input the operator is mid-edit on — this // hook only decides *when* to call it. import { useEffect, useRef } from 'preact/hooks'; import { SelectField, type SelectOption } from '../select-field/SelectField.js'; // `null` means "off" throughout this module — no separate boolean, so // there's exactly one way to represent "not polling". export type RefreshIntervalMs = number | null; const PRESETS: { value: RefreshIntervalMs; label: string }[] = [ { value: null, label: 'off' }, { value: 10_000, label: '10s' }, { value: 30_000, label: '30s' }, { value: 60_000, label: '1m' }, ]; // `SelectField` only speaks strings, so presets round-trip through the // label side of `PRESETS` rather than a second parallel list. const OPTIONS: SelectOption[] = PRESETS.map((p) => ({ value: p.label, label: p.label })); export function RefreshIntervalPicker({ id, value, onChange, }: { id: string; value: RefreshIntervalMs; onChange: (value: RefreshIntervalMs) => void; }) { const current = PRESETS.find((p) => p.value === value) ?? PRESETS[0]; return ( { const preset = PRESETS.find((p) => p.label === label); if (preset) onChange(preset.value); }} options={OPTIONS} /> ); } // Calls `onTick` immediately and then every `intervalMs`, paused while // the document is hidden and resynced (an immediate call, not a stale // leftover wait) on becoming visible again — same pattern // `RelativeTime` already uses for the same reason. `intervalMs = null` // means "off": no calls at all, not even the initial one — a caller // that wants data on mount regardless of the picker's starting value // should default its own `intervalMs` state to a real cadence, not // `null`, and add a separate mount effect only if it genuinely wants // "off" to still mean "load once." export function useRefreshInterval(intervalMs: RefreshIntervalMs, onTick: () => void) { // Always-current via a ref rather than a `useEffect` dependency: // callers pass an inline closure that's a new value every render, and // depending on it directly would re-arm the timer (losing whatever's // left of the current interval) on every render instead of only when // the cadence itself changes. The ref keeps the *timer* stable while // still calling the *latest* callback, not a stale one captured at // mount. const onTickRef = useRef(onTick); onTickRef.current = onTick; useEffect(() => { if (intervalMs === null) return; let id: ReturnType | undefined; const start = () => { onTickRef.current(); id = setInterval(() => onTickRef.current(), intervalMs); }; const stop = () => { if (id !== undefined) clearInterval(id); id = undefined; }; if (document.visibilityState === 'visible') start(); const onVisibility = () => { if (document.visibilityState === 'visible') start(); else stop(); }; document.addEventListener('visibilitychange', onVisibility); return () => { stop(); document.removeEventListener('visibilitychange', onVisibility); }; }, [intervalMs]); }