/* eslint-disable @typescript-eslint/naming-convention */ import { useDidUpdate } from "@better-hooks/lifecycle"; import type { ExtractErrorType, CacheValueType, ExtractResponseType, RequestInstance, ExtractAdapterType, ExtractAdapterExtraType, ResponseType, } from "@hyper-fetch/core"; import { scopeKey } from "@hyper-fetch/core"; import { useCallback, useRef, useSyncExternalStore } from "react"; import { isEqual } from "utils"; import type { UseTrackedStateActions, UseTrackedStateType, UseTrackedStateProps, UseTrackedStateReturn, } from "./use-tracked-state.types"; import { getInitialState, getIsInitiallyLoading, getShouldClearState, getValidCacheData, isStaleCacheData, } from "./use-tracked-state.utils"; /** * * @param request * @param initialResponse * @param dispatcher * @param dependencies * @internal */ export const useTrackedState = ({ request, dispatcher, initialResponse, deepCompare, dependencyTracking, keepPreviousData = "auto", disabled, revalidate, }: UseTrackedStateProps): UseTrackedStateReturn => { const { client, cacheKey, queryKey, staleTime, unstable_responseMapper } = request; const { cache } = client; const state = useRef>(getInitialState({ initialResponse, dispatcher, request, disabled })); const renderKeys = useRef>>([]); const isProcessingData = useRef(""); const previousCacheKey = useRef(cacheKey); // ****************** // useSyncExternalStore // ****************** const versionRef = useRef(0); const listenerRef = useRef<(() => void) | null>(null); const subscribe = useCallback((listener: () => void) => { listenerRef.current = listener; return () => { listenerRef.current = null; }; }, []); const getSnapshot = useCallback(() => versionRef.current, []); useSyncExternalStore(subscribe, getSnapshot, getSnapshot); const emitChange = () => { versionRef.current += 1; listenerRef.current?.(); }; // ****************** // Utils // ****************** const getStaleStatus = (): boolean => { const cacheData = cache.get(scopeKey(cacheKey, request.scope)); return !cacheData || isStaleCacheData(staleTime, cacheData?.responseTimestamp); }; // ****************** // Dependency Tracking // ****************** const renderKeyTrigger = (keys: Array) => { const shouldRerender = renderKeys.current.some((renderKey) => keys.includes(renderKey)); if (shouldRerender) emitChange(); }; const setRenderKey = (renderKey: keyof UseTrackedStateType) => { if (!renderKeys.current.includes(renderKey)) { renderKeys.current.push(renderKey); } }; // ****************** // Cache initialization // ****************** useDidUpdate( () => { const oldKey = previousCacheKey.current; previousCacheKey.current = cacheKey; // Get cache state const cacheData = cache.get, ExtractErrorType>(cacheKey); const cacheState = getValidCacheData(request, initialResponse, cacheData); // Determine whether to clear state based on keepPreviousData mode const shouldClear = getShouldClearState(keepPreviousData, oldKey, cacheKey); if (shouldClear && !cacheState) { // Reset state to initial values when no cached data exists for the new key const resetState = getInitialState({ initialResponse, dispatcher, request, disabled, revalidate }); state.current = resetState; renderKeyTrigger(Object.keys(state.current) as (keyof UseTrackedStateType)[]); } else if (cacheState) { state.current.loading = getIsInitiallyLoading({ queryKey: scopeKey(request.queryKey, request.scope), dispatcher, disabled, revalidate, hasState: true, }); // eslint-disable-next-line @typescript-eslint/no-use-before-define setCacheData(cacheState); } // When state is preserved and no cache exists (e.g. query param change in "auto" mode), // don't update loading on the ref — let the dispatcher's loading event handle it (#126) }, [cacheKey, queryKey], true, ); // ****************** // Turn off dependency tracking // ****************** useDidUpdate( () => { const handleDependencyTracking = () => { if (!dependencyTracking) { Object.keys(state.current).forEach((key) => setRenderKey(key as Parameters[0])); } }; handleDependencyTracking(); }, [dependencyTracking], true, ); // ****************** // Cache data handler // ****************** const handleCompare = (firstValue: unknown, secondValue: unknown) => { if (typeof deepCompare === "function") { return deepCompare(firstValue, secondValue); } if (deepCompare) { return isEqual(firstValue, secondValue); } return false; }; const handleCacheData = ( cacheData: CacheValueType, ExtractErrorType, ExtractAdapterType>, ) => { const newStateValues: UseTrackedStateType = { data: cacheData.data, error: cacheData.error, status: cacheData.status, success: cacheData.success, extra: cacheData.extra as ExtractAdapterExtraType>, retries: cacheData.retries, responseTimestamp: new Date(cacheData.responseTimestamp), requestTimestamp: new Date(cacheData.requestTimestamp), loading: dispatcher.hasRunningRequests(queryKey), }; const changedKeys = Object.keys(newStateValues).filter((key) => { const keyValue = key as keyof UseTrackedStateType; const firstValue = state.current[keyValue]; const secondValue = newStateValues[keyValue]; return !handleCompare(firstValue, secondValue); }) as unknown as (keyof UseTrackedStateType)[]; state.current = { ...state.current, ...newStateValues, }; renderKeyTrigger(changedKeys); }; const setIsDataProcessing = ({ processingCacheKey, isProcessing, }: { processingCacheKey: string; isProcessing: boolean; }) => { if (isProcessing) { isProcessingData.current = processingCacheKey; } // Do not turn off other keys processing else if (isProcessingData.current === cacheKey) { isProcessingData.current = ""; } }; const getIsDataProcessing = (processingCacheKey: string) => { return isProcessingData.current === processingCacheKey; }; const setCacheData = ( cacheData: CacheValueType, ExtractErrorType, ExtractAdapterType>, ): Promise | void => { setIsDataProcessing({ processingCacheKey: cacheKey, isProcessing: true }); const data = unstable_responseMapper ? unstable_responseMapper(cacheData as ResponseType>) : cacheData; if (data instanceof Promise) { return (async () => { const promiseData = await data; handleCacheData({ ...cacheData, ...promiseData } as CacheValueType< ExtractResponseType, ExtractErrorType, ExtractAdapterType >); setIsDataProcessing({ processingCacheKey: cacheKey, isProcessing: false }); })(); } setIsDataProcessing({ processingCacheKey: cacheKey, isProcessing: false }); return handleCacheData({ ...cacheData, ...data } as CacheValueType< ExtractResponseType, ExtractErrorType, ExtractAdapterType >); }; // ****************** // Actions // ****************** const actions: UseTrackedStateActions = { setData: (data) => { state.current.data = data instanceof Function ? data(state.current.data || null) : data; renderKeyTrigger(["data"]); }, setError: (error) => { state.current.error = error instanceof Function ? error(state.current.error || null) : error; renderKeyTrigger(["error"]); }, setLoading: (loading) => { const value = loading instanceof Function ? loading(state.current.loading) : loading; if (value === state.current.loading) return; state.current.loading = value; renderKeyTrigger(["loading"]); }, setStatus: (status) => { const value = status instanceof Function ? status(state.current.status) : status; if (value === state.current.status) return; state.current.status = status instanceof Function ? status(state.current.status || null) : status; renderKeyTrigger(["status"]); }, setSuccess: (success) => { const value = success instanceof Function ? success(state.current.success || false) : success; if (value === state.current.success) return; state.current.success = success instanceof Function ? success(state.current.success || false) : success; renderKeyTrigger(["success"]); }, setExtra: (extra) => { const value = extra instanceof Function ? extra(state.current.extra) : extra; if (value === state.current.extra) return; state.current.extra = extra instanceof Function ? extra(state.current.extra) : extra; renderKeyTrigger(["extra"]); }, setRetries: (retries) => { const value = retries instanceof Function ? retries(state.current.retries || 0) : retries; if (value === state.current.retries) return; state.current.retries = retries instanceof Function ? retries(state.current.retries || 0) : retries; renderKeyTrigger(["retries"]); }, setResponseTimestamp: (timestamp) => { const value = timestamp instanceof Function ? timestamp(state.current.responseTimestamp) : timestamp; if (value === state.current.responseTimestamp) return; const getTimestamp = (prev: Date | null) => { return timestamp instanceof Function ? timestamp(prev ? new Date(prev) : null) : timestamp; }; state.current.responseTimestamp = getTimestamp(state.current.responseTimestamp); renderKeyTrigger(["responseTimestamp"]); }, setRequestTimestamp: (timestamp) => { const value = timestamp instanceof Function ? timestamp(state.current.requestTimestamp) : timestamp; if (value === state.current.requestTimestamp) return; const getTimestamp = (prev: Date | null) => { return timestamp instanceof Function ? timestamp(prev ? new Date(prev) : null) : timestamp; }; state.current.requestTimestamp = getTimestamp(state.current.requestTimestamp); renderKeyTrigger(["requestTimestamp"]); }, clearState: () => { state.current = { data: null, error: null, loading: false, status: null, success: false, extra: null, retries: 0, responseTimestamp: null, requestTimestamp: null, } as UseTrackedStateType; renderKeyTrigger(Object.keys(state.current) as (keyof UseTrackedStateType)[]); }, }; return [state.current, actions, { setRenderKey, setCacheData, getStaleStatus, getIsDataProcessing }]; };