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, andretry: falsefor a 401 — all four, declaratively. - SWR:
refreshInterval+refreshWhenHidden: false(default) +isPaused. - VueUse:
useTimeoutPoll/pausableIntervalcomposed withuseDocumentVisibility.
Ask
- Add
enabledtouseQuery— a boolean or a signal-reading predicate, checked before each fetch including each interval tick. - Add
refetchIntervalInBackground(default false) and skip the tick whiledocument.hidden, resuming onvisibilitychange. - Add a stop condition for polling: either
retry: false-style semantics on a given status, or letonErrorreturnfalseto cancel the interval — so "stop on 401" does not require the caller to hold a reference to its own timer. - Give
useIntervalan optional{ whenVisible: true }so the plain timer case gets the same behaviour without adoptinguseQuery. - Have
useIntervalpass the handle to its own callback (useInterval(t => …, ms)) so thelet-then-assign dance is unnecessary.
Related: #1818 (refetchOnFocus listener leak — same options object).