Skip to content

Eleven hand-rolled polling intervals keep hammering the API from a hidden tab instead of going through useAutoRefetch #5697

Description

@atomantic

Problemclient/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

  1. 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 undefinedMediaJobsQueue.jsx:372enabled: status === 'running' || status === 'queued'; LoomProductionPanel.jsx:183enabled: activeBatchRun?.status === 'in_progress'; LoomEditorialAutomation.jsx:114enabled: autopilotActive && !!autopilotRun?.id; TrackWorkflow.jsx:52enabled: working; LoraDatasetDetail.jsx:375enabled: renderingCount > 0; the other five are unconditional (enabled omitted).
  2. 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.
  3. 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).
  4. 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.
  5. 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

  • All eleven listed sites call useAutoRefetch with pollOnly: true.
  • document.visibilityState === 'hidden' suppresses the poll in the two extended render tests.
  • client/src/pollingConventions.test.js passes and its allowlist has no stale entries.
  • cd client && npm test passes with no act() 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 --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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions