151 lines
5.6 KiB
TypeScript
151 lines
5.6 KiB
TypeScript
'use client';
|
|
|
|
import {useCallback, useEffect, useState} from 'react';
|
|
import {fetchEndpoint, endpointUrl} from '@/shared/services/httpClient';
|
|
import type {Endpoint} from '@/shared/services/httpClient';
|
|
import {isFailure} from '@/shared/types/api';
|
|
import type {ApiFailure, ApiMeta} from '@/shared/types/api';
|
|
|
|
/**
|
|
* Deliberately shaped like SWR so it can be replaced by SWR or TanStack Query
|
|
* in exactly one file when a real backend arrives.
|
|
*
|
|
* 'empty' is a first-class status rather than something each caller derives
|
|
* from `data.length === 0`. Getting that wrong is how "no results" ends up
|
|
* rendering as an axis with no bars.
|
|
*/
|
|
export type AsyncState<T> =
|
|
| {status: 'loading'; data: undefined; error: undefined}
|
|
| {status: 'success'; data: T; error: undefined}
|
|
| {status: 'empty'; data: T; error: undefined}
|
|
| {status: 'error'; data: undefined; error: ApiFailure['error']};
|
|
|
|
export type Resource<T> = AsyncState<T> & {
|
|
refetch: () => void;
|
|
/**
|
|
* True while a new request is in flight but previous data is still on
|
|
* screen. Panels use this to dim rather than to blank.
|
|
*/
|
|
isRefreshing: boolean;
|
|
/**
|
|
* Response metadata, including the SERVER's clock. Anything computing a
|
|
* relative deadline — days-to-expiry, "2 hours ago" — must measure against
|
|
* this rather than Date.now(): calling Date.now() during render is impure,
|
|
* disagrees between the SSR and hydration passes, and quietly reports the
|
|
* wrong countdown to any user whose device clock is off.
|
|
*/
|
|
meta?: ApiMeta;
|
|
/**
|
|
* TEMPORARY — set by useSampleFallback() when the real endpoint had nothing
|
|
* and an example is on screen instead. PanelCard shows the "Sample data"
|
|
* token from it. Goes with shared/mocks.
|
|
*/
|
|
isSample?: boolean;
|
|
};
|
|
|
|
/** What the fetch resolved to, tagged with the request it belongs to. */
|
|
type Settled<T> =
|
|
| {key: string; ok: true; data: T; meta?: ApiMeta}
|
|
| {key: string; ok: false; error: ApiFailure['error']};
|
|
|
|
export function useResource<T>(
|
|
endpoint: Endpoint<T> | null,
|
|
opts?: {isEmpty?: (d: T) => boolean; initialData?: T},
|
|
): Resource<T> {
|
|
const {isEmpty, initialData} = opts ?? {};
|
|
|
|
// The URL IS the request identity. Keying on it means a store or range change
|
|
// refetches while an unrelated re-render does not.
|
|
const key = endpoint ? endpointUrl(endpoint) : null;
|
|
|
|
// Seeded from initialData with no meta — a caller-supplied payload has no
|
|
// server timestamp to report.
|
|
const [settled, setSettled] = useState<Settled<T> | null>(() =>
|
|
initialData !== undefined && key
|
|
? {key, ok: true, data: initialData}
|
|
: null,
|
|
);
|
|
const [nonce, setNonce] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (!endpoint || !key) return;
|
|
|
|
const controller = new AbortController();
|
|
let cancelled = false;
|
|
|
|
// No setState for the loading state: it is DERIVED below from whether the
|
|
// settled result matches the current key. Setting it here would be a
|
|
// synchronous setState in an effect body — a cascading render, and the
|
|
// thing react-hooks/set-state-in-effect exists to catch.
|
|
fetchEndpoint(endpoint, controller.signal)
|
|
.then((res) => {
|
|
if (cancelled) return;
|
|
setSettled(
|
|
isFailure(res)
|
|
? {key, ok: false, error: res.error}
|
|
: {key, ok: true, data: res.data, meta: res.meta},
|
|
);
|
|
})
|
|
.catch((err: unknown) => {
|
|
// An abort is a superseded request, not a failure to show the user.
|
|
if (cancelled || (err as Error)?.name === 'AbortError') return;
|
|
setSettled({
|
|
key,
|
|
ok: false,
|
|
error: {
|
|
code: 'internal',
|
|
message: (err as Error)?.message ?? 'Request failed',
|
|
},
|
|
});
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
controller.abort();
|
|
};
|
|
// `endpoint` is intentionally not a dep: `key` is its serialized identity,
|
|
// and callers construct the object inline on every render.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [key, nonce]);
|
|
|
|
const refetch = useCallback(() => setNonce((n) => n + 1), []);
|
|
|
|
// Stale-while-revalidate.
|
|
//
|
|
// When the scope changes, the honest-but-wrong thing is to drop straight to
|
|
// 'loading': the whole dashboard blanks to skeletons and every animated
|
|
// counter restarts from zero. Merchants switch store and period constantly,
|
|
// so that reads as the page breaking rather than as data arriving.
|
|
//
|
|
// Instead the previous result stays on screen until the new one lands, with
|
|
// `isRefreshing` set. 'loading' is now reserved for the genuine first paint,
|
|
// when there is nothing to show. A stale ERROR is not kept — showing a dead
|
|
// error under a fresh request would be actively misleading.
|
|
const isStale = !!settled && settled.key !== key;
|
|
const usable = settled && (settled.ok || !isStale) ? settled : null;
|
|
|
|
const state: AsyncState<T> =
|
|
!key || !usable
|
|
? {status: 'loading', data: undefined, error: undefined}
|
|
: usable.ok
|
|
? {
|
|
// Emptiness is derived here rather than inside the effect, so
|
|
// `isEmpty` never has to be a dependency — no ref, no stale
|
|
// closure, no ref mutation during render.
|
|
status: (
|
|
isEmpty
|
|
? isEmpty(usable.data)
|
|
: Array.isArray(usable.data) && usable.data.length === 0
|
|
)
|
|
? 'empty'
|
|
: 'success',
|
|
data: usable.data,
|
|
error: undefined,
|
|
}
|
|
: {status: 'error', data: undefined, error: usable.error};
|
|
|
|
const meta = usable && usable.ok ? usable.meta : undefined;
|
|
|
|
return {...state, refetch, isRefreshing: isStale, meta} as Resource<T>;
|
|
}
|