From 8917ba27b652684c236f6a2e1440362d4c747948 Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 21 Oct 2026 22:03:59 +0200 Subject: [PATCH] swarm-ui: one polled-resource hook for the refreshing pages Each polling page repeated its own fetch-and-set code on a fixed timer, so a slow answer could be overtaken by the next tick, an older response could overwrite a newer one, and nothing aborted a request when the page went away. usePolled skips a tick while a request is in flight, aborts on unmount or refresh, ignores answers from dropped requests, and keeps the last good data under an error. The alerts, hives, agents, schedules, builds, review runs and broadcast pages and the subagent list use it. --- docs/swarm/ui.md | 4 + .../src/agent/terminal/useSubagentList.ts | 36 +++---- .../swarm-ui/src/pages/AlertsPage.tsx | 20 +--- .../packages/swarm-ui/src/pages/HivesPage.tsx | 23 +---- .../swarm-ui/src/pages/agents/AgentsPage.tsx | 29 ++---- .../src/pages/broadcast/BroadcastPage.tsx | 21 +---- .../swarm-ui/src/pages/builds/BuildsPage.tsx | 24 +---- .../src/pages/review-runs/ReviewRuns.tsx | 24 +---- .../src/pages/schedules/SchedulesPage.tsx | 35 +++---- .../packages/swarm-ui/src/ui/polled/poller.ts | 53 +++++++++++ .../swarm-ui/src/ui/polled/usePolled.ts | 71 ++++++++++++++ .../packages/swarm-ui/test/poller.test.mjs | 94 +++++++++++++++++++ 12 files changed, 274 insertions(+), 160 deletions(-) create mode 100644 frontend/packages/swarm-ui/src/ui/polled/poller.ts create mode 100644 frontend/packages/swarm-ui/src/ui/polled/usePolled.ts create mode 100644 frontend/packages/swarm-ui/test/poller.test.mjs diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index d0eb77a6..7e7ee3e7 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -24,6 +24,10 @@ domain, covers host-level detail for one hive. | `/builds` | the swarm builder's recent builds of agent config PRs, with what nix printed when one failed | Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md). +Pages that refresh on a timer share one polling rule: a tick is skipped while +the last request is still running, so a slow answer is never overtaken by an +older one, and a failed refresh keeps the rows already on screen under an +error. An agent created here or with `swarmctl agent create` starts `paused`; set it `up` from its card. A card shows **login needed** when the agent's own turn-state header says its claude has no session, and a **log in** button diff --git a/frontend/packages/swarm-ui/src/agent/terminal/useSubagentList.ts b/frontend/packages/swarm-ui/src/agent/terminal/useSubagentList.ts index 6810f3f7..a514fbe3 100644 --- a/frontend/packages/swarm-ui/src/agent/terminal/useSubagentList.ts +++ b/frontend/packages/swarm-ui/src/agent/terminal/useSubagentList.ts @@ -1,10 +1,9 @@ // An agent's subagents, refetched on a timer: `GET /api/agents/{name}/subagents`. // Subagents are spawned on demand, so a one-off fetch would go stale. `null` // until the first answer; a failed fetch sets `error` and keeps the last list. -// Mount per agent name: the timer is armed once per mount. -import { useState } from "preact/hooks"; -import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; -import { useRefreshInterval } from "../../ui/refresh-interval/RefreshInterval.js"; +// Mount per agent name: the poll belongs to one agent. +import type { ProblemDetails } from "@hive/shared/api-error.js"; +import { fetchJson, usePolled } from "../../ui/polled/usePolled.js"; import { byRecency, type Subagent } from "./subagent-list.js"; const REFRESH_MS = 10_000; @@ -13,24 +12,13 @@ export function useSubagentList(agentName: string): { subagents: Subagent[] | null; error: ProblemDetails | null; } { - const [subagents, setSubagents] = useState(null); - const [error, setError] = useState(null); - - async function refresh() { - const res = await fetch( - `/api/agents/${encodeURIComponent(agentName)}/subagents`, - ); - if (!res.ok) { - setError(await readApiError(res)); - return; - } - setSubagents(((await res.json()) as Subagent[]).sort(byRecency)); - setError(null); - } - - useRefreshInterval(REFRESH_MS, () => { - refresh().catch((e: unknown) => setError({ detail: String(e) })); - }); - - return { subagents, error }; + const { data, error } = usePolled(REFRESH_MS, async (signal) => + ( + await fetchJson( + `/api/agents/${encodeURIComponent(agentName)}/subagents`, + signal, + ) + ).sort(byRecency), + ); + return { subagents: data, error }; } diff --git a/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx b/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx index a0c23add..42bd0b1e 100644 --- a/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx @@ -5,16 +5,15 @@ // rendered as a `Table`. import { useState } from "preact/hooks"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; -import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { Badge, type BadgeTone } from "@hive/shared/badge.js"; import { Button } from "../ui/button/Button.js"; import { Panel } from "../ui/panel/Panel.js"; import { RelativeTime } from "../ui/relative-time/RelativeTime.js"; import { RefreshIntervalPicker, - useRefreshInterval, type RefreshIntervalMs, } from "../ui/refresh-interval/RefreshInterval.js"; +import { fetchJson, usePolled } from "../ui/polled/usePolled.js"; import { Table, type TableColumn } from "../ui/table/Table.js"; import { activeSinceMs, alertKey, listedAlerts, type Alert } from "./alerts.js"; @@ -73,26 +72,15 @@ const COLUMNS: TableColumn[] = [ const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000; export function AlertsPage() { - const [alerts, setAlerts] = useState(null); - const [error, setError] = useState(null); const [showAll, setShowAll] = useState(false); const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); + const { data: alerts, error } = usePolled(intervalMs, (signal) => + fetchJson("/api/alerts", signal), + ); const listed = alerts ? listedAlerts(alerts, showAll) : null; const hidden = alerts && listed ? alerts.length - listed.length : 0; - useRefreshInterval(intervalMs, () => { - (async () => { - const r = await fetch("/api/alerts"); - if (!r.ok) { - setError(await readApiError(r)); - return; - } - setAlerts((await r.json()) as Alert[]); - setError(null); - })().catch((e: unknown) => setError({ detail: String(e) })); - }); - return ( [] = [ const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000; export function HivesPage() { - const [hives, setHives] = useState(null); - const [error, setError] = useState(null); const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); - - useRefreshInterval(intervalMs, () => { - (async () => { - const r = await fetch("/api/hives/status"); - if (!r.ok) { - setError(await readApiError(r)); - return; - } - setHives((await r.json()) as HiveStatus[]); - // A refresh that succeeds clears a previous failure — otherwise a - // transient error would sit on screen forever after the data - // itself has recovered. - setError(null); - })().catch((e: unknown) => setError({ detail: String(e) })); - }); + const { data: hives, error } = usePolled(intervalMs, (signal) => + fetchJson("/api/hives/status", signal), + ); return ( (null); - const [error, setError] = useState(null); const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); + const { + data: rows, + error, + setData: setRows, + } = usePolled(intervalMs, (signal) => + fetchJson("/api/agents/status", signal), + ); // Lazy initializer (not a mount effect) so the very first render // already reflects the stored choice — no default-then-flip flash. const [viewMode, setViewModeState] = useState(() => { @@ -116,23 +120,6 @@ export function AgentsPage() { const [detailTargetName, setDetailTargetName] = useState(null); const detailTarget = rows?.find((r) => r.name === detailTargetName) ?? null; - async function refresh() { - const res = await fetch("/api/agents/status"); - if (!res.ok) { - setError(await readApiError(res)); - return; - } - setRows((await res.json()) as AgentRow[]); - // A refresh that succeeds clears a previous failure — otherwise a - // transient error would sit on screen forever after the data itself - // has recovered. - setError(null); - } - - useRefreshInterval(intervalMs, () => { - refresh().catch((e: unknown) => setError({ detail: String(e) })); - }); - // `FilterableView`'s `columns` — the full field set (including // per-column sort/filter, which drives both the table's own popovers // and the card view's filter bar now — see that component's own diff --git a/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastPage.tsx b/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastPage.tsx index 1db66ea3..1f39e37e 100644 --- a/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastPage.tsx @@ -19,9 +19,9 @@ import { Button } from "../../ui/button/Button.js"; import { Panel } from "../../ui/panel/Panel.js"; import { RefreshIntervalPicker, - useRefreshInterval, type RefreshIntervalMs, } from "../../ui/refresh-interval/RefreshInterval.js"; +import { fetchJson, usePolled } from "../../ui/polled/usePolled.js"; import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js"; import { failedAgents, @@ -58,10 +58,11 @@ function storedFilter(): AgentFilter { } export function BroadcastPage() { - const [agents, setAgents] = useState(null); - const [loadError, setLoadError] = useState(null); const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); + const { data: agents, error: loadError } = usePolled(intervalMs, (signal) => + fetchJson("/api/agents/status", signal), + ); const [filter, setFilterState] = useState(storedFilter); const [ticked, setTicked] = useState([]); const [body, setBody] = useState(""); @@ -76,20 +77,6 @@ export function BroadcastPage() { } } - async function refresh() { - const res = await fetch("/api/agents/status"); - if (!res.ok) { - setLoadError(await readApiError(res)); - return; - } - setAgents((await res.json()) as BroadcastAgent[]); - setLoadError(null); - } - - useRefreshInterval(intervalMs, () => { - refresh().catch((e: unknown) => setLoadError({ detail: String(e) })); - }); - const all = agents ?? []; const shown = visibleAgents(all, filter); const targets = selectedVisible(ticked, shown); diff --git a/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx b/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx index 161c663f..49263b6a 100644 --- a/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx @@ -6,15 +6,14 @@ import { useEffect, useRef, useState } from "preact/hooks"; import { useSearch } from "wouter-preact"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; -import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { Badge } from "@hive/shared/badge.js"; import { Card } from "../../ui/card/Card.js"; import { Panel } from "../../ui/panel/Panel.js"; import { RefreshIntervalPicker, - useRefreshInterval, type RefreshIntervalMs, } from "../../ui/refresh-interval/RefreshInterval.js"; +import { fetchJson, usePolled } from "../../ui/polled/usePolled.js"; import { RelativeTime } from "../../ui/relative-time/RelativeTime.js"; import { durationLabel } from "../review-runs/RunsTypes.js"; import { @@ -96,8 +95,9 @@ export function BuildsPage() { const wanted = wantedRun(useSearch()); const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); - const [runs, setRuns] = useState(null); - const [error, setError] = useState(null); + const { data: runs, error } = usePolled(intervalMs, (signal) => + fetchJson(RUNS, signal), + ); // Which cards are open. Survives a refresh, so a list that updates under // you does not fold the card you are reading. A linked build starts open. const [open, setOpen] = useState>( @@ -105,22 +105,6 @@ export function BuildsPage() { ); const scrolledTo = useRef(null); - async function load() { - try { - const res = await fetch(RUNS); - if (!res.ok) { - setError(await readApiError(res)); - return; - } - setRuns((await res.json()) as BuildRun[]); - setError(null); - } catch (e: unknown) { - setError({ detail: String(e) }); - } - } - - useRefreshInterval(intervalMs, () => void load()); - // Scroll to the linked build once its card exists, not on every refresh. useEffect(() => { if (!wanted || !runs || scrolledTo.current === wanted) return; diff --git a/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.tsx b/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.tsx index 7e14d594..28205914 100644 --- a/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.tsx +++ b/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.tsx @@ -6,15 +6,14 @@ // posts nothing, and shows no token counts, cache counts or cost. import { useState } from "preact/hooks"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; -import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { Badge } from "@hive/shared/badge.js"; import { Card } from "../../ui/card/Card.js"; import { Panel } from "../../ui/panel/Panel.js"; import { RefreshIntervalPicker, - useRefreshInterval, type RefreshIntervalMs, } from "../../ui/refresh-interval/RefreshInterval.js"; +import { fetchJson, usePolled } from "../../ui/polled/usePolled.js"; import { RelativeTime } from "../../ui/relative-time/RelativeTime.js"; import { TryOutResult } from "../review-tryout/TryOutResult.js"; import { @@ -94,28 +93,13 @@ function RunCard({ export function ReviewRuns() { const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); - const [runs, setRuns] = useState(null); - const [error, setError] = useState(null); + const { data: runs, error } = usePolled(intervalMs, (signal) => + fetchJson(RUNS, signal), + ); // Which cards are expanded. Survives a refresh, so a list that updates // under you does not fold the card you are reading. const [open, setOpen] = useState>(new Set()); - async function load() { - try { - const res = await fetch(RUNS); - if (!res.ok) { - setError(await readApiError(res)); - return; - } - setRuns((await res.json()) as ClassifierRun[]); - setError(null); - } catch (e: unknown) { - setError({ detail: String(e) }); - } - } - - useRefreshInterval(intervalMs, () => void load()); - function toggle(id: string) { setOpen((cur) => { const next = new Set(cur); diff --git a/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx b/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx index 247a53c1..bdde1fa8 100644 --- a/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx @@ -9,7 +9,7 @@ // multiselects). A card being edited keeps its form state across the // periodic refetch because `FilterableView` keys cards by schedule id and // the open edit is held here by id, not by position. -import { useCallback, useEffect, useMemo, useState } from "preact/hooks"; +import { useEffect, useMemo, useState } from "preact/hooks"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { Button } from "../../ui/button/Button.js"; @@ -18,9 +18,9 @@ import { FilterableView } from "../../ui/filterable-view/FilterableView.js"; import { Panel } from "../../ui/panel/Panel.js"; import { RefreshIntervalPicker, - useRefreshInterval, type RefreshIntervalMs, } from "../../ui/refresh-interval/RefreshInterval.js"; +import { fetchJson, usePolled } from "../../ui/polled/usePolled.js"; import type { TableColumn } from "../../ui/table/Table.js"; import type { AgentRow } from "../../agent/AgentTypes.js"; import { ScheduleCard } from "./ScheduleCard.js"; @@ -69,8 +69,13 @@ async function loadAgentNames(): Promise { export function SchedulesPage() { const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); - const [schedules, setSchedules] = useState(null); - const [error, setError] = useState(null); + const { + data: schedules, + error, + refresh, + } = usePolled(intervalMs, (signal) => + fetchJson("/api/schedules", signal), + ); const [agentNames, setAgentNames] = useState([]); // The schedule whose card is currently its edit form, and whether the // "new schedule" form is open above the list. @@ -80,22 +85,6 @@ export function SchedulesPage() { const [removeTarget, setRemoveTarget] = useState(null); const [actionError, setActionError] = useState(null); - const load = useCallback(async () => { - try { - const r = await fetch("/api/schedules"); - if (!r.ok) { - setError(await readApiError(r)); - return; - } - setSchedules((await r.json()) as Schedule[]); - setError(null); - } catch (err) { - setError({ detail: String(err) }); - } - }, []); - - useRefreshInterval(intervalMs, load); - useEffect(() => { loadAgentNames().then(setAgentNames, () => setAgentNames([])); }, []); @@ -121,7 +110,7 @@ export function SchedulesPage() { setActionError(await readApiError(r)); return; } - await load(); + refresh(); } catch (err) { setActionError({ detail: String(err) }); } finally { @@ -158,10 +147,10 @@ export function SchedulesPage() { [agentNames], ); - async function saved() { + function saved() { setCreating(false); setEditingId(null); - await load(); + refresh(); } return ( diff --git a/frontend/packages/swarm-ui/src/ui/polled/poller.ts b/frontend/packages/swarm-ui/src/ui/polled/poller.ts new file mode 100644 index 00000000..8d32fe26 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/polled/poller.ts @@ -0,0 +1,53 @@ +// The request discipline behind a polled page, kept free of Preact so it can +// be tested on its own. `tick` fetches unless a request is already in flight, +// so a slow answer is not overtaken by the next tick. `refetch` drops the +// request in flight and asks again, for a refresh that follows a change. +// `stop` drops the request in flight. An answer from a dropped request never +// reaches `onData` or `onError`, so an older response cannot overwrite a +// newer one or land on a page that is gone. +export interface Poller { + tick(): void; + refetch(): void; + stop(): void; +} + +export function createPoller(opts: { + fetch: (signal: AbortSignal) => Promise; + onData: (data: T) => void; + onError: (error: unknown) => void; +}): Poller { + let current: { controller: AbortController } | null = null; + + function start() { + const run = { controller: new AbortController() }; + current = run; + opts.fetch(run.controller.signal).then( + (data) => { + if (current !== run) return; + current = null; + opts.onData(data); + }, + (error: unknown) => { + if (current !== run) return; + current = null; + opts.onError(error); + }, + ); + } + + function stop() { + current?.controller.abort(); + current = null; + } + + return { + tick() { + if (current === null) start(); + }, + refetch() { + stop(); + start(); + }, + stop, + }; +} diff --git a/frontend/packages/swarm-ui/src/ui/polled/usePolled.ts b/frontend/packages/swarm-ui/src/ui/polled/usePolled.ts new file mode 100644 index 00000000..c12fb462 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/polled/usePolled.ts @@ -0,0 +1,71 @@ +// A resource refetched on a timer: `{ data, error, refresh, setData }`. +// `data` is `null` until the first answer; a failed fetch sets `error` and +// keeps the last good `data`; the next good answer clears `error`. The +// request discipline (skip a tick while one is in flight, drop answers that +// are out of date or arrive after unmount) lives in `createPoller`. +// +// `fetcher` gets an `AbortSignal` and returns the data or throws; `fetchJson` +// does that for a GET. It may change between renders: the latest one runs. +// `refresh` refetches now, dropping a request in flight, for after a change +// the page made. `setData` patches what is shown without a request. +import { useEffect, useRef, useState } from "preact/hooks"; +import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; +import { + useRefreshInterval, + type RefreshIntervalMs, +} from "../refresh-interval/RefreshInterval.js"; +import { createPoller, type Poller } from "./poller.js"; + +/** A failed request, carrying the problem the API answered with. */ +export class ProblemError extends Error { + constructor(readonly problem: ProblemDetails) { + super(problem.detail ?? problem.title ?? "request failed"); + } +} + +/** GET `url` as JSON; a non-2xx answer throws a `ProblemError`. */ +export async function fetchJson( + url: string, + signal: AbortSignal, +): Promise { + const res = await fetch(url, { signal }); + if (!res.ok) throw new ProblemError(await readApiError(res)); + return (await res.json()) as T; +} + +function toProblem(e: unknown): ProblemDetails { + return e instanceof ProblemError ? e.problem : { detail: String(e) }; +} + +export function usePolled( + intervalMs: RefreshIntervalMs, + fetcher: (signal: AbortSignal) => Promise, +): { + data: T | null; + error: ProblemDetails | null; + refresh: () => void; + setData: (update: (prev: T | null) => T | null) => void; +} { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + + const fetcherRef = useRef(fetcher); + fetcherRef.current = fetcher; + const pollerRef = useRef(null); + if (pollerRef.current === null) { + pollerRef.current = createPoller({ + fetch: (signal) => fetcherRef.current(signal), + onData: (next) => { + setData(next); + setError(null); + }, + onError: (e) => setError(toProblem(e)), + }); + } + const poller = pollerRef.current; + + useEffect(() => () => poller.stop(), [poller]); + useRefreshInterval(intervalMs, () => poller.tick()); + + return { data, error, refresh: () => poller.refetch(), setData }; +} diff --git a/frontend/packages/swarm-ui/test/poller.test.mjs b/frontend/packages/swarm-ui/test/poller.test.mjs new file mode 100644 index 00000000..78e08214 --- /dev/null +++ b/frontend/packages/swarm-ui/test/poller.test.mjs @@ -0,0 +1,94 @@ +// The request discipline of a polled page. Run by `npm test`, which Node runs +// with its built-in test runner and type stripping, so the `.ts` module is +// imported as it is. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createPoller } from "../src/ui/polled/poller.ts"; + +// A fetch whose answers the test settles by hand, in any order. +function manualFetch() { + const calls = []; + const fetch = (signal) => + new Promise((resolve, reject) => { + calls.push({ signal, resolve, reject }); + }); + return { calls, fetch }; +} + +// Lets the promise callbacks of a settled request run. +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +function collect() { + const seen = { data: [], error: [] }; + return { + seen, + onData: (d) => seen.data.push(d), + onError: (e) => seen.error.push(e), + }; +} + +test("a tick while a request is in flight asks for nothing", async () => { + const { calls, fetch } = manualFetch(); + const sink = collect(); + const poller = createPoller({ fetch, ...sink }); + poller.tick(); + poller.tick(); + assert.equal(calls.length, 1); + calls[0].resolve("one"); + await flush(); + assert.deepEqual(sink.seen.data, ["one"]); + poller.tick(); + assert.equal(calls.length, 2); +}); + +test("a refetch drops the request in flight, so an older answer never lands", async () => { + const { calls, fetch } = manualFetch(); + const sink = collect(); + const poller = createPoller({ fetch, ...sink }); + poller.tick(); + poller.refetch(); + assert.equal(calls.length, 2); + assert.equal(calls[0].signal.aborted, true); + calls[1].resolve("new"); + calls[0].resolve("old"); + await flush(); + assert.deepEqual(sink.seen.data, ["new"]); +}); + +test("a stopped poller aborts the request and ignores its answer", async () => { + const { calls, fetch } = manualFetch(); + const sink = collect(); + const poller = createPoller({ fetch, ...sink }); + poller.tick(); + poller.stop(); + assert.equal(calls[0].signal.aborted, true); + calls[0].resolve("late"); + await flush(); + assert.deepEqual(sink.seen.data, []); + poller.tick(); + assert.equal(calls.length, 2); +}); + +test("a failed request reports the error and frees the next tick", async () => { + const { calls, fetch } = manualFetch(); + const sink = collect(); + const poller = createPoller({ fetch, ...sink }); + poller.tick(); + const boom = new Error("boom"); + calls[0].reject(boom); + await flush(); + assert.deepEqual(sink.seen.error, [boom]); + poller.tick(); + assert.equal(calls.length, 2); +}); + +test("the answer of a dropped request that fails is not reported", async () => { + const { calls, fetch } = manualFetch(); + const sink = collect(); + const poller = createPoller({ fetch, ...sink }); + poller.tick(); + poller.refetch(); + calls[0].reject(new Error("aborted")); + await flush(); + assert.deepEqual(sink.seen.error, []); +});