ReviewOS

stacks/stx

feat(data): no polling primitive — `useInterval` and `refetchInterval` both keep firing in a hidden tab, with no `enabled` and no stop-on-error

#1870
Closed glennmichael123 opened this 10 days ago ·
10 days ago

Summary

stx has two ways to poll and neither expresses "poll every N seconds while authorized and visible". useInterval is a bare setInterval with pause/resume; useQuery's refetchInterval is a bare setInterval around fetchData with no enabled predicate, no pause, no stop-on-error, and no suppression while the document is hidden. An app therefore hand-composes the stop conditions, and the common case — do not poll a backgrounded tab — is not expressible at all.

Impact (real app)

analyticshq's dashboard shows a live visitor count. resources/views/dashboard.stx:1036-1063 is what "poll every 15s while authorized and visible" costs today — 25 lines plus 20 lines of comment:

// Declared with `let` + a separate assignment because the callback pauses the timer
// it belongs to; a `const live = useInterval(() => … live.pause() …)` reads fine only
// because the first tick is 15s away, and that is too subtle to rely on.
let live: { pause: () => void }
const tick = () => session.authFetch(`/api/sites/${encodeURIComponent(activeSiteId)}/realtime`)
  .then((r) => {
    // 401 here means the renewal could not help: a share-link viewer holds no token
    // at all. Stop rather than poll an endpoint that will never answer them.
    if (r.status === 401) { live.pause(); return null }
    return r.ok ? r.json() : null
  })
  .then((d) => { if (d && typeof d.current === 'number') liveCount.set(d.current) })
  .catch(() => {})

live = useInterval(tick, 15000)

The let-then-assign dance exists purely because the callback has to pause the timer it belongs to. The 401 stop is hand-coded. And the "visible" half is simply not implemented, because there is nothing to implement it with: the dashboard hits /api/sites/:id/realtime every 15 seconds for as long as the tab is open, backgrounded or not — ~240 requests/hour per idle open tab, from an analytics product whose pitch is efficiency.

Evidence in stx

packages/stx/src/signals.ts:4361-4402 — useInterval:

function resume() {
  if (running) return;
  running = true;
  id = setInterval(tick, interval);        // ← no visibility awareness
  if (options.immediate) tick();
}

packages/stx/src/signals.ts:1281-1284 — useQuery's refetchInterval:

if (options.refetchInterval) {
  var intervalId = setInterval(fetchData, options.refetchInterval);
  onDestroy(function() { clearInterval(intervalId); });
}

No enabled, no pause, no error-conditional stop, no document.hidden guard.

grep -n 'enabled' packages/stx/src/signals.ts returns one unrelated comment (:1443) — useQuery has no enabled option.

grep -n 'document.hidden\|visibilitychange' packages/stx/src/signals.ts returns only :1273-1277, inside refetchOnFocus — which refetches when the tab becomes visible and is not connected to refetchInterval in any way.

Prior art

  • TanStack Query: refetchInterval, refetchIntervalInBackground: false (the default), enabled, and retry: false for a 401 — all four, declaratively.
  • SWR: refreshInterval + refreshWhenHidden: false (default) + isPaused.
  • VueUse: useTimeoutPoll / pausableInterval composed with useDocumentVisibility.

Ask

  1. Add enabled to useQuery — a boolean or a signal-reading predicate, checked before each fetch including each interval tick.
  2. Add refetchIntervalInBackground (default false) and skip the tick while document.hidden, resuming on visibilitychange.
  3. Add a stop condition for polling: either retry: false-style semantics on a given status, or let onError return false to cancel the interval — so "stop on 401" does not require the caller to hold a reference to its own timer.
  4. Give useInterval an optional { whenVisible: true } so the plain timer case gets the same behaviour without adopting useQuery.
  5. Have useInterval pass the handle to its own callback (useInterval(t => …, ms)) so the let-then-assign dance is unnecessary.

Related: #1818 (refetchOnFocus listener leak — same options object).

Sign in to comment on this issue.