// imports import { useEffect, useMemo } from 'react'; import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'; // locals import ApiSignal from '../signals/Api'; import { useSignal } from '../signal'; /** * Global cache configuration for all queries * - staleTime: Duration before data is considered stale (5 minutes) * - gcTime: Duration before unused data is garbage collected (30 minutes) */ const CACHE_CONFIG = { staleTime: 5 * 60 * 1000, gcTime: 30 * 60 * 1000, }; /** * Generates a stable cache key based on params and items state * @param {Object} params - Query parameters * @param {Array} items - Array of items to track * @returns {string} A stable cache key */ const generateStableKey = (params, items) => { const itemsState = items?.map(item => ({ id: item.id, updatedAt: item.updatedAt || item._updatedAt || '', })); return `${JSON.stringify(params)}-${JSON.stringify(itemsState)}`; }; /** * Hook for fetching paginated results with infinite loading and real-time updates * @param {Function} apiMethod - API method to call * @param {Object} params - Query parameters * @param {Object} options - Configuration options * @returns {Object} Query result object with pagination and real-time update handlers */ export const useResults = (apiMethod, params = {}, options = {}) => { const [sApi] = useSignal(ApiSignal); const queryClient = useQueryClient(); const { limit = 25, resultKey = 'items', event = null, filter = null, sort = null, disable = false, } = options; const { data, error, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, status, refetch, } = useInfiniteQuery({ queryKey: [resultKey || apiMethod?.name, params], queryFn: async ({ pageParam = 0 }) => { if (!sApi || !apiMethod) { throw new Error('API not initialized'); } return apiMethod({ ...params, limit, page: pageParam, }); }, getNextPageParam: (lastPage, allPages) => { const totalFetched = allPages.reduce((total, page) => total + (page[resultKey]?.length || 0), 0 ); return totalFetched < (lastPage.total || 0) ? allPages.length : undefined; }, initialPageParam: 0, ...CACHE_CONFIG, enabled: !!sApi && !!apiMethod && !disable, }); const handleItem = (item) => { if (filter && !filter(item)) return; queryClient.setQueriesData( { queryKey: [resultKey || apiMethod?.name, params] }, (oldData) => { if (!oldData) return oldData; // First, collect all items across all pages let allItems = oldData.pages.reduce((acc, page) => [ ...acc, ...(page[resultKey] || []) ], []); const itemExists = allItems.some(existing => existing.id === item.id); if (itemExists) { // Update the item in the full list allItems = allItems.map(existing => existing.id === item.id ? { ...existing, ...item } : existing ); } else { // Add new item to the full list allItems = [item, ...allItems]; } // Apply sorting to the full list if provided if (sort) { allItems = allItems.sort(sort); } // Redistribute items across pages const newPages = []; for (let i = 0; i < allItems.length; i += limit) { const pageItems = allItems.slice(i, i + limit); newPages.push({ ...oldData.pages[0], // Copy other properties from first page [resultKey]: pageItems, total: allItems.length, }); } return { ...oldData, pages: newPages, }; } ); }; useEffect(() => { if (!event || !sApi?.socket) return; sApi.socket.on(event, handleItem); return () => sApi.socket.off(event, handleItem); }, [event, sApi?.socket, queryClient, apiMethod?.name, params, resultKey, filter, sort, limit]); // Deduplicate and sort all items across pages const items = useMemo(() => { // First collect and deduplicate all items const dedupedItems = data?.pages.reduce((acc, page) => { const pageItems = page[resultKey] || []; return [...acc, ...pageItems].reduce((unique, item) => { if (!unique.find((i) => i.id === item.id)) { unique.push(item); } return unique; }, []); }, []) || []; // Then apply sorting if provided return sort ? [...dedupedItems].sort(sort) : dedupedItems; }, [data?.pages, resultKey, sort]); const stableKey = useMemo(() => generateStableKey(params, items), [params, items] ); const total = data?.pages[data.pages.length - 1]?.total || 0; return { items, total, data: data?.pages, error, isLoading: status === 'loading', isFetching, hasMore: hasNextPage, loadMore: () => !isFetchingNextPage && fetchNextPage(), refetch, key: stableKey, handleItem, }; }; /** * Hook for fetching a single result with real-time updates * @param {Function} apiMethod - API method to call * @param {string|number} id - Item ID * @param {Object} options - Configuration options * @returns {Object} Query result object with real-time update handler */ export const useResult = (apiMethod, id, options = {}) => { const [sApi] = useSignal(ApiSignal); const queryClient = useQueryClient(); const { event = null } = options; const { data, error, isLoading, isFetching, refetch, } = useQuery({ queryKey: [apiMethod?.name, id], queryFn: async () => { if (!sApi || !apiMethod) { throw new Error('API not initialized'); } return apiMethod(id); }, ...CACHE_CONFIG, enabled: !!sApi && !!apiMethod && !!id, }); /** * Handles real-time updates for single item * @param {Object} item - Updated item */ const handleItem = (item) => { if (item.id === id) { queryClient.setQueryData([apiMethod?.name, id], item); } }; // Set up real-time updates useEffect(() => { if (!event || !sApi?.socket || !id) return; sApi.socket.on(event, handleItem); return () => sApi.socket.off(event, handleItem); }, [event, id, sApi?.socket, queryClient, apiMethod?.name]); const stableKey = useMemo(() => generateStableKey({}, [data]), [data] ); return { data, error, item: data, isLoading, isFetching, refetch, key: stableKey, handleItem, }; }; /** * Factory function to create entity-specific hooks * @param {string} entityType - Type of entity (e.g., 'user', 'post') * @param {string} entityKey - Key used in API responses * @returns {Array} Array containing list and item hooks for the entity */ export const createEntityHooks = (entityType, entityKey) => { /** * Generic query wrapper for entity operations */ const useEntityQuery = (queryFn, params, options = {}) => { const [sApi] = useSignal(ApiSignal); return queryFn(sApi?.[entityType]?.[options.method], params, { event: `${entityKey}.update`, ...options, }); }; /** * Hook for fetching entity lists */ const useList = (opts = {}, params = {}) => { const { items, data, handleItem, ...rest } = useEntityQuery(useResults, { ...opts, category: typeof opts.category === 'object' ? opts.category?.slug : opts.category, }, { ...params, method: 'list', resultKey: `${entityKey}s`, limit: opts.limit || 25, }); return { [`${entityKey}s`]: items, categories: data?.result?.categories || [], handleItem, ...rest, }; }; /** * Hook for fetching single entity */ const useItem = (id) => { const { item, handleItem, ...rest } = useEntityQuery(useResult, id, { method: 'get', }); return { [entityKey]: item, handleItem, ...rest }; }; return [useList, useItem]; };