mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-27 15:17:18 +09:00
feat(ui): one visibility-aware polling convention, and a ratchet on the rest
Plan 76 milestone E2, drift/bug row 4. 45 hand-rolled setInterval sites across 35 files, with `visibilitychange` handled in exactly one of them and an in-flight guard in exactly one of them — the documented poller-stampede shape. Two failures every hand-rolled `setInterval(load, 5000)` shares: - it starts a request every 5s whether or not the previous one returned. A slow endpoint does not slow the poller down, it makes it stack; each extra request makes the server slower, which makes the next overlap more likely. This is what a DevTools capture of /domains showed as a wall of pending rows. - it keeps polling a tab nobody is looking at, where browsers throttle timers unevenly anyway, so it is not even a reliable refresh. utils/pollScheduler.js holds the rules as a plain object so they are testable without a DOM (the repo has no jsdom — this follows formState.js/useForm). hooks/usePolling.js is the React binding, and useServerQuery gains `refetchInterval` built on the same scheduler, so there is one implementation rather than a second one for query users. Two design points worth stating, since both differ from what the sites did: - the next tick is armed by the previous run *finishing*, not by setInterval. With setInterval the gap is measured tick-to-tick regardless of how long the work took, so slow work silently turns a 5s gap into no gap. The in-flight guard therefore protects the out-of-band paths — refresh() and the visibility catch-up — rather than the schedule, and the test says so instead of implying a guard the schedule can no longer exercise. - the callback is held in a ref, so an inline arrow re-created each render does not restart the timer. That is the usual reason a hand-rolled poller runs far faster than its stated interval. Migrated four as reference implementations, per the plan's "migrate a representative flow before broader adoption": DeployPill (which had worked all of this out by hand and loses ~20 lines), MonitorsSummary, appdetail/LogsTab, and ProcessListPanel. MonitorsSummary keeps its `refreshKey` prop as an explicit out-of-band refresh rather than folding it into the schedule. Ratchet: check-frontend-boundaries.mjs gains LEGACY_POLLERS with exact per-file counts, now 41 sites in 31 files, so every future cleanup must shrink it and a new bare setInterval fails lint. Genuine non-fetching timers (clock ticks, countdowns) are listed too rather than pattern-matched away — they are harmless but the exact count is what makes the ratchet trustworthy. Gate: npm test (72 passed, 11 of them new), npm run lint (0 errors; 954 warnings, one fewer than the 955 baseline), npm run build clean, boundary check green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
054cc33cdf
commit
f21abe7af5
9 files changed
+628
-33
No files matched your search
@@ -34,6 +34,55 @@ const EXTENSION_OWNED_STYLES = [
|
||||
// insecure context, and SSL is optional by policy. This map must stay empty.
|
||||
const LEGACY_CLIPBOARD = new Map();
|
||||
|
||||
// Polling belongs to usePolling / useServerQuery({refetchInterval}), which do
|
||||
// two things a hand-rolled setInterval does not: they never start a request
|
||||
// while the previous one is still in flight, and they stop entirely while the
|
||||
// tab is hidden, catching up once on return. A bare setInterval that fetches is
|
||||
// the documented poller-stampede shape - the page gets slower the longer it is
|
||||
// left open. Finite legacy baseline with exact counts, so every cleanup shrinks
|
||||
// it. A genuine non-fetching timer (a clock tick, a countdown) may stay, but it
|
||||
// is still listed so nothing new slips in unnoticed.
|
||||
const LEGACY_POLLERS = new Map(Object.entries({
|
||||
'components/dashboard/widgets/renderers.jsx': 1,
|
||||
'components/deploy-console/SuccessBanner.jsx': 1,
|
||||
'components/docker/ContainersTab.jsx': 2,
|
||||
'components/monitoring/DoctorPanel.jsx': 1,
|
||||
'components/security/ScannerTab.jsx': 2,
|
||||
'components/security/VulnerabilityTab.jsx': 1,
|
||||
'components/server/OnboardingWizard.jsx': 2,
|
||||
'components/serverdetail/ServicesTab.jsx': 1,
|
||||
'components/servers/LinkPanelForm.jsx': 1,
|
||||
'components/service-detail/LogsTab.jsx': 1,
|
||||
'components/service-detail/MetricsTab.jsx': 1,
|
||||
'components/service-detail/OverviewTab.jsx': 1,
|
||||
'hooks/useContainerStatus.js': 1,
|
||||
'hooks/useDeployJobStream.js': 1,
|
||||
'hooks/useMetrics.js': 1,
|
||||
'pages/Dashboard.jsx': 4,
|
||||
'pages/Databases.jsx': 1,
|
||||
'pages/DeliveryLog.jsx': 1,
|
||||
'pages/DeployConsole.jsx': 1,
|
||||
'pages/Deployments.jsx': 1,
|
||||
'pages/ImportWizard.jsx': 1,
|
||||
'pages/Jobs.jsx': 1,
|
||||
'pages/MonitorDetail.jsx': 1,
|
||||
'pages/Monitors.jsx': 2,
|
||||
'pages/QueueDetail.jsx': 1,
|
||||
'pages/QueueOperations.jsx': 1,
|
||||
'pages/ServerDetail.jsx': 1,
|
||||
'pages/Terminal.jsx': 3,
|
||||
'pages/TestSandbox.jsx': 2,
|
||||
'plugins/serverkit-gui/components/ServerGui.jsx': 1,
|
||||
'plugins/serverkit-gui/components/SyntheticDesktop.jsx': 1,
|
||||
}));
|
||||
|
||||
// The polling door itself, plus the query hook that wraps it.
|
||||
const POLLING_DOOR = new Set([
|
||||
'utils/pollScheduler.js',
|
||||
'hooks/usePolling.js',
|
||||
'hooks/useServerQuery.js',
|
||||
]);
|
||||
|
||||
// These are purpose-built experiences whose geometry/content is intentionally
|
||||
// richer than the ordinary Modal contract. Any new exception needs review.
|
||||
const LOW_LEVEL_DIALOG_EXCEPTIONS = new Set([
|
||||
@@ -52,6 +101,7 @@ function walk(dir) {
|
||||
const files = walk(src).filter((path) => ['.js', '.jsx'].includes(extname(path)));
|
||||
const failures = [];
|
||||
const seenClipboard = new Set();
|
||||
const seenPollers = new Set();
|
||||
|
||||
function rel(path) {
|
||||
return relative(src, path).replaceAll('\\', '/');
|
||||
@@ -74,6 +124,15 @@ for (const path of files) {
|
||||
if (expected) seenClipboard.add(file);
|
||||
}
|
||||
|
||||
if (!POLLING_DOOR.has(file)) {
|
||||
const actual = count(source, /setInterval\s*\(/g);
|
||||
const expected = LEGACY_POLLERS.get(file) || 0;
|
||||
if (actual !== expected) {
|
||||
failures.push(`${file}: raw setInterval count is ${actual}; legacy baseline is ${expected}. Use usePolling() (or useServerQuery({refetchInterval})) and shrink the baseline.`);
|
||||
}
|
||||
if (expected) seenPollers.add(file);
|
||||
}
|
||||
|
||||
if (/(?:(?:window|globalThis)\s*\.\s*)?confirm\s*\(\s*['"`]/m.test(source)) {
|
||||
failures.push(`${file}: use useConfirm() instead of the browser confirm dialog.`);
|
||||
}
|
||||
@@ -117,6 +176,12 @@ for (const file of LEGACY_CLIPBOARD.keys()) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of LEGACY_POLLERS.keys()) {
|
||||
if (!seenPollers.has(file)) {
|
||||
failures.push(`${file}: legacy poller entry is stale; remove it from the boundary baseline.`);
|
||||
}
|
||||
}
|
||||
|
||||
const mainStylesPath = resolve(src, 'styles', 'main.scss');
|
||||
const mainStyles = readFileSync(mainStylesPath, 'utf8');
|
||||
for (const ownership of EXTENSION_OWNED_STYLES) {
|
||||
@@ -159,4 +224,4 @@ if (failures.length) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✓ frontend boundaries: browser/API/dialog boundaries and ${EXTENSION_OWNED_STYLES.length} extension style ownership rule(s) hold (${LEGACY_CLIPBOARD.size} clipboard files remain ratcheted).`);
|
||||
console.log(`✓ frontend boundaries: browser/API/dialog boundaries and ${EXTENSION_OWNED_STYLES.length} extension style ownership rule(s) hold (${LEGACY_CLIPBOARD.size} clipboard / ${LEGACY_POLLERS.size} poller files remain ratcheted).`);
|
||||
@@ -3,6 +3,7 @@ import { Link, useLocation } from 'react-router-dom';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import socketService from '../services/socket';
|
||||
import { usePolling } from '../hooks/usePolling';
|
||||
|
||||
// A small global "deploy in progress" pill: while any deployment job is
|
||||
// pending/running it appears (bottom-left) so you can navigate away mid-deploy
|
||||
@@ -49,30 +50,19 @@ export default function DeployPill() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// The interval, the hidden-tab pause, and the catch-up on return are the
|
||||
// shared convention now; this component was where they were first worked
|
||||
// out by hand.
|
||||
usePolling(refresh, POLL_MS, { enabled: !onDeploySurface });
|
||||
|
||||
useEffect(() => {
|
||||
if (onDeploySurface) return undefined;
|
||||
|
||||
// Polling a background tab buys nothing and competes for the browser's
|
||||
// per-origin connections with whatever the visible tab is doing.
|
||||
const tick = () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
refresh();
|
||||
};
|
||||
tick();
|
||||
const t = setInterval(tick, POLL_MS);
|
||||
|
||||
// Catch up once when the tab comes back rather than polling all along.
|
||||
const onVisible = () => { if (document.visibilityState === 'visible') refresh(); };
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
|
||||
// React quickly to live status changes when a console elsewhere is
|
||||
// streaming — but a burst of deploy_status events must not become a
|
||||
// burst of requests, so the in-flight guard covers these too.
|
||||
const unsubStatus = socketService.on('deploy_status', refresh);
|
||||
const unsubConnect = socketService.on('connected', refresh);
|
||||
return () => {
|
||||
clearInterval(t);
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
unsubStatus();
|
||||
unsubConnect();
|
||||
};
|
||||
|
||||
@@ -2,6 +2,9 @@ import { useState, useEffect } from 'react';
|
||||
import { logsToText } from '@/utils/logText';
|
||||
import api from '../../services/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { usePolling } from '../../hooks/usePolling';
|
||||
|
||||
const LOG_REFRESH_MS = 5000;
|
||||
|
||||
const LogsTab = ({ app }) => {
|
||||
const [logs, setLogs] = useState('');
|
||||
@@ -15,11 +18,9 @@ const LogsTab = ({ app }) => {
|
||||
loadLogs();
|
||||
}, [app.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const interval = setInterval(loadLogs, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRefresh, app.id]);
|
||||
// immediate: false — the effect above already loads on mount and on app
|
||||
// change; this adds only the repeat while auto-refresh is on.
|
||||
usePolling(loadLogs, LOG_REFRESH_MS, { enabled: autoRefresh, immediate: false });
|
||||
|
||||
async function loadLogs() {
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Ban, Activity, TimerReset } from 'lucide-react';
|
||||
import api from '../../services/api';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
import { usePolling } from '../../hooks/usePolling';
|
||||
import EmptyState from '../EmptyState';
|
||||
import { DataTable, DataTableFooter, SearchField } from '@/components/ds';
|
||||
import {
|
||||
@@ -114,11 +115,10 @@ export default function ProcessListPanel({ conn, engine, active, isAdmin }) {
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !autoRefresh) return undefined;
|
||||
const t = setInterval(() => load(true), REFRESH_MS);
|
||||
return () => clearInterval(t);
|
||||
}, [active, autoRefresh, load]);
|
||||
usePolling(() => load(true), REFRESH_MS, {
|
||||
enabled: active && autoRefresh,
|
||||
immediate: false, // the effect above already loaded once
|
||||
});
|
||||
|
||||
async function killProcess(proc) {
|
||||
const verb = engineKind === 'postgresql' ? 'Terminate' : 'Kill';
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
// machines ServerKit runs on, and nothing described the things it is supposed to
|
||||
// be watching. Host health stays below; this sits above it because "is the site
|
||||
// up" is the question the page is actually for.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Activity, Plus, Radar, ShieldAlert, Timer, Zap } from 'lucide-react';
|
||||
import api from '../../services/api';
|
||||
import { AreaChart, KpiBand, MetricCard, Pill } from '@/components/ds';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { monitorStateOf } from './monitorShared';
|
||||
import { usePolling } from '../../hooks/usePolling';
|
||||
|
||||
const POLL_MS = 15000;
|
||||
const FEED_LIMIT = 9;
|
||||
@@ -32,7 +33,6 @@ export default function MonitorsSummary({ refreshKey = 0 }) {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [feed, setFeed] = useState([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const pollRef = useRef(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -60,11 +60,13 @@ export default function MonitorsSummary({ refreshKey = 0 }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refresh = usePolling(load, POLL_MS);
|
||||
|
||||
// The parent bumps refreshKey to force a reload (e.g. after creating a
|
||||
// monitor); it is not part of the polling schedule.
|
||||
useEffect(() => {
|
||||
load();
|
||||
pollRef.current = setInterval(load, POLL_MS);
|
||||
return () => clearInterval(pollRef.current);
|
||||
}, [load, refreshKey]);
|
||||
if (refreshKey) refresh();
|
||||
}, [refreshKey, refresh]);
|
||||
|
||||
if (!loaded) return null;
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { createPollScheduler, DEFAULT_INTERVAL_MS } from '@/utils/pollScheduler';
|
||||
|
||||
// One convention for "keep this fresh while the user is looking at it".
|
||||
//
|
||||
// usePolling(loadStatus, 5000);
|
||||
// usePolling(loadLogs, 3000, { enabled: autoRefresh });
|
||||
//
|
||||
// Replaces the `useEffect(() => { const t = setInterval(load, 5000); return
|
||||
// () => clearInterval(t); })` block, which this codebase had ~47 of and which
|
||||
// gets two things wrong every time:
|
||||
//
|
||||
// * it starts a new request every 5s whether or not the last one came back,
|
||||
// so a slow endpoint turns into a growing pile of concurrent requests;
|
||||
// * it keeps polling a tab nobody is looking at.
|
||||
//
|
||||
// The scheduling itself lives in utils/pollScheduler.js so it can be tested
|
||||
// without a DOM; this is the React binding.
|
||||
//
|
||||
// `callback` is held in a ref, so an inline arrow re-created every render does
|
||||
// NOT restart the timer — the usual reason a hand-rolled poller silently polls
|
||||
// far faster than its stated interval.
|
||||
//
|
||||
// Returns a stable `refresh()` for "reload now" buttons, which goes through the
|
||||
// same in-flight guard.
|
||||
export function usePolling(callback, intervalMs = DEFAULT_INTERVAL_MS, options = {}) {
|
||||
const {
|
||||
enabled = true,
|
||||
immediate = true,
|
||||
pauseWhenHidden = true,
|
||||
onError,
|
||||
} = options;
|
||||
|
||||
const callbackRef = useRef(callback);
|
||||
const onErrorRef = useRef(onError);
|
||||
callbackRef.current = callback;
|
||||
onErrorRef.current = onError;
|
||||
|
||||
const schedulerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !intervalMs) return undefined;
|
||||
|
||||
const scheduler = createPollScheduler({
|
||||
run: () => callbackRef.current?.(),
|
||||
intervalMs,
|
||||
setTimer: (fn, ms) => window.setTimeout(fn, ms),
|
||||
clearTimer: (handle) => window.clearTimeout(handle),
|
||||
isHidden: () => (
|
||||
pauseWhenHidden
|
||||
&& typeof document !== 'undefined'
|
||||
&& document.visibilityState === 'hidden'
|
||||
),
|
||||
onError: (error) => onErrorRef.current?.(error),
|
||||
});
|
||||
schedulerRef.current = scheduler;
|
||||
scheduler.start({ runNow: immediate });
|
||||
|
||||
let onVisibility;
|
||||
if (pauseWhenHidden && typeof document !== 'undefined') {
|
||||
onVisibility = () => scheduler.setHidden(document.visibilityState === 'hidden');
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (onVisibility) document.removeEventListener('visibilitychange', onVisibility);
|
||||
scheduler.stop();
|
||||
schedulerRef.current = null;
|
||||
};
|
||||
}, [enabled, intervalMs, immediate, pauseWhenHidden]);
|
||||
|
||||
return useCallback(() => {
|
||||
const scheduler = schedulerRef.current;
|
||||
if (scheduler) {
|
||||
scheduler.refresh();
|
||||
return;
|
||||
}
|
||||
// Disabled or unmounted: a manual refresh should still do the work.
|
||||
callbackRef.current?.();
|
||||
}, []);
|
||||
}
|
||||
|
||||
export default usePolling;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { useWorkspace } from '../contexts/WorkspaceContext';
|
||||
import { queryClient, serializeQueryKey } from '../services/queryClient';
|
||||
import { usePolling } from './usePolling';
|
||||
|
||||
const workspaceKey = (workspaceId, queryKey) => ['workspace', workspaceId, ...queryKey];
|
||||
|
||||
@@ -8,6 +9,11 @@ export function useServerQuery(queryKey, queryFn, options = {}) {
|
||||
const {
|
||||
enabled = true,
|
||||
staleTime = 0,
|
||||
// Keep this key fresh on an interval. Two things a hand-rolled
|
||||
// setInterval next to a useState does not get: the query client already
|
||||
// dedupes concurrent fetches of the same key, and the scheduler below
|
||||
// stops entirely while the tab is hidden, catching up once on return.
|
||||
refetchInterval = 0,
|
||||
onError,
|
||||
} = options;
|
||||
const { activeWorkspaceId } = useWorkspace();
|
||||
@@ -60,6 +66,13 @@ export function useServerQuery(queryKey, queryFn, options = {}) {
|
||||
[scopedKeyHash, staleTime],
|
||||
);
|
||||
|
||||
usePolling(refetch, refetchInterval, {
|
||||
enabled: enabled && refetchInterval > 0,
|
||||
// The mount effect above already fetched; polling adds the *repeat*.
|
||||
immediate: false,
|
||||
onError: (error) => onErrorRef.current?.(error),
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
isLoading: state.status === 'idle' || state.status === 'loading',
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createPollScheduler } from '../pollScheduler.js';
|
||||
|
||||
// A hand-driven clock. The scheduler only ever talks to timers through the
|
||||
// injected pair, so a test can advance time deterministically.
|
||||
function fakeClock() {
|
||||
let next = 1;
|
||||
const pending = new Map();
|
||||
return {
|
||||
setTimer(fn, ms) {
|
||||
const id = next++;
|
||||
pending.set(id, { fn, ms });
|
||||
return id;
|
||||
},
|
||||
clearTimer(id) { pending.delete(id); },
|
||||
/** Fire every timer currently scheduled (one round). */
|
||||
async fire() {
|
||||
const round = [...pending.entries()];
|
||||
round.forEach(([id]) => pending.delete(id));
|
||||
round.forEach(([, t]) => t.fn());
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
},
|
||||
get scheduled() { return pending.size; },
|
||||
};
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
|
||||
test('runs immediately on start, then on each tick', async () => {
|
||||
const clock = fakeClock();
|
||||
let runs = 0;
|
||||
const poller = createPollScheduler({
|
||||
run: () => { runs += 1; },
|
||||
intervalMs: 1000,
|
||||
setTimer: clock.setTimer,
|
||||
clearTimer: clock.clearTimer,
|
||||
});
|
||||
|
||||
poller.start();
|
||||
assert.equal(runs, 1, 'start() should run once immediately');
|
||||
|
||||
await clock.fire();
|
||||
assert.equal(runs, 2);
|
||||
await clock.fire();
|
||||
assert.equal(runs, 3);
|
||||
|
||||
poller.stop();
|
||||
});
|
||||
|
||||
|
||||
test('start({runNow:false}) waits for the first interval', async () => {
|
||||
const clock = fakeClock();
|
||||
let runs = 0;
|
||||
const poller = createPollScheduler({
|
||||
run: () => { runs += 1; },
|
||||
intervalMs: 1000,
|
||||
setTimer: clock.setTimer,
|
||||
clearTimer: clock.clearTimer,
|
||||
});
|
||||
|
||||
poller.start({ runNow: false });
|
||||
assert.equal(runs, 0);
|
||||
await clock.fire();
|
||||
assert.equal(runs, 1);
|
||||
poller.stop();
|
||||
});
|
||||
|
||||
|
||||
test('the schedule itself can never overlap a request', async () => {
|
||||
// The stampede this module exists to prevent. Note the schedule cannot
|
||||
// produce one *structurally*: the next timer is armed by the previous run
|
||||
// finishing, so while a request is in flight there is no timer at all.
|
||||
// That is the property worth pinning — a guard you can also delete without
|
||||
// any test noticing would be no guard.
|
||||
const clock = fakeClock();
|
||||
const first = deferred();
|
||||
let starts = 0;
|
||||
const poller = createPollScheduler({
|
||||
run: () => { starts += 1; return first.promise; },
|
||||
intervalMs: 1000,
|
||||
setTimer: clock.setTimer,
|
||||
clearTimer: clock.clearTimer,
|
||||
});
|
||||
|
||||
poller.start();
|
||||
assert.equal(starts, 1);
|
||||
assert.equal(clock.scheduled, 0, 'no timer may be armed while a request is in flight');
|
||||
|
||||
await clock.fire();
|
||||
await clock.fire();
|
||||
assert.equal(starts, 1, 'nothing to fire, so nothing stacked');
|
||||
|
||||
first.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
await clock.fire();
|
||||
assert.equal(starts, 2, 'polling resumes once the request settles');
|
||||
poller.stop();
|
||||
});
|
||||
|
||||
|
||||
test('out-of-band runs are what the in-flight guard actually protects', async () => {
|
||||
// refresh() and the visibility catch-up bypass the schedule, so they are
|
||||
// the paths that CAN land on top of a request in flight.
|
||||
const clock = fakeClock();
|
||||
const pending = deferred();
|
||||
let starts = 0;
|
||||
const poller = createPollScheduler({
|
||||
run: () => { starts += 1; return pending.promise; },
|
||||
intervalMs: 1000,
|
||||
setTimer: clock.setTimer,
|
||||
clearTimer: clock.clearTimer,
|
||||
isHidden: () => false,
|
||||
});
|
||||
|
||||
poller.start();
|
||||
assert.equal(starts, 1);
|
||||
|
||||
poller.refresh();
|
||||
poller.refresh();
|
||||
poller.setHidden(false); // a catch-up while the first is still open
|
||||
poller.start(); // and a redundant start
|
||||
|
||||
assert.equal(starts, 1, 'none of these may stack a second request');
|
||||
assert.equal(poller.skippedTicks, 4);
|
||||
|
||||
pending.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await clock.fire();
|
||||
assert.equal(starts, 2);
|
||||
poller.stop();
|
||||
});
|
||||
|
||||
|
||||
test('a skipped tick does not queue up a burst afterwards', async () => {
|
||||
// Skipping must mean "drop it", not "remember it". Otherwise the stampede
|
||||
// just arrives later, all at once.
|
||||
const clock = fakeClock();
|
||||
const slow = deferred();
|
||||
let starts = 0;
|
||||
const poller = createPollScheduler({
|
||||
run: () => { starts += 1; return starts === 1 ? slow.promise : Promise.resolve(); },
|
||||
intervalMs: 1000,
|
||||
setTimer: clock.setTimer,
|
||||
clearTimer: clock.clearTimer,
|
||||
});
|
||||
|
||||
poller.start();
|
||||
for (let i = 0; i < 5; i += 1) await clock.fire();
|
||||
assert.equal(starts, 1);
|
||||
|
||||
slow.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
await clock.fire();
|
||||
assert.equal(starts, 2, 'exactly one run, not five');
|
||||
assert.equal(clock.scheduled, 1, 'and exactly one timer outstanding');
|
||||
poller.stop();
|
||||
});
|
||||
|
||||
|
||||
test('the gap is measured after the work finishes, not tick to tick', async () => {
|
||||
const clock = fakeClock();
|
||||
let starts = 0;
|
||||
let pendingRun = deferred();
|
||||
const poller = createPollScheduler({
|
||||
run: () => { starts += 1; return pendingRun.promise; },
|
||||
intervalMs: 1000,
|
||||
setTimer: clock.setTimer,
|
||||
clearTimer: clock.clearTimer,
|
||||
});
|
||||
|
||||
poller.start();
|
||||
assert.equal(clock.scheduled, 0, 'nothing is scheduled while work is in flight');
|
||||
|
||||
pendingRun.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(clock.scheduled, 1, 'the next tick is scheduled only once work settled');
|
||||
|
||||
pendingRun = deferred();
|
||||
await clock.fire();
|
||||
assert.equal(starts, 2);
|
||||
poller.stop();
|
||||
});
|
||||
|
||||
|
||||
test('stops polling while the tab is hidden and catches up when it returns', async () => {
|
||||
const clock = fakeClock();
|
||||
let hidden = false;
|
||||
let runs = 0;
|
||||
const poller = createPollScheduler({
|
||||
run: () => { runs += 1; },
|
||||
intervalMs: 1000,
|
||||
setTimer: clock.setTimer,
|
||||
clearTimer: clock.clearTimer,
|
||||
isHidden: () => hidden,
|
||||
});
|
||||
|
||||
poller.start();
|
||||
assert.equal(runs, 1);
|
||||
|
||||
hidden = true;
|
||||
poller.setHidden(true);
|
||||
assert.equal(clock.scheduled, 0, 'no timer should be armed for a hidden tab');
|
||||
await clock.fire();
|
||||
assert.equal(runs, 1, 'a hidden tab must not poll');
|
||||
|
||||
hidden = false;
|
||||
poller.setHidden(false);
|
||||
assert.equal(runs, 2, 'coming back runs once immediately to catch up');
|
||||
|
||||
await clock.fire();
|
||||
assert.equal(runs, 3, 'and resumes the schedule');
|
||||
poller.stop();
|
||||
});
|
||||
|
||||
|
||||
test('stop() cancels the outstanding timer', async () => {
|
||||
const clock = fakeClock();
|
||||
let runs = 0;
|
||||
const poller = createPollScheduler({
|
||||
run: () => { runs += 1; },
|
||||
intervalMs: 1000,
|
||||
setTimer: clock.setTimer,
|
||||
clearTimer: clock.clearTimer,
|
||||
});
|
||||
|
||||
poller.start();
|
||||
poller.stop();
|
||||
assert.equal(clock.scheduled, 0);
|
||||
await clock.fire();
|
||||
assert.equal(runs, 1, 'no further runs after stop()');
|
||||
assert.equal(poller.isRunning, false);
|
||||
});
|
||||
|
||||
|
||||
test('a rejected run does not wedge the poller', async () => {
|
||||
// If a failed request left inFlight true, the poller would go silent
|
||||
// forever — the worst failure mode, because the page looks live.
|
||||
const clock = fakeClock();
|
||||
let starts = 0;
|
||||
const errors = [];
|
||||
const poller = createPollScheduler({
|
||||
run: () => { starts += 1; return Promise.reject(new Error('boom')); },
|
||||
intervalMs: 1000,
|
||||
setTimer: clock.setTimer,
|
||||
clearTimer: clock.clearTimer,
|
||||
onError: (error) => errors.push(error.message),
|
||||
});
|
||||
|
||||
poller.start();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(errors, ['boom'], 'the failure is surfaced, not swallowed');
|
||||
|
||||
await clock.fire();
|
||||
assert.equal(starts, 2, 'polling continues after a failure');
|
||||
assert.equal(poller.isInFlight, false);
|
||||
poller.stop();
|
||||
});
|
||||
|
||||
|
||||
test('a synchronously throwing run does not wedge the poller either', async () => {
|
||||
const clock = fakeClock();
|
||||
let starts = 0;
|
||||
const poller = createPollScheduler({
|
||||
run: () => { starts += 1; throw new Error('sync boom'); },
|
||||
intervalMs: 1000,
|
||||
setTimer: clock.setTimer,
|
||||
clearTimer: clock.clearTimer,
|
||||
onError: () => {},
|
||||
});
|
||||
|
||||
poller.start();
|
||||
assert.equal(poller.isInFlight, false);
|
||||
await clock.fire();
|
||||
assert.equal(starts, 2);
|
||||
poller.stop();
|
||||
});
|
||||
|
||||
|
||||
test('refresh() honours the in-flight guard', async () => {
|
||||
const clock = fakeClock();
|
||||
const pending = deferred();
|
||||
let starts = 0;
|
||||
const poller = createPollScheduler({
|
||||
run: () => { starts += 1; return pending.promise; },
|
||||
intervalMs: 1000,
|
||||
setTimer: clock.setTimer,
|
||||
clearTimer: clock.clearTimer,
|
||||
});
|
||||
|
||||
poller.start();
|
||||
poller.refresh();
|
||||
poller.refresh();
|
||||
assert.equal(starts, 1, 'a manual refresh must not stack either');
|
||||
pending.resolve();
|
||||
poller.stop();
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
// The scheduling rules behind usePolling, as a plain object so they can be
|
||||
// tested without a DOM.
|
||||
//
|
||||
// Two rules, both of which every hand-rolled `setInterval(load, 5000)` in this
|
||||
// codebase got wrong:
|
||||
//
|
||||
// 1. NEVER let a tick start while the previous one is still in flight. A poller
|
||||
// on a 5s interval against a request that has started taking 30s does not
|
||||
// slow down — it stacks, and each new request makes the server slower, which
|
||||
// makes the next request more likely to overlap. That is the stampede: the
|
||||
// page gets slower the longer you leave it open, and the server sees a load
|
||||
// it never sees in a fresh session.
|
||||
//
|
||||
// 2. Do not poll a tab nobody is looking at. A background tab polling every 5s
|
||||
// is pure cost, and browsers throttle its timers unevenly anyway, so it is
|
||||
// not even a reliable refresh. Catch up ONCE when it comes back instead —
|
||||
// which is also what makes the data fresh at the moment someone looks.
|
||||
//
|
||||
// A skipped tick is not a dropped update: the next one takes its place, and the
|
||||
// in-flight request that caused the skip is delivering the same data.
|
||||
|
||||
export const DEFAULT_INTERVAL_MS = 5000;
|
||||
|
||||
/**
|
||||
* @param {object} config
|
||||
* @param {() => (Promise<any>|any)} config.run the work to repeat
|
||||
* @param {number} config.intervalMs delay BETWEEN runs
|
||||
* @param {(fn: Function, ms: number) => any} config.setTimer
|
||||
* @param {(handle: any) => void} config.clearTimer
|
||||
* @param {() => boolean} [config.isHidden] document.hidden, injected
|
||||
* @param {(error: any) => void} [config.onError]
|
||||
*/
|
||||
export function createPollScheduler({
|
||||
run,
|
||||
intervalMs = DEFAULT_INTERVAL_MS,
|
||||
setTimer,
|
||||
clearTimer,
|
||||
isHidden = () => false,
|
||||
onError,
|
||||
}) {
|
||||
let handle = null;
|
||||
let inFlight = false;
|
||||
let stopped = true;
|
||||
let skipped = 0;
|
||||
|
||||
const clear = () => {
|
||||
if (handle !== null) {
|
||||
clearTimer(handle);
|
||||
handle = null;
|
||||
}
|
||||
};
|
||||
|
||||
// A timeout chained after each run, not setInterval: with setInterval the
|
||||
// delay is measured from tick to tick regardless of how long the work took,
|
||||
// so slow work silently turns a 5s gap into no gap at all.
|
||||
const schedule = () => {
|
||||
clear();
|
||||
if (stopped || isHidden()) return;
|
||||
handle = setTimer(tick, intervalMs);
|
||||
};
|
||||
|
||||
function tick() {
|
||||
handle = null;
|
||||
if (stopped) return;
|
||||
if (inFlight) {
|
||||
// Rule 1. Do not queue behind it; the next tick will do.
|
||||
skipped += 1;
|
||||
schedule();
|
||||
return;
|
||||
}
|
||||
if (isHidden()) return; // rule 2; onVisible() resumes
|
||||
|
||||
let result;
|
||||
inFlight = true;
|
||||
try {
|
||||
result = run();
|
||||
} catch (error) {
|
||||
inFlight = false;
|
||||
onError?.(error);
|
||||
schedule();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result || typeof result.then !== 'function') {
|
||||
inFlight = false;
|
||||
schedule();
|
||||
return;
|
||||
}
|
||||
|
||||
result.then(
|
||||
() => { inFlight = false; schedule(); },
|
||||
(error) => { inFlight = false; onError?.(error); schedule(); },
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
/** Begin polling. `runNow` performs the first run immediately. */
|
||||
start({ runNow = true } = {}) {
|
||||
stopped = false;
|
||||
if (runNow) tick();
|
||||
else schedule();
|
||||
},
|
||||
stop() {
|
||||
stopped = true;
|
||||
clear();
|
||||
},
|
||||
/** Visibility changed. Coming back runs once immediately to catch up. */
|
||||
setHidden(hidden) {
|
||||
if (stopped) return;
|
||||
if (hidden) clear();
|
||||
else tick();
|
||||
},
|
||||
/** Run now, outside the schedule, honouring the in-flight guard. */
|
||||
refresh() {
|
||||
if (stopped) {
|
||||
stopped = false;
|
||||
}
|
||||
tick();
|
||||
},
|
||||
/** Test/diagnostic surface. */
|
||||
get isRunning() { return !stopped; },
|
||||
get isInFlight() { return inFlight; },
|
||||
get skippedTicks() { return skipped; },
|
||||
};
|
||||
}
|
||||
|
||||
export default createPollScheduler;
|
||||
Reference in new issue
Block a user