'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 = | {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 = AsyncState & { 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 = | {key: string; ok: true; data: T; meta?: ApiMeta} | {key: string; ok: false; error: ApiFailure['error']}; export function useResource( endpoint: Endpoint | null, opts?: {isEmpty?: (d: T) => boolean; initialData?: T}, ): Resource { 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 | 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 = !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; }