Problem — client/src/hooks/useAutoRefetch.js exists to replace exactly this and says so in its first docblock line: "Auto-refetch on an interval, pausing while the tab is hidden and re-firing once when it becomes visible again. Replaces the per-component useEffect + setInterval pattern for data-fetch polling." Eleven data-fetch polls still hand-roll useEffect + setInterval, and every one of them keeps firing while the tab is hidden — the hook's loadData short-circuits on document.visibilityState === 'hidden' and re-fires on useVisibilityEvent, while a raw interval does not. PortOS is routinely left open in a background tab on a second machine over the tailnet, so this is a real load: AppDetailView.jsx:146 polls PM2 process status every 1.5 s unconditionally for as long as the app-detail view is mounted; MemoryManagement.jsx:175 polls loaded-model state every 5 s; MindTab.jsx runs two unconditional polls (10 s and 30 s); ConfigTab.jsx:275 polls mind status every 15 s. That is a continuous server-side request stream — several of which fan out to pm2 jlist and Ollama — from tabs nobody is looking at. client/src/components/cos/tabs/AgentCard.jsx:232 in the same feature area already does it right (useAutoRefetch(fetchStats, 5000, { enabled: …, pollOnly: true })), so the migration target is proven, not speculative.
Evidence — the hook's contract, client/src/hooks/useAutoRefetch.js:97-115:
const loadData = async () => {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
…
};
loadOnVisibleRef.current = loadData;
if (immediate) loadData();
const interval = setInterval(loadData, intervalMs);
…
useVisibilityEvent((state) => {
if (state === 'visible') loadOnVisibleRef.current?.();
});
the hot offender, client/src/components/apps/AppDetailView.jsx:145-151:
refresh();
const timer = setInterval(refresh, 1500);
return () => { cancelled = true; clearInterval(timer); };
}, [appId, app?.nativeLaunch?.processName, launchProcess]);
and the unconditional pair, client/src/components/cos/tabs/MindTab.jsx:317-326:
useEffect(() => {
void loadRuntime();
const interval = setInterval(() => { void loadRuntime(); }, 10_000);
return () => clearInterval(interval);
}, [loadRuntime]);
useEffect(() => {
void loadVisibility();
const interval = setInterval(() => { void loadVisibility(); }, 30_000);
return () => clearInterval(interval);
}, [loadVisibility]);
the compliant sibling, client/src/components/cos/tabs/AgentCard.jsx:232:
useAutoRefetch(fetchStats, 5000, { enabled: !inactive && !remote, pollOnly: true });
Plan
- Convert each of the eleven sites to
useAutoRefetch(fetchFn, intervalMs, { enabled, pollOnly: true }), keeping each site's existing gate as the enabled value rather than an early return undefined — MediaJobsQueue.jsx:372 → enabled: status === 'running' || status === 'queued'; LoomProductionPanel.jsx:183 → enabled: activeBatchRun?.status === 'in_progress'; LoomEditorialAutomation.jsx:114 → enabled: autopilotActive && !!autopilotRun?.id; TrackWorkflow.jsx:52 → enabled: working; LoraDatasetDetail.jsx:375 → enabled: renderingCount > 0; the other five are unconditional (enabled omitted).
- Use
pollOnly: true everywhere: all eleven own their own state via the fetch function's side effects and ignore the hook's data/loading, which is precisely what pollOnly documents.
- Drop each site's now-redundant
cancelled/ignore flag — the hook owns cancellation. Keep each site's .catch()/silent: true handling as-is (these are side-effect polls; per the hook docblock, swallowing inside fetchFn is the sanctioned pattern for pollOnly callers).
- Leave
client/src/pages/ThreejsModelDetail.jsx:342 and client/src/components/sprites/WalkWorkflow.jsx:614 alone: the former runs a bounded in-flight-poll pool with its own AbortController per tick, the latter counts ticks to self-cancel. Decision: converting those would lose behavior the hook does not model; state that explicitly in the PR description so a reviewer does not read the omission as an oversight.
- Add a scan rule to a new
client/src/pollingConventions.test.js (node env, trackedSourceFiles): fail on a setInterval( inside a useEffect in a tracked .jsx/.js whose callback body mentions api, get, or fetch-shaped identifiers — too fuzzy. Instead, decide simpler: fail on any setInterval( in client/src/components/** or client/src/pages/** unless the file is in a small documented allowlist (ThreejsModelDetail.jsx, WalkWorkflow.jsx, plus the pure clock-tick sites AgentCard.jsx, OpenWorldIntelPane.jsx, ExercisePanel.jsx, BrailleSpinner.jsx, DrillTransition.jsx and the audio/metronome modules), each entry carrying a one-line reason. Hooks and client/src/lib/ are exempt.
Tests
- New
client/src/pollingConventions.test.js — the allowlisted scan from step 5, plus a stale-entry check (an allowlist entry whose file no longer contains setInterval fails) mirroring the a11y allowlists' burn-down rule. Pins the next hand-rolled poll.
- Extend one existing render test per converted feature area where one exists (
client/src/components/media/MediaJobsQueue.test.jsx, client/src/components/cos/tabs/MindTab.test.jsx if present) with a hidden-document case: set document.visibilityState = 'hidden', advance fake timers past the interval, assert the API mock was not called. That is the regression the migration uniquely buys; do not add one per site.
Acceptance criteria
Out of scope — clock-tick intervals that render elapsed time (AgentCard.jsx:216, ExercisePanel.jsx:49, OpenWorldIntelPane.jsx:249), audio/metronome timing, and any change to poll cadences.
Filed by a /do:better --scan-only --issues audit (2026-09-01). Category: stack-specific · Severity: medium · Files: client/src/components/apps/AppDetailView.jsx:146, client/src/components/settings/MemoryManagement.jsx:175, client/src/components/cos/tabs/MindTab.jsx:319, client/src/components/cos/tabs/MindTab.jsx:324, client/src/components/cos/tabs/ConfigTab.jsx:275, client/src/components/media/MediaJobsQueue.jsx:373, client/src/components/fableloom/LoomProductionPanel.jsx:184, client/src/components/fableloom/LoomEditorialAutomation.jsx:118, client/src/components/sprites/TrackWorkflow.jsx:53, client/src/pages/LoraDatasetDetail.jsx:376, client/src/hooks/useAutoRefetch.js:6
All labels already exist in the repo; do NOT create labels. Never add planner:* labels.
Problem —
client/src/hooks/useAutoRefetch.jsexists to replace exactly this and says so in its first docblock line: "Auto-refetch on an interval, pausing while the tab is hidden and re-firing once when it becomes visible again. Replaces the per-component useEffect + setInterval pattern for data-fetch polling." Eleven data-fetch polls still hand-rolluseEffect+setInterval, and every one of them keeps firing while the tab is hidden — the hook'sloadDatashort-circuits ondocument.visibilityState === 'hidden'and re-fires onuseVisibilityEvent, while a raw interval does not. PortOS is routinely left open in a background tab on a second machine over the tailnet, so this is a real load:AppDetailView.jsx:146polls PM2 process status every 1.5 s unconditionally for as long as the app-detail view is mounted;MemoryManagement.jsx:175polls loaded-model state every 5 s;MindTab.jsxruns two unconditional polls (10 s and 30 s);ConfigTab.jsx:275polls mind status every 15 s. That is a continuous server-side request stream — several of which fan out topm2 jlistand Ollama — from tabs nobody is looking at.client/src/components/cos/tabs/AgentCard.jsx:232in the same feature area already does it right (useAutoRefetch(fetchStats, 5000, { enabled: …, pollOnly: true })), so the migration target is proven, not speculative.Evidence — the hook's contract,
client/src/hooks/useAutoRefetch.js:97-115:the hot offender,
client/src/components/apps/AppDetailView.jsx:145-151:and the unconditional pair,
client/src/components/cos/tabs/MindTab.jsx:317-326:the compliant sibling,
client/src/components/cos/tabs/AgentCard.jsx:232:Plan
useAutoRefetch(fetchFn, intervalMs, { enabled, pollOnly: true }), keeping each site's existing gate as theenabledvalue rather than an earlyreturn undefined—MediaJobsQueue.jsx:372→enabled: status === 'running' || status === 'queued';LoomProductionPanel.jsx:183→enabled: activeBatchRun?.status === 'in_progress';LoomEditorialAutomation.jsx:114→enabled: autopilotActive && !!autopilotRun?.id;TrackWorkflow.jsx:52→enabled: working;LoraDatasetDetail.jsx:375→enabled: renderingCount > 0; the other five are unconditional (enabledomitted).pollOnly: trueeverywhere: all eleven own their own state via the fetch function's side effects and ignore the hook'sdata/loading, which is precisely whatpollOnlydocuments.cancelled/ignoreflag — the hook owns cancellation. Keep each site's.catch()/silent: truehandling as-is (these are side-effect polls; per the hook docblock, swallowing insidefetchFnis the sanctioned pattern forpollOnlycallers).client/src/pages/ThreejsModelDetail.jsx:342andclient/src/components/sprites/WalkWorkflow.jsx:614alone: the former runs a bounded in-flight-poll pool with its ownAbortControllerper tick, the latter counts ticks to self-cancel. Decision: converting those would lose behavior the hook does not model; state that explicitly in the PR description so a reviewer does not read the omission as an oversight.client/src/pollingConventions.test.js(node env,trackedSourceFiles): fail on asetInterval(inside auseEffectin a tracked.jsx/.jswhose callback body mentionsapi,get, orfetch-shaped identifiers — too fuzzy. Instead, decide simpler: fail on anysetInterval(inclient/src/components/**orclient/src/pages/**unless the file is in a small documented allowlist (ThreejsModelDetail.jsx,WalkWorkflow.jsx, plus the pure clock-tick sitesAgentCard.jsx,OpenWorldIntelPane.jsx,ExercisePanel.jsx,BrailleSpinner.jsx,DrillTransition.jsxand the audio/metronome modules), each entry carrying a one-line reason. Hooks andclient/src/lib/are exempt.Tests
client/src/pollingConventions.test.js— the allowlisted scan from step 5, plus a stale-entry check (an allowlist entry whose file no longer containssetIntervalfails) mirroring the a11y allowlists' burn-down rule. Pins the next hand-rolled poll.client/src/components/media/MediaJobsQueue.test.jsx,client/src/components/cos/tabs/MindTab.test.jsxif present) with a hidden-document case: setdocument.visibilityState = 'hidden', advance fake timers past the interval, assert the API mock was not called. That is the regression the migration uniquely buys; do not add one per site.Acceptance criteria
useAutoRefetchwithpollOnly: true.document.visibilityState === 'hidden'suppresses the poll in the two extended render tests.client/src/pollingConventions.test.jspasses and its allowlist has no stale entries.cd client && npm testpasses with noact()warnings.Out of scope — clock-tick intervals that render elapsed time (
AgentCard.jsx:216,ExercisePanel.jsx:49,OpenWorldIntelPane.jsx:249), audio/metronome timing, and any change to poll cadences.Filed by a
/do:better --scan-only --issuesaudit (2026-09-01). Category:stack-specific· Severity:medium· Files:client/src/components/apps/AppDetailView.jsx:146, client/src/components/settings/MemoryManagement.jsx:175, client/src/components/cos/tabs/MindTab.jsx:319, client/src/components/cos/tabs/MindTab.jsx:324, client/src/components/cos/tabs/ConfigTab.jsx:275, client/src/components/media/MediaJobsQueue.jsx:373, client/src/components/fableloom/LoomProductionPanel.jsx:184, client/src/components/fableloom/LoomEditorialAutomation.jsx:118, client/src/components/sprites/TrackWorkflow.jsx:53, client/src/pages/LoraDatasetDetail.jsx:376, client/src/hooks/useAutoRefetch.js:6All labels already exist in the repo; do NOT create labels. Never add
planner:*labels.