import { useDidUpdate, useDidMount, useWillUnmount } from "@better-hooks/lifecycle"; import { useDebounce, useThrottle } from "@better-hooks/performance"; import type { RequestInstance, ExtractAdapterStatusType, ExtractAdapterType, ExtractAdapterExtraType, } from "@hyper-fetch/core"; import { scopeKey } from "@hyper-fetch/core"; import { useRef } from "react"; import { useRequestEvents, useTrackedState } from "helpers"; import type { UseFetchOptionsType, UseFetchReturnType } from "hooks/use-fetch"; import { useFetchDefaultOptions } from "hooks/use-fetch"; import { useProvider } from "provider"; import { createTrackedProxy, getBounceData } from "utils"; type SuspenseEntry = { promise: Promise; resolve: () => void; cleanup: () => void }; const suspensePromiseMap = new Map(); const suspenseResultMap = new Map(); /** * This hooks aims to retrieve data from server. * @param request Request instance * @param options Hook options * @returns */ export const useFetch = ( request: R, options?: UseFetchOptionsType, ): UseFetchReturnType => { // Build the configuration options const { config: globalConfig } = useProvider(); const { suspense, dependencies, disabled, dependencyTracking, revalidate, initialResponse, keepPreviousData, refresh, refreshTime, refetchBlurred, refetchOnBlur, refetchOnFocus, refetchOnReconnect, bounce, bounceType, bounceTime, bounceTimeout, deepCompare, } = { ...useFetchDefaultOptions, ...globalConfig.useFetchConfig, ...options, }; const updateKey = JSON.stringify(request.toJSON()); const requestDebounce = useDebounce({ delay: bounceTime }); const requestThrottle = useThrottle({ interval: bounceTime, timeout: bounceTimeout }); const refreshDebounce = useDebounce({ delay: refreshTime }); const { cacheKey, queryKey, client } = request; const { cache, fetchDispatcher: dispatcher, appManager, loggerManager } = client; const ignoreReact18DoubleRender = useRef(true); const logger = useRef(loggerManager.initialize(client, "useFetch")).current; const bounceData = bounceType === "throttle" ? requestThrottle : requestDebounce; const bounceFunction = bounceType === "throttle" ? requestThrottle.throttle : requestDebounce.debounce; /** * State handler with optimization for re-rendering, that hooks into the cache state and dispatchers queues */ const [state, actions, { setRenderKey, setCacheData, getStaleStatus, getIsDataProcessing }] = useTrackedState({ logger, request: request as unknown as R, dispatcher, initialResponse, deepCompare, dependencyTracking, keepPreviousData, disabled, revalidate, }); /** * Handles the data exchange with the core logic - responses, loading, downloading etc */ const [callbacks, listeners] = useRequestEvents({ logger, actions, request: request as unknown as R, dispatcher, setCacheData, getIsDataProcessing, }); const { addCacheDataListener, addLifecycleListeners, clearCacheDataListener } = listeners; // ****************** // Fetching // ****************** const handleFetch = () => { if (!disabled) { logger.debug({ title: `Fetching data`, type: "system", extra: { request } }); dispatcher.add(request as unknown as R); } else { logger.debug({ title: `Cannot add to fetch queue`, type: "system", extra: { disabled } }); } }; // ****************** // Refreshing // ****************** function handleRefresh() { if (!refresh || disabled) { refreshDebounce.reset(); return; } refreshDebounce.debounce(() => { const isBlurred = !appManager.isFocused; // If window tab is not active should we refresh the cache const scopedQueryKey = scopeKey(request.queryKey, request.scope); const isFetching = dispatcher.hasRunningRequests(scopedQueryKey); const isQueued = dispatcher.getIsActiveQueue(scopedQueryKey); const isActive = isFetching || isQueued; const canRefreshBlurred = isBlurred && refetchBlurred && !isActive; const canRefreshFocused = !isBlurred && !isActive; if (canRefreshBlurred || canRefreshFocused) { handleFetch(); logger.debug({ title: `Performing refresh request`, type: "system", extra: { request } }); } // Start new refresh counter handleRefresh(); }); } const refetch = () => { handleFetch(); handleRefresh(); }; // ****************** // Fetching lifecycle // ****************** // This help us to check for currently running requests const getIsFetchingIdentity = () => { // We need to check if the queryKey have the same cacheKey elements ongoing return dispatcher.getRunningRequests(queryKey).some((running) => running.request.cacheKey === cacheKey); }; const initialFetchData = () => { const hasStaleData = getStaleStatus(); const isFetching = getIsFetchingIdentity(); if ((revalidate || hasStaleData) && !isFetching) { handleFetch(); } }; const updateFetchData = () => { const hasStaleData = getStaleStatus(); const shouldUpdate = !revalidate ? hasStaleData : true; /** * This is a hack to avoid double rendering in React 18 * It renders initial mount event and allow us to consume only hook updates */ if (!ignoreReact18DoubleRender.current && shouldUpdate) { /** * While debouncing we need to make sure that first request is not debounced when the cache is not available * This way it will not wait for debouncing but fetch data right away */ if (bounce) { logger.debug({ title: `Bounce request with ${bounceType}`, type: "system", extra: { queryKey, request } }); bounceFunction(() => handleFetch()); } else { handleFetch(); } } else { ignoreReact18DoubleRender.current = false; } }; // ****************** // Events // ****************** const handleMountEvents = () => { addCacheDataListener(request as unknown as R); addLifecycleListeners(request as unknown as R); const focusUnmount = appManager.events.onFocus(() => { if (refetchOnFocus && !disabled) { handleFetch(); handleRefresh(); } }); const blurUnmount = appManager.events.onBlur(() => { if (refetchOnBlur && !disabled) { handleFetch(); handleRefresh(); } }); const onlineUnmount = appManager.events.onOnline(() => { if (refetchOnReconnect && !disabled) { handleFetch(); handleRefresh(); } }); const invalidateUnmount = cache.events.onInvalidateByKey(cacheKey, handleFetch); const deletionUnmount = cache.events.onDeleteByKey(cacheKey, handleFetch); const unmount = () => { clearCacheDataListener(); focusUnmount(); blurUnmount(); onlineUnmount(); invalidateUnmount(); deletionUnmount(); }; return unmount; }; // ****************** // Lifecycle // ****************** /** * Initialization of the events related to data exchange with cache and queue * This allows to share the state with other hooks and keep it related */ useDidUpdate(handleMountEvents, [updateKey, disabled], true); /** * Initial fetch triggered once data is stale or we use the refetch strategy */ useDidMount(initialFetchData); /** * Fetching logic for updates handling */ useDidUpdate(updateFetchData, [updateKey, disabled, ...dependencies], true); /** * Refresh lifecycle handler */ useDidUpdate(handleRefresh, [updateKey, ...dependencies, disabled, refresh, refreshTime], true); /** * Reset the ignore flag for React 18 strict mode */ useWillUnmount(() => { ignoreReact18DoubleRender.current = true; }); // ****************** // Suspense // ****************** if (suspense && !disabled) { // Apply any cached suspense result (covers error responses that aren't stored in cache) const suspenseResult = suspenseResultMap.get(cacheKey); if (suspenseResult) { suspenseResultMap.delete(cacheKey); if (suspenseResult.data !== null) state.data = suspenseResult.data; if (suspenseResult.error !== null) state.error = suspenseResult.error; if (suspenseResult.status !== null) state.status = suspenseResult.status; if (suspenseResult.extra !== null) state.extra = suspenseResult.extra; state.success = suspenseResult.success; } const hasData = state.data !== null || state.error !== null; if (!hasData) { let entry = suspensePromiseMap.get(cacheKey); if (!entry) { let resolvePromise: () => void; const promise = new Promise((r) => { resolvePromise = r; }); const unsubscribe = cache.events.onDataByKey(cacheKey, (cacheData: any) => { suspenseResultMap.set(cacheKey, { data: cacheData.data ?? null, error: cacheData.error ?? null, status: cacheData.status ?? null, extra: cacheData.extra ?? null, success: cacheData.success ?? false, }); resolvePromise(); unsubscribe(); suspensePromiseMap.delete(cacheKey); }); entry = { promise, resolve: resolvePromise!, cleanup: unsubscribe }; suspensePromiseMap.set(cacheKey, entry); } // Ensure the request is dispatched before suspending — // effects never run when a component suspends. if (!dispatcher.hasRunningRequests(queryKey)) { dispatcher.add(request as unknown as R); } throw entry.promise; } // Data has arrived — clean up any leftover entry const entry = suspensePromiseMap.get(cacheKey); if (entry) { entry.cleanup(); suspensePromiseMap.delete(cacheKey); } } const trackedKeys = [ "data", "error", "loading", "status", "success", "extra", "retries", "responseTimestamp", "requestTimestamp", ] as const; return createTrackedProxy( { data: state.data, error: state.error, loading: state.loading, status: state.status as ExtractAdapterStatusType>, success: state.success, extra: state.extra as ExtractAdapterExtraType>, retries: state.retries, responseTimestamp: state.responseTimestamp, requestTimestamp: state.requestTimestamp, bounce: getBounceData(bounceData), ...actions, ...(callbacks as any), refetch, }, trackedKeys, setRenderKey, ) as UseFetchReturnType; };