// 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( 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, 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(() => cacheKey === undefined ? null : readAnswer(cacheKey), ); const [error, setError] = useState(null); const [refreshing, setRefreshing] = useState(false); const fetcherRef = useRef(fetcher); fetcherRef.current = fetcher; const pollerRef = useRef(null); const holdRef = useRef | null>(null); if (holdRef.current === null) { holdRef.current = createBusyHold({ show: setRefreshing }); } const hold = holdRef.current; if (pollerRef.current === null) { pollerRef.current = createPoller({ 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 }; }