- Simplify useFetchData: remove unused URL building logic - Add usePolledData initial implementation - Add router page_size param validation test - Update API reference docs - Clean up tasks doc
244 lines
9.5 KiB
TypeScript
244 lines
9.5 KiB
TypeScript
/**
|
|
* Composable base hook for fetch lifecycle, cancellation, and error handling.
|
|
*
|
|
* Provides common primitives for data-fetching hooks: abort controller management,
|
|
* loading/error state, and a refresh callback with cancellation safety.
|
|
*
|
|
* This is an internal hook meant to be composed by higher-level hooks like
|
|
* useListData and usePolledData. Direct usage is discouraged — instead,
|
|
* create a domain-specific hook that wraps this base and adds your specific
|
|
* requirements (e.g., polling, windowed effects, derived state).
|
|
*
|
|
* When a `requestKey` is provided, multiple hook instances with the same key
|
|
* will deduplicate in-flight requests, ensuring only one fetch happens at a time
|
|
* for that data. This prevents wasted bandwidth and race conditions.
|
|
*/
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { handleFetchError } from "../utils/fetchError";
|
|
import type { FetchError } from "../types/api";
|
|
|
|
/**
|
|
* Module-level cache for in-flight requests.
|
|
* Maps requestKey to { promise, controller, subscribers } to enable deduplication
|
|
* across multiple hook instances.
|
|
*/
|
|
interface Subscriber<TResponse> {
|
|
/** This instance's abort signal. */
|
|
signal: AbortSignal;
|
|
/** Callbacks registered by this subscriber. */
|
|
onSetData?: (response: TResponse) => void;
|
|
onSuccess?: (response: TResponse) => void;
|
|
}
|
|
|
|
interface InFlightRequest<TResponse> {
|
|
promise: Promise<TResponse>;
|
|
controller: AbortController;
|
|
/** Map of subscriberId -> Subscriber. Cleared when subscriber aborts. */
|
|
subscribers: Map<string, Subscriber<TResponse>>;
|
|
/** True when initiator has cleaned up but subscribers remain. */
|
|
initiatorDone: boolean;
|
|
}
|
|
|
|
const inFlightRequests = new Map<string, InFlightRequest<unknown>>();
|
|
|
|
/** Visible for testing only. Clears all in-flight request state. */
|
|
export const _resetInFlightRequests = (): void => {
|
|
inFlightRequests.clear();
|
|
};
|
|
|
|
export interface UseFetchDataOptions<TResponse, TData> {
|
|
/** Async function that accepts an AbortSignal for cancellation. */
|
|
fetcher: (signal: AbortSignal) => Promise<TResponse>;
|
|
/** Synchronous selector to extract domain data from the response. */
|
|
selector: (response: TResponse) => TData;
|
|
/** Human-readable error message used as fallback if fetch fails. */
|
|
errorMessage: string;
|
|
/** Optional callback invoked after successful fetch with full response. */
|
|
onSuccess?: (response: TResponse) => void;
|
|
/** Initial data value. If undefined, data starts as undefined until first fetch. */
|
|
initialData?: TData;
|
|
/**
|
|
* Optional unique key for request deduplication.
|
|
* When provided, multiple hook instances with the same key will share
|
|
* in-flight requests, preventing duplicate API calls.
|
|
*/
|
|
requestKey?: string;
|
|
}
|
|
|
|
export interface UseFetchDataResult<TData> {
|
|
/** The extracted data from the most recent successful fetch. */
|
|
data: TData | undefined;
|
|
/** True while a fetch is in-flight, false when complete. */
|
|
loading: boolean;
|
|
/** Typed error or null. Check `error?.type` to handle specific failure modes. */
|
|
error: FetchError | null;
|
|
/** Trigger a fresh fetch. Cancels any in-flight request first. Returns null if aborted, otherwise the selected data. */
|
|
refresh: () => Promise<TData | null>;
|
|
}
|
|
|
|
/**
|
|
* Generic base hook that manages the fetch lifecycle for a single resource.
|
|
*
|
|
* Handles abort controller management, error handling, and refresh semantics.
|
|
* Automatically cancels in-flight requests on component unmount.
|
|
*
|
|
* When `requestKey` is provided, deduplicates in-flight requests across
|
|
* multiple hook instances. If a request is already in-flight for that key,
|
|
* the hook reuses the existing promise instead of making a duplicate call.
|
|
* This prevents wasted bandwidth and race conditions when multiple components
|
|
* fetch the same data simultaneously or when the same component calls refresh rapidly.
|
|
*
|
|
* Prefer composing this hook via higher-level hooks (useListData, usePolledData)
|
|
* rather than using directly.
|
|
*
|
|
* @param options - Configuration: fetcher, selector, error message, and optional callbacks
|
|
* @returns Data, loading state, typed error, and refresh callback
|
|
*/
|
|
export function useFetchData<TResponse, TData>(
|
|
options: UseFetchDataOptions<TResponse, TData>,
|
|
): UseFetchDataResult<TData> {
|
|
const { fetcher, selector, errorMessage, onSuccess, initialData, requestKey } = options;
|
|
const [data, setData] = useState<TData | undefined>(initialData);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<FetchError | null>(null);
|
|
const abortRef = useRef<AbortController | null>(null);
|
|
const localControllerRef = useRef<AbortController | null>(null);
|
|
const subscriberAbortRef = useRef<AbortController | null>(null);
|
|
/** Unique ID for this instance, used to track its subscription to deduplicated requests. */
|
|
const subscriberIdRef = useRef<string | null>(null);
|
|
|
|
const refresh = useCallback(async (): Promise<TData | null> => {
|
|
// Abort any previous request from this hook instance
|
|
abortRef.current?.abort();
|
|
localControllerRef.current = new AbortController();
|
|
abortRef.current = localControllerRef.current;
|
|
|
|
// If using request deduplication via requestKey and a request is already in-flight,
|
|
// subscribe to it instead of launching a duplicate
|
|
if (requestKey && inFlightRequests.has(requestKey)) {
|
|
const inFlight = inFlightRequests.get(requestKey)! as InFlightRequest<TResponse>;
|
|
|
|
// Create per-instance abort controller for this subscription.
|
|
// Do NOT abort previous subscription signals here - each subscriber's signal
|
|
// only aborts when THAT subscriber unmounts. Aborting old signals would
|
|
// incorrectly remove other active subscribers from the list.
|
|
subscriberAbortRef.current = new AbortController();
|
|
|
|
// Register this instance as a subscriber.
|
|
// Always generate a new ID - each hook instance must have its own subscriber ID
|
|
// so that unmounting one subscriber does not remove another subscriber's entry.
|
|
subscriberIdRef.current = crypto.randomUUID();
|
|
const sid = subscriberIdRef.current;
|
|
const subscription: Subscriber<TResponse> = {
|
|
signal: subscriberAbortRef.current.signal,
|
|
onSetData: (response: TResponse) => {
|
|
setData(selector(response));
|
|
if (onSuccess) {
|
|
onSuccess(response);
|
|
}
|
|
},
|
|
onSuccess,
|
|
};
|
|
inFlight.subscribers.set(sid, subscription);
|
|
|
|
// When this instance aborts, remove it from the subscriber list.
|
|
// Note: we do NOT abort the underlying controller here - that belongs to
|
|
// the initiator and should only be aborted by the initiator itself.
|
|
const cleanup = (): void => {
|
|
inFlight.subscribers.delete(sid);
|
|
};
|
|
subscription.signal.addEventListener("abort", cleanup, { once: true });
|
|
|
|
inFlight.promise
|
|
.then((response: TResponse) => {
|
|
// Check whether this subscriber has already aborted before propagating result
|
|
if (subscription.signal.aborted) return;
|
|
subscription.onSetData?.(response);
|
|
})
|
|
.catch((err: unknown) => {
|
|
// Only handle non-abort errors; abort errors are silently ignored
|
|
if (err instanceof DOMException && err.name === "AbortError") {
|
|
return;
|
|
}
|
|
handleFetchError(err, setError, errorMessage);
|
|
})
|
|
.finally(() => {
|
|
setLoading(false);
|
|
});
|
|
return null;
|
|
}
|
|
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
const controller = localControllerRef.current;
|
|
|
|
// Raw promise for deduplication storage - stores BEFORE transformation
|
|
const rawPromise = fetcher(controller.signal).catch((err: unknown) => {
|
|
if (controller.signal.aborted) throw err;
|
|
handleFetchError(err, setError, errorMessage);
|
|
throw err;
|
|
});
|
|
|
|
// Store in-flight BEFORE transformation for correct subscriber type
|
|
if (requestKey) {
|
|
inFlightRequests.set(requestKey, {
|
|
promise: rawPromise as Promise<TResponse>,
|
|
controller,
|
|
subscribers: new Map(),
|
|
initiatorDone: false,
|
|
});
|
|
}
|
|
|
|
// Transformed promise for return value - applies selector and returns TData | null
|
|
const responsePromise = rawPromise
|
|
.then((response) => {
|
|
if (controller.signal.aborted) return null;
|
|
const data = selector(response);
|
|
setData(data);
|
|
if (onSuccess) {
|
|
onSuccess(response);
|
|
}
|
|
return data;
|
|
})
|
|
.finally(() => {
|
|
if (!controller.signal.aborted) {
|
|
setLoading(false);
|
|
}
|
|
if (requestKey) {
|
|
inFlightRequests.delete(requestKey);
|
|
}
|
|
});
|
|
|
|
return responsePromise;
|
|
}, [fetcher, selector, errorMessage, onSuccess, requestKey]);
|
|
|
|
useEffect(() => {
|
|
refresh();
|
|
|
|
return (): void => {
|
|
if (subscriberIdRef.current !== null) {
|
|
// Subscriber cleanup
|
|
const inFlight = requestKey ? inFlightRequests.get(requestKey) : undefined;
|
|
subscriberAbortRef.current?.abort();
|
|
// If initiator already done and no subscribers left, cancel the request
|
|
if (inFlight && inFlight.initiatorDone && inFlight.subscribers.size === 0) {
|
|
inFlight.controller.abort();
|
|
}
|
|
} else {
|
|
// Initiator cleanup
|
|
const inFlight = requestKey ? inFlightRequests.get(requestKey) : undefined;
|
|
// Mark initiator as done so last subscriber knows to cancel
|
|
if (inFlight) {
|
|
inFlight.initiatorDone = true;
|
|
}
|
|
if (!inFlight || inFlight.subscribers.size === 0) {
|
|
abortRef.current?.abort();
|
|
}
|
|
}
|
|
};
|
|
}, [refresh]);
|
|
|
|
return { data, loading, error, refresh };
|
|
}
|