usePolled takes a cache key: it saves each answer in localStorage and starts from the saved one, so opening the agents, alerts, builds, review runs or hives view shows the last data at once and the refresh spinner marks the update. A saved answer is dropped after a day, when it was saved by another build of the UI, at log out (a new onLogout of the shared user menu) and when the gateway ends the login (a new onSessionEnded of the session guard). Pages with an editor (schedules, notification flows, broadcast) do not use it.
103 lines
3.7 KiB
TypeScript
103 lines
3.7 KiB
TypeScript
// A resource refetched on a timer: `{ data, error, refreshing, refresh, setData }`.
|
|
// `data` is `null` until the first answer (or, with a `cacheKey`, the saved one
|
|
// of the last visit, which is shown at once and replaced by the fresh answer;
|
|
// see `answer-cache.ts`); 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.
|
|
// `refreshing` is true while a request is in flight, and for a short minimum
|
|
// after it starts (`createBusyHold`), for the refresh picker's spinner.
|
|
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 { readAnswer, writeAnswer } from "./answer-cache.js";
|
|
import { createBusyHold } from "./busy-hold.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>,
|
|
opts: {
|
|
/** Saves each answer under this key and starts from the saved one. */
|
|
cacheKey?: string;
|
|
} = {},
|
|
): {
|
|
data: T | null;
|
|
error: ProblemDetails | null;
|
|
refreshing: boolean;
|
|
refresh: () => void;
|
|
setData: (update: (prev: T | null) => T | null) => void;
|
|
} {
|
|
const { cacheKey } = opts;
|
|
const cacheKeyRef = useRef(cacheKey);
|
|
cacheKeyRef.current = cacheKey;
|
|
const [data, setData] = useState<T | null>(() =>
|
|
cacheKey === undefined ? null : readAnswer<T>(cacheKey),
|
|
);
|
|
const [error, setError] = useState<ProblemDetails | null>(null);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
|
|
const fetcherRef = useRef(fetcher);
|
|
fetcherRef.current = fetcher;
|
|
const pollerRef = useRef<Poller | null>(null);
|
|
const holdRef = useRef<ReturnType<typeof createBusyHold> | null>(null);
|
|
if (holdRef.current === null) {
|
|
holdRef.current = createBusyHold({ show: setRefreshing });
|
|
}
|
|
const hold = holdRef.current;
|
|
if (pollerRef.current === null) {
|
|
pollerRef.current = createPoller<T>({
|
|
fetch: (signal) => fetcherRef.current(signal),
|
|
onData: (next) => {
|
|
if (cacheKeyRef.current !== undefined) {
|
|
writeAnswer(cacheKeyRef.current, next);
|
|
}
|
|
setData(next);
|
|
setError(null);
|
|
},
|
|
onError: (e) => setError(toProblem(e)),
|
|
onBusy: (busy) => hold.busy(busy),
|
|
});
|
|
}
|
|
const poller = pollerRef.current;
|
|
|
|
useEffect(
|
|
() => () => {
|
|
poller.stop();
|
|
hold.stop();
|
|
},
|
|
[poller, hold],
|
|
);
|
|
useRefreshInterval(intervalMs, () => poller.tick());
|
|
|
|
return { data, error, refreshing, refresh: () => poller.refetch(), setData };
|
|
}
|