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.
This commit is contained in:
parent
9338496e24
commit
8917ba27b6
2 changed files with 274 additions and 160 deletions
53
frontend/packages/swarm-ui/src/ui/polled/poller.ts
Normal file
53
frontend/packages/swarm-ui/src/ui/polled/poller.ts
Normal file
|
|
@ -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<T>(opts: {
|
||||
fetch: (signal: AbortSignal) => Promise<T>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
71
frontend/packages/swarm-ui/src/ui/polled/usePolled.ts
Normal file
71
frontend/packages/swarm-ui/src/ui/polled/usePolled.ts
Normal file
|
|
@ -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<T>(
|
||||
url: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<T> {
|
||||
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<T>(
|
||||
intervalMs: RefreshIntervalMs,
|
||||
fetcher: (signal: AbortSignal) => Promise<T>,
|
||||
): {
|
||||
data: T | null;
|
||||
error: ProblemDetails | null;
|
||||
refresh: () => void;
|
||||
setData: (update: (prev: T | null) => T | null) => void;
|
||||
} {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
|
||||
const fetcherRef = useRef(fetcher);
|
||||
fetcherRef.current = fetcher;
|
||||
const pollerRef = useRef<Poller | null>(null);
|
||||
if (pollerRef.current === null) {
|
||||
pollerRef.current = createPoller<T>({
|
||||
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 };
|
||||
}
|
||||
Loading…
Reference in a new issue