From 6567d9d255388bdd171e31b695ebb587bc36a331 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Mon, 5 Jan 2026 00:12:01 +0800 Subject: [PATCH] feat: update useDataApi hook and documentation for improved loading states and refetch logic - Refactored `useQuery` and `useMutation` hooks to replace `loading` with `isLoading` for consistency in naming conventions. - Enhanced `useQuery` to include `isRefreshing` state for better tracking of background revalidation. - Updated documentation and examples to reflect changes in hook signatures and loading state management. - Improved mock implementations in tests to align with the new hook signatures, ensuring accurate testing of loading states. --- .../references/data/data-api-in-renderer.md | 132 ++-- src/renderer/src/data/hooks/useDataApi.ts | 602 +++++++++++------- tests/__mocks__/renderer/useDataApi.ts | 98 +-- 3 files changed, 518 insertions(+), 314 deletions(-) diff --git a/docs/en/references/data/data-api-in-renderer.md b/docs/en/references/data/data-api-in-renderer.md index abe79ca8c1..8d4b9f07f6 100644 --- a/docs/en/references/data/data-api-in-renderer.md +++ b/docs/en/references/data/data-api-in-renderer.md @@ -12,7 +12,7 @@ Fetch data with automatic caching and revalidation via SWR. import { useQuery } from '@data/hooks/useDataApi' // Basic usage -const { data, loading, error } = useQuery('/topics') +const { data, isLoading, error } = useQuery('/topics') // With query parameters const { data: messages } = useQuery('/messages', { @@ -23,12 +23,12 @@ const { data: messages } = useQuery('/messages', { const { data: topic } = useQuery('/topics/abc123') // Conditional fetching -const { data } = useQuery(topicId ? `/topics/${topicId}` : null) +const { data } = useQuery('/topics', { enabled: !!topicId }) // With refresh callback -const { data, mutate } = useQuery('/topics') +const { data, mutate, refetch } = useQuery('/topics') // Refresh data -await mutate() +refetch() // or await mutate() ``` ### useMutation (POST/PUT/PATCH/DELETE) @@ -39,22 +39,68 @@ Perform data modifications with loading states. import { useMutation } from '@data/hooks/useDataApi' // Create (POST) -const { trigger: createTopic, isMutating } = useMutation('/topics', 'POST') +const { trigger: createTopic, isLoading } = useMutation('POST', '/topics') const newTopic = await createTopic({ body: { name: 'New Topic' } }) // Update (PUT - full replacement) -const { trigger: replaceTopic } = useMutation('/topics/abc123', 'PUT') +const { trigger: replaceTopic } = useMutation('PUT', '/topics/abc123') await replaceTopic({ body: { name: 'Updated Name', description: '...' } }) // Partial Update (PATCH) -const { trigger: updateTopic } = useMutation('/topics/abc123', 'PATCH') +const { trigger: updateTopic } = useMutation('PATCH', '/topics/abc123') await updateTopic({ body: { name: 'New Name' } }) // Delete -const { trigger: deleteTopic } = useMutation('/topics/abc123', 'DELETE') +const { trigger: deleteTopic } = useMutation('DELETE', '/topics/abc123') await deleteTopic() + +// With auto-refresh of other queries +const { trigger } = useMutation('POST', '/topics', { + refresh: ['/topics'], // Refresh these keys on success + onSuccess: (data) => console.log('Created:', data) +}) ``` +### useInfiniteQuery (Cursor-based Infinite Scroll) + +For infinite scroll UIs with "Load More" pattern. + +```typescript +import { useInfiniteQuery } from '@data/hooks/useDataApi' + +const { items, isLoading, hasNext, loadNext } = useInfiniteQuery('/messages', { + query: { topicId: 'abc123' }, + limit: 20 +}) + +// items: all loaded items flattened +// loadNext(): load next page +// hasNext: true if more pages available +``` + +### usePaginatedQuery (Offset-based Pagination) + +For page-by-page navigation with previous/next controls. + +```typescript +import { usePaginatedQuery } from '@data/hooks/useDataApi' + +const { items, page, total, hasNext, hasPrev, nextPage, prevPage } = + usePaginatedQuery('/topics', { limit: 10 }) + +// items: current page items +// page/total: current page number and total count +// nextPage()/prevPage(): navigate between pages +``` + +### Choosing Pagination Hooks + +| Use Case | Hook | +|----------|------| +| Infinite scroll, chat, feeds | `useInfiniteQuery` | +| Page navigation, tables | `usePaginatedQuery` | +| Manual control | `useQuery` | + ## DataApiService Direct Usage For non-React code or more control. @@ -94,9 +140,9 @@ await dataApiService.delete('/topics/abc123') ```typescript function TopicList() { - const { data, loading, error } = useQuery('/topics') + const { data, isLoading, error } = useQuery('/topics') - if (loading) return + if (isLoading) return if (error) { if (error.code === ErrorCode.NOT_FOUND) { return @@ -146,39 +192,18 @@ if (error instanceof DataApiError && error.isRetryable) { ## Common Patterns -### List with Pagination - -```typescript -function TopicListWithPagination() { - const [page, setPage] = useState(1) - const { data, loading } = useQuery('/topics', { - query: { page, limit: 20 } - }) - - return ( - <> - - - - ) -} -``` - ### Create Form ```typescript function CreateTopicForm() { - const { trigger: createTopic, isMutating } = useMutation('/topics', 'POST') - const { mutate } = useQuery('/topics') // For revalidation + // Use refresh option to auto-refresh /topics after creation + const { trigger: createTopic, isLoading } = useMutation('POST', '/topics', { + refresh: ['/topics'] + }) const handleSubmit = async (data: CreateTopicDto) => { try { await createTopic({ body: data }) - await mutate() // Refresh list toast.success('Topic created') } catch (error) { toast.error('Failed to create topic') @@ -188,8 +213,8 @@ function CreateTopicForm() { return (
{/* form fields */} -
) @@ -200,26 +225,16 @@ function CreateTopicForm() { ```typescript function TopicItem({ topic }: { topic: Topic }) { - const { trigger: updateTopic } = useMutation(`/topics/${topic.id}`, 'PATCH') - const { mutate } = useQuery('/topics') + // Use optimisticData for automatic optimistic updates with rollback + const { trigger: updateTopic } = useMutation('PATCH', `/topics/${topic.id}`, { + optimisticData: { ...topic, starred: !topic.starred } + }) const handleToggleStar = async () => { - // Optimistically update the cache - await mutate( - current => ({ - ...current, - items: current.items.map(t => - t.id === topic.id ? { ...t, starred: !t.starred } : t - ) - }), - { revalidate: false } - ) - try { await updateTopic({ body: { starred: !topic.starred } }) } catch (error) { - // Revert on failure - await mutate() + // Rollback happens automatically when optimisticData is set toast.error('Failed to update') } } @@ -279,7 +294,7 @@ The API is fully typed based on schema definitions: const { data } = useQuery('/topics') // data is typed as PaginatedResponse -const { trigger } = useMutation('/topics', 'POST') +const { trigger } = useMutation('POST', '/topics') // trigger expects { body: CreateTopicDto } // returns Topic @@ -291,8 +306,9 @@ const { data: topic } = useQuery('/topics/abc123') ## Best Practices 1. **Use hooks for components**: `useQuery` and `useMutation` handle loading/error states -2. **Handle loading states**: Always show feedback while data is loading -3. **Handle errors gracefully**: Provide meaningful error messages to users -4. **Revalidate after mutations**: Keep the UI in sync with the database -5. **Use conditional fetching**: Pass `null` to skip queries when dependencies aren't ready -6. **Batch related operations**: Consider using transactions for multiple updates +2. **Choose the right pagination hook**: Use `useInfiniteQuery` for infinite scroll, `usePaginatedQuery` for page navigation +3. **Handle loading states**: Always show feedback while data is loading +4. **Handle errors gracefully**: Provide meaningful error messages to users +5. **Revalidate after mutations**: Use `refresh` option to keep the UI in sync +6. **Use conditional fetching**: Set `enabled: false` to skip queries when dependencies aren't ready +7. **Batch related operations**: Consider using transactions for multiple updates diff --git a/src/renderer/src/data/hooks/useDataApi.ts b/src/renderer/src/data/hooks/useDataApi.ts index 4af746d9cf..41aa80a016 100644 --- a/src/renderer/src/data/hooks/useDataApi.ts +++ b/src/renderer/src/data/hooks/useDataApi.ts @@ -1,17 +1,62 @@ +/** + * @fileoverview React hooks for data fetching with SWR integration. + * + * This module provides type-safe hooks for interacting with the DataApi: + * + * - {@link useQuery} - Fetch data with automatic caching and revalidation + * - {@link useMutation} - Perform POST/PUT/PATCH/DELETE operations + * - {@link useInfiniteQuery} - Cursor-based infinite scrolling + * - {@link usePaginatedQuery} - Offset-based pagination with navigation + * - {@link useInvalidateCache} - Manual cache invalidation + * - {@link prefetch} - Warm up cache before user interactions + * + * All hooks use SWR under the hood for caching, deduplication, and revalidation. + * + * @example + * // Basic data fetching + * const { data, isLoading } = useQuery('/topics') + * + * @example + * // Create with auto-refresh + * const { trigger } = useMutation('POST', '/topics', { refresh: ['/topics'] }) + * await trigger({ body: { name: 'New Topic' } }) + * + * @see {@link https://swr.vercel.app SWR Documentation} + */ + +import { dataApiService } from '@data/DataApiService' import type { BodyForPath, QueryParamsForPath, ResponseForPath } from '@shared/data/api/apiPaths' import type { ConcreteApiPaths } from '@shared/data/api/apiTypes' import { - isCursorPaginationResponse, + type CursorPaginationResponse, type OffsetPaginationResponse, type PaginationResponse } from '@shared/data/api/apiTypes' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { KeyedMutator } from 'swr' -import useSWR, { useSWRConfig } from 'swr' +import type { KeyedMutator, SWRConfiguration } from 'swr' +import useSWR, { preload, useSWRConfig } from 'swr' +import type { SWRInfiniteConfiguration } from 'swr/infinite' import useSWRInfinite from 'swr/infinite' +import type { SWRMutationConfiguration } from 'swr/mutation' import useSWRMutation from 'swr/mutation' -import { dataApiService } from '../DataApiService' +/** + * Default SWR configuration shared across all hooks. + * + * @remarks + * - `revalidateOnFocus: false` - Prevents refetch when window regains focus + * - `revalidateOnReconnect: true` - Refetch when network reconnects + * - `dedupingInterval: 5000` - Dedupe requests within 5 seconds + * - `errorRetryCount: 3` - Retry failed requests up to 3 times + * - `errorRetryInterval: 1000` - Wait 1 second between retries + */ +const DEFAULT_SWR_OPTIONS = { + revalidateOnFocus: false, + revalidateOnReconnect: true, + dedupingInterval: 5000, + errorRetryCount: 3, + errorRetryInterval: 1000 +} as const // ============================================================================ // Hook Result Types @@ -24,7 +69,15 @@ type InferPaginatedItem = ResponseForPath { data?: ResponseForPath isLoading: boolean @@ -34,12 +87,17 @@ export interface UseQueryResult { mutate: KeyedMutator> } -/** useMutation result type */ +/** + * useMutation result type + * @property trigger - Execute the mutation with optional body and query params + * @property isLoading - True while the mutation is in progress + * @property error - Error object if the last mutation failed + */ export interface UseMutationResult< TPath extends ConcreteApiPaths, TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH' > { - mutate: (data?: { + trigger: (data?: { body?: BodyForPath query?: QueryParamsForPath }) => Promise> @@ -47,24 +105,45 @@ export interface UseMutationResult< error: Error | undefined } -/** useInfiniteQuery result type */ +/** + * useInfiniteQuery result type (cursor-based pagination) + * @property items - All loaded items flattened from all pages + * @property isLoading - True during initial load + * @property isRefreshing - True during background revalidation + * @property error - Error object if the request failed + * @property hasNext - True if more pages are available (nextCursor exists) + * @property loadNext - Load the next page of items + * @property refresh - Revalidate all loaded pages from the server + * @property reset - Reset to first page only + * @property mutate - SWR mutator for advanced cache control + */ export interface UseInfiniteQueryResult { items: T[] - pages: PaginationResponse[] - total: number - size: number isLoading: boolean isRefreshing: boolean error?: Error hasNext: boolean loadNext: () => void - setSize: (size: number | ((size: number) => number)) => void refresh: () => void reset: () => void - mutate: KeyedMutator[]> + mutate: KeyedMutator[]> } -/** usePaginatedQuery result type */ +/** + * usePaginatedQuery result type (offset-based pagination) + * @property items - Items on the current page + * @property total - Total number of items across all pages + * @property page - Current page number (1-indexed) + * @property isLoading - True during initial load + * @property isRefreshing - True during background revalidation + * @property error - Error object if the request failed + * @property hasNext - True if next page exists + * @property hasPrev - True if previous page exists (page > 1) + * @property prevPage - Navigate to previous page + * @property nextPage - Navigate to next page + * @property refresh - Revalidate current page from the server + * @property reset - Reset to page 1 + */ export interface UsePaginatedQueryResult { items: T[] total: number @@ -80,94 +159,50 @@ export interface UsePaginatedQueryResult { reset: () => void } -// ============================================================================ -// Utilities -// ============================================================================ - /** - * Unified API fetcher with type-safe method dispatching - */ -function createApiFetcher( - method: TMethod -) { - return async ( - path: TPath, - options?: { - body?: BodyForPath - query?: Record - } - ): Promise> => { - switch (method) { - case 'GET': - return dataApiService.get(path, { query: options?.query }) - case 'POST': - return dataApiService.post(path, { body: options?.body, query: options?.query }) - case 'PUT': - return dataApiService.put(path, { body: options?.body || {}, query: options?.query }) - case 'DELETE': - return dataApiService.delete(path, { query: options?.query }) - case 'PATCH': - return dataApiService.patch(path, { body: options?.body, query: options?.query }) - default: - throw new Error(`Unsupported method: ${method}`) - } - } -} - -/** - * Build SWR cache key from path and query - */ -function buildSWRKey( - path: TPath, - query?: Record -): [TPath, Record?] | null { - if (query && Object.keys(query).length > 0) { - return [path, query] - } - - return [path] -} - -/** - * GET request fetcher for SWR - */ -function getFetcher([path, query]: [TPath, Record?]): Promise< - ResponseForPath -> { - const apiFetcher = createApiFetcher('GET') - return apiFetcher(path, { query }) -} - -/** - * Default SWR configuration options shared across hooks - */ -const DEFAULT_SWR_OPTIONS = { - revalidateOnFocus: false, - revalidateOnReconnect: true, - dedupingInterval: 5000, - errorRetryCount: 3, - errorRetryInterval: 1000 -} as const - -/** - * Data fetching hook with SWR caching and revalidation + * Data fetching hook with SWR caching and revalidation. + * + * Features: + * - Automatic caching and deduplication + * - Background revalidation on focus/reconnect + * - Error retry with exponential backoff + * + * @param path - API endpoint path (e.g., '/topics', '/messages') + * @param options - Query options + * @param options.query - Query parameters for filtering, pagination, etc. + * @param options.enabled - Set to false to disable the request (default: true) + * @param options.swrOptions - Override default SWR configuration + * @returns Query result with data, loading states, and cache controls * * @example - * const { data, isLoading, error } = useQuery('/items', { query: { page: 1 } }) + * // Basic usage + * const { data, isLoading, error } = useQuery('/topics') + * + * @example + * // With query parameters + * const { data } = useQuery('/messages', { query: { topicId: 'abc', limit: 20 } }) + * + * @example + * // Conditional fetching + * const { data } = useQuery('/topics', { enabled: !!userId }) + * + * @example + * // Manual cache update + * const { data, mutate } = useQuery('/topics') + * mutate({ ...data, name: 'Updated' }, { revalidate: false }) */ export function useQuery( path: TPath, options?: { /** Query parameters for filtering, pagination, etc. */ query?: QueryParamsForPath - /** Disable the request */ + /** Disable the request (default: true) */ enabled?: boolean - /** Custom SWR options */ - swrOptions?: Parameters[2] + /** Override default SWR configuration */ + swrOptions?: SWRConfiguration } ): UseQueryResult { - // Internal type conversion for SWR compatibility - const key = options?.enabled !== false ? buildSWRKey(path, options?.query as Record) : null + const key = options?.enabled !== false ? buildSWRKey(path, options?.query) : null const { data, error, isLoading, isValidating, mutate } = useSWR(key, getFetcher, { ...DEFAULT_SWR_OPTIONS, @@ -187,29 +222,59 @@ export function useQuery( } /** - * Mutation hook for POST, PUT, DELETE, PATCH operations + * Mutation hook for POST, PUT, DELETE, PATCH operations. + * + * Features: + * - Automatic cache invalidation via refresh option + * - Optimistic updates with automatic rollback on error + * - Success/error callbacks + * + * @param method - HTTP method ('POST' | 'PUT' | 'DELETE' | 'PATCH') + * @param path - API endpoint path + * @param options - Mutation options + * @param options.onSuccess - Callback when mutation succeeds + * @param options.onError - Callback when mutation fails + * @param options.refresh - API paths to revalidate on success + * @param options.optimisticData - If provided, updates cache immediately before request completes + * @param options.swrOptions - Override SWR mutation configuration + * @returns Mutation result with trigger function and loading state * * @example - * const { mutate, isLoading } = useMutation('POST', '/items', { - * onSuccess: (data) => console.log(data), - * revalidate: ['/items'] + * // Basic POST + * const { trigger, isLoading } = useMutation('POST', '/topics') + * await trigger({ body: { name: 'New Topic' } }) + * + * @example + * // With auto-refresh and callbacks + * const { trigger } = useMutation('POST', '/topics', { + * refresh: ['/topics'], + * onSuccess: (data) => toast.success('Created!'), + * onError: (error) => toast.error(error.message) + * }) + * + * @example + * // Optimistic update (UI updates immediately, rolls back on error) + * const { trigger } = useMutation('PATCH', '/topics/abc', { + * optimisticData: { ...topic, starred: true } * }) - * await mutate({ body: { title: 'New Item' } }) */ export function useMutation( method: TMethod, path: TPath, options?: { - /** Called when mutation succeeds */ + /** Callback when mutation succeeds */ onSuccess?: (data: ResponseForPath) => void - /** Called when mutation fails */ + /** Callback when mutation fails */ onError?: (error: Error) => void - /** Automatically revalidate these SWR keys on success */ - revalidate?: boolean | string[] - /** Enable optimistic updates */ - optimistic?: boolean - /** Optimistic data to use for updates */ + /** API paths to revalidate on success */ + refresh?: ConcreteApiPaths[] + /** If provided, updates cache immediately (with auto-rollback on error) */ optimisticData?: ResponseForPath + /** Override SWR mutation configuration (fetcher, onSuccess, onError are handled internally) */ + swrOptions?: Omit< + SWRMutationConfiguration, Error>, + 'fetcher' | 'onSuccess' | 'onError' + > } ): UseMutationResult { const { mutate: globalMutate } = useSWRConfig() @@ -220,7 +285,7 @@ export function useMutation(method) const fetcher = async ( _key: string, @@ -229,79 +294,87 @@ export function useMutation - query?: Record + query?: QueryParamsForPath } } ): Promise> => { return apiFetcher(path, { body: arg?.body, query: arg?.query }) } - const { trigger, isMutating, error } = useSWRMutation(path as string, fetcher, { + const { + trigger: swrTrigger, + isMutating, + error + } = useSWRMutation(path as string, fetcher, { populateCache: false, revalidate: false, onSuccess: async (data) => { optionsRef.current?.onSuccess?.(data) - if (optionsRef.current?.revalidate === true) { - await globalMutate(() => true) - } else if (Array.isArray(optionsRef.current?.revalidate)) { - await Promise.all(optionsRef.current.revalidate.map((key) => globalMutate(key))) + // Refresh specified keys on success + if (optionsRef.current?.refresh?.length) { + await Promise.all(optionsRef.current.refresh.map((key) => globalMutate(key))) } }, - onError: (error) => optionsRef.current?.onError?.(error) + onError: (error) => optionsRef.current?.onError?.(error), + ...options?.swrOptions }) - const optimisticMutate = async (data?: { + const trigger = async (data?: { body?: BodyForPath query?: QueryParamsForPath }): Promise> => { const opts = optionsRef.current - if (opts?.optimistic && opts?.optimisticData) { - await globalMutate(path, opts.optimisticData, false) + const hasOptimisticData = opts?.optimisticData !== undefined + + // Apply optimistic update if optimisticData is provided + if (hasOptimisticData) { + await globalMutate(path, opts!.optimisticData, false) } try { - const convertedData = data ? { body: data.body, query: data.query as Record } : undefined + const result = await swrTrigger(data) - const result = await trigger(convertedData) - - if (opts?.optimistic) { + // Revalidate after optimistic update completes + if (hasOptimisticData) { await globalMutate(path) } return result } catch (err) { - if (opts?.optimistic && opts?.optimisticData) { + // Rollback optimistic update on error + if (hasOptimisticData) { await globalMutate(path) } throw err } } - const normalMutate = async (data?: { - body?: BodyForPath - query?: QueryParamsForPath - }): Promise> => { - const convertedData = data ? { body: data.body, query: data.query as Record } : undefined - - return trigger(convertedData) - } - return { - mutate: optionsRef.current?.optimistic ? optimisticMutate : normalMutate, + trigger, isLoading: isMutating, error } } /** - * Hook to invalidate SWR cache entries + * Hook to invalidate SWR cache entries and trigger revalidation. + * + * Use this to manually clear cached data and force a fresh fetch. + * + * @returns Invalidate function that accepts keys to invalidate * * @example * const invalidate = useInvalidateCache() - * await invalidate('/items') // specific key - * await invalidate(['/a', '/b']) // multiple keys - * await invalidate(true) // all keys + * + * // Invalidate specific path + * await invalidate('/topics') + * + * // Invalidate multiple paths + * await invalidate(['/topics', '/messages']) + * + * // Invalidate all cached data + * await invalidate(true) */ export function useInvalidateCache() { const { mutate } = useSWRConfig() @@ -320,10 +393,25 @@ export function useInvalidateCache() { } /** - * Prefetch data for warming up before user interactions + * Prefetch data to warm up the cache before user interactions. + * + * Uses SWR preload to fetch and cache data. Subsequent useQuery calls + * with the same path and query will use the cached data immediately. + * + * @param path - API endpoint path to prefetch + * @param options - Prefetch options + * @param options.query - Query parameters (must match useQuery call) + * @returns Promise resolving to the fetched data * * @example - * prefetch('/items', { query: { page: 1 } }) + * // Prefetch on hover + * onMouseEnter={() => prefetch('/topics/abc')} + * + * @example + * // Prefetch with query params + * await prefetch('/messages', { query: { topicId: 'abc', limit: 20 } }) + * // Later, this will be instant: + * const { data } = useQuery('/messages', { query: { topicId: 'abc', limit: 20 } }) */ export function prefetch( path: TPath, @@ -331,8 +419,8 @@ export function prefetch( query?: QueryParamsForPath } ): Promise> { - const apiFetcher = createApiFetcher('GET') - return apiFetcher(path, { query: options?.query as Record }) + const key = buildSWRKey(path, options?.query) + return preload(key, getFetcher) } // ============================================================================ @@ -340,100 +428,94 @@ export function prefetch( // ============================================================================ /** - * Infinite scrolling hook with cursor/offset pagination + * Infinite scrolling hook with cursor-based pagination. + * + * Automatically loads pages using cursor tokens. Items from all loaded pages + * are flattened into a single array for easy rendering. + * + * @param path - API endpoint path (must return CursorPaginationResponse) + * @param options - Infinite query options + * @param options.query - Additional query parameters (cursor/limit are managed internally) + * @param options.limit - Items per page (default: 10) + * @param options.enabled - Set to false to disable fetching (default: true) + * @param options.swrOptions - Override SWR infinite configuration + * @returns Infinite query result with items, pagination controls, and loading states * * @example - * const { items, hasNext, loadNext, isLoading } = useInfiniteQuery('/items', { - * limit: 20, - * mode: 'cursor' // or 'offset' + * // Basic infinite scroll + * const { items, hasNext, loadNext, isLoading } = useInfiniteQuery('/messages') + * + * return ( + *
+ * {items.map(item => )} + * {hasNext && } + *
+ * ) + * + * @example + * // With filters and custom limit + * const { items, loadNext } = useInfiniteQuery('/messages', { + * query: { topicId: 'abc' }, + * limit: 50 * }) */ export function useInfiniteQuery( path: TPath, options?: { - /** Additional query parameters (excluding pagination params) */ - query?: Omit, 'page' | 'limit' | 'cursor'> + /** Additional query parameters (cursor/limit are managed internally) */ + query?: Omit, 'cursor' | 'limit'> /** Items per page (default: 10) */ limit?: number - /** Pagination mode (default: 'cursor') */ - mode?: 'offset' | 'cursor' - /** Whether to enable the query (default: true) */ + /** Set to false to disable fetching (default: true) */ enabled?: boolean - /** SWR options (including initialSize, revalidateAll, etc.) */ - swrOptions?: Parameters[2] + /** Override SWR infinite configuration */ + swrOptions?: SWRInfiniteConfiguration } ): UseInfiniteQueryResult> { const limit = options?.limit ?? 10 - const mode = options?.mode ?? 'cursor' // Default: cursor mode const enabled = options?.enabled !== false const getKey = useCallback( - (pageIndex: number, previousPageData: PaginationResponse | null) => { + (_pageIndex: number, previousPageData: CursorPaginationResponse | null) => { if (!enabled) return null - if (previousPageData) { - if (mode === 'cursor') { - if (!isCursorPaginationResponse(previousPageData) || !previousPageData.nextCursor) { - return null - } - } else { - // Offset mode: check if we've reached the end - if (isCursorPaginationResponse(previousPageData)) { - return null - } - const offsetData = previousPageData as OffsetPaginationResponse - // No more pages if items returned is less than limit or we've fetched all - if (offsetData.items.length < limit || pageIndex * limit >= offsetData.total) { - return null - } - } + // Stop if previous page has no nextCursor + if (previousPageData && !previousPageData.nextCursor) { + return null } - const paginationQuery: Record = { - ...(options?.query as Record), - limit + const paginationQuery = { + ...options?.query, + limit, + ...(previousPageData?.nextCursor ? { cursor: previousPageData.nextCursor } : {}) } - if (mode === 'cursor' && previousPageData && isCursorPaginationResponse(previousPageData)) { - paginationQuery.cursor = previousPageData.nextCursor - } else if (mode === 'offset') { - paginationQuery.page = pageIndex + 1 - } - - return [path, paginationQuery] as [TPath, Record] + return [path, paginationQuery] as [TPath, typeof paginationQuery] }, - [path, options?.query, limit, mode, enabled] + [path, options?.query, limit, enabled] ) - const infiniteFetcher = (key: [ConcreteApiPaths, Record?]) => { - return getFetcher(key) as Promise> + const infiniteFetcher = (key: [TPath, Record]) => { + return getFetcher(key as unknown as [TPath, QueryParamsForPath?]) as Promise< + CursorPaginationResponse> + > } const swrResult = useSWRInfinite(getKey, infiniteFetcher, { ...DEFAULT_SWR_OPTIONS, - initialSize: 1, - revalidateAll: false, - revalidateFirstPage: true, - parallel: false, ...options?.swrOptions }) - const { error, isLoading, isValidating, mutate, size, setSize } = swrResult - const data = swrResult.data as PaginationResponse[] | undefined + const { error, isLoading, isValidating, mutate, setSize } = swrResult + const data = swrResult.data as CursorPaginationResponse>[] | undefined const items = useMemo(() => data?.flatMap((p) => p.items) ?? [], [data]) const hasNext = useMemo(() => { if (!data?.length) return false const last = data[data.length - 1] - if (mode === 'cursor') { - return isCursorPaginationResponse(last) && !!last.nextCursor - } - // Offset mode: check if there are more items - if (isCursorPaginationResponse(last)) return false - const offsetData = last as OffsetPaginationResponse - return offsetData.page * limit < offsetData.total - }, [data, mode, limit]) + return !!last.nextCursor + }, [data]) const loadNext = useCallback(() => { if (!hasNext || isValidating) return @@ -443,29 +525,17 @@ export function useInfiniteQuery( const refresh = useCallback(() => mutate(), [mutate]) const reset = useCallback(() => setSize(1), [setSize]) - // Total is only available in offset mode - const total = useMemo(() => { - if (!data?.length) return 0 - const first = data[0] - if (isCursorPaginationResponse(first)) return 0 - return (first as OffsetPaginationResponse).total - }, [data]) - return { items, - pages: data ?? [], - total, - size, isLoading, isRefreshing: isValidating, error: error as Error | undefined, hasNext, loadNext, - setSize, refresh, reset, mutate - } as UseInfiniteQueryResult> + } } // ============================================================================ @@ -473,25 +543,50 @@ export function useInfiniteQuery( // ============================================================================ /** - * Paginated data fetching hook with navigation controls + * Paginated data fetching hook with offset-based navigation. + * + * Provides page-by-page navigation with previous/next controls. + * Automatically resets to page 1 when query parameters change. + * + * @param path - API endpoint path (must return OffsetPaginationResponse) + * @param options - Pagination options + * @param options.query - Additional query parameters (page/limit are managed internally) + * @param options.limit - Items per page (default: 10) + * @param options.enabled - Set to false to disable fetching (default: true) + * @param options.swrOptions - Override SWR configuration + * @returns Paginated query result with items, page info, and navigation controls * * @example - * const { items, page, hasNext, nextPage, prevPage } = usePaginatedQuery('/items', { - * limit: 20, - * query: { search: 'hello' } + * // Basic pagination + * const { items, page, hasNext, hasPrev, nextPage, prevPage } = usePaginatedQuery('/topics') + * + * return ( + *
+ * {items.map(item => )} + * + * Page {page} + * + *
+ * ) + * + * @example + * // With search filter + * const { items, total } = usePaginatedQuery('/topics', { + * query: { search: searchTerm }, + * limit: 20 * }) */ export function usePaginatedQuery( path: TPath, options?: { - /** Additional query parameters (excluding pagination params) */ + /** Additional query parameters (page/limit are managed internally) */ query?: Omit, 'page' | 'limit'> /** Items per page (default: 10) */ limit?: number - /** Whether to enable the query (default: true) */ + /** Set to false to disable fetching (default: true) */ enabled?: boolean - /** SWR options */ - swrOptions?: Parameters[2] + /** Override SWR configuration */ + swrOptions?: SWRConfiguration } ): UsePaginatedQueryResult> { const [currentPage, setCurrentPage] = useState(1) @@ -503,13 +598,15 @@ export function usePaginatedQuery( setCurrentPage(1) }, [queryKey]) + // Build query with pagination params const queryWithPagination = { ...options?.query, page: currentPage, limit - } as Record + } const { data, isLoading, isRefreshing, error, refetch } = useQuery(path, { + // Type assertion needed: we're adding pagination params to a partial query type query: queryWithPagination as QueryParamsForPath, enabled: options?.enabled, swrOptions: options?.swrOptions @@ -555,3 +652,80 @@ export function usePaginatedQuery( reset } as UsePaginatedQueryResult> } + +// ============================================================================ +// Internal Utilities +// ============================================================================ + +/** + * Create a type-safe API fetcher for the specified HTTP method. + * + * @internal + * @param method - HTTP method to use + * @returns Async function that makes the API request + * + * @remarks + * Type assertion at dataApiService boundary is intentional since dataApiService + * accepts 'any' for maximum flexibility. + */ +function createApiFetcher( + method: TMethod +) { + return async ( + path: TPath, + options?: { + body?: BodyForPath + query?: QueryParamsForPath + } + ): Promise> => { + // Internal type assertion for dataApiService boundary (accepts any) + const query = options?.query as Record | undefined + switch (method) { + case 'GET': + return dataApiService.get(path, { query }) + case 'POST': + return dataApiService.post(path, { body: options?.body, query }) + case 'PUT': + return dataApiService.put(path, { body: options?.body || {}, query }) + case 'DELETE': + return dataApiService.delete(path, { query }) + case 'PATCH': + return dataApiService.patch(path, { body: options?.body, query }) + default: + throw new Error(`Unsupported method: ${method}`) + } + } +} + +/** + * Build SWR cache key from path and optional query parameters. + * + * @internal + * @param path - API endpoint path + * @param query - Optional query parameters + * @returns Tuple of [path] or [path, query] for SWR cache key + */ +function buildSWRKey>( + path: TPath, + query?: TQuery +): [TPath] | [TPath, TQuery] { + if (query && Object.keys(query).length > 0) { + return [path, query] + } + + return [path] +} + +/** + * SWR fetcher function for GET requests. + * + * @internal + * @param key - SWR cache key tuple [path, query?] + * @returns Promise resolving to the API response + */ +function getFetcher([path, query]: [TPath, QueryParamsForPath?]): Promise< + ResponseForPath +> { + const apiFetcher = createApiFetcher('GET') + return apiFetcher(path, { query }) +} diff --git a/tests/__mocks__/renderer/useDataApi.ts b/tests/__mocks__/renderer/useDataApi.ts index 760a134583..03d9b71cc0 100644 --- a/tests/__mocks__/renderer/useDataApi.ts +++ b/tests/__mocks__/renderer/useDataApi.ts @@ -46,7 +46,7 @@ function createMockDataForPath(path: ConcreteApiPaths): any { /** * Mock useQuery hook - * Matches actual signature: useQuery(path, options?) => { data, loading, error, refetch, mutate } + * Matches actual signature: useQuery(path, options?) => { data, isLoading, isRefreshing, error, refetch, mutate } */ export const mockUseQuery = vi.fn( ( @@ -58,7 +58,8 @@ export const mockUseQuery = vi.fn( } ): { data?: ResponseForPath - loading: boolean + isLoading: boolean + isRefreshing: boolean error?: Error refetch: () => void mutate: KeyedMutator> @@ -67,7 +68,8 @@ export const mockUseQuery = vi.fn( if (options?.enabled === false) { return { data: undefined, - loading: false, + isLoading: false, + isRefreshing: false, error: undefined, refetch: vi.fn(), mutate: vi.fn().mockResolvedValue(undefined) as unknown as KeyedMutator> @@ -78,7 +80,8 @@ export const mockUseQuery = vi.fn( return { data: mockData as ResponseForPath, - loading: false, + isLoading: false, + isRefreshing: false, error: undefined, refetch: vi.fn(), mutate: vi.fn().mockResolvedValue(mockData) as unknown as KeyedMutator> @@ -88,7 +91,7 @@ export const mockUseQuery = vi.fn( /** * Mock useMutation hook - * Matches actual signature: useMutation(method, path, options?) => { mutate, loading, error } + * Matches actual signature: useMutation(method, path, options?) => { trigger, isLoading, error } */ export const mockUseMutation = vi.fn( ( @@ -97,19 +100,19 @@ export const mockUseMutation = vi.fn( _options?: { onSuccess?: (data: ResponseForPath) => void onError?: (error: Error) => void - revalidate?: boolean | string[] - optimistic?: boolean + refresh?: ConcreteApiPaths[] optimisticData?: ResponseForPath + swrOptions?: any } ): { - mutate: (data?: { + trigger: (data?: { body?: BodyForPath query?: QueryParamsForPath }) => Promise> - loading: boolean + isLoading: boolean error: Error | undefined } => { - const mockMutate = vi.fn( + const mockTrigger = vi.fn( async (_data?: { body?: BodyForPath; query?: QueryParamsForPath }) => { // Simulate different responses based on method switch (method) { @@ -127,8 +130,8 @@ export const mockUseMutation = vi.fn( ) return { - mutate: mockMutate, - loading: false, + trigger: mockTrigger, + isLoading: false, error: undefined } } @@ -136,7 +139,7 @@ export const mockUseMutation = vi.fn( /** * Mock usePaginatedQuery hook - * Matches actual signature: usePaginatedQuery(path, options?) => { items, total, page, loading, error, hasMore, hasPrev, prevPage, nextPage, refresh, reset } + * Matches actual signature: usePaginatedQuery(path, options?) => { items, total, page, isLoading, isRefreshing, error, hasNext, hasPrev, prevPage, nextPage, refresh, reset } */ export const mockUsePaginatedQuery = vi.fn( ( @@ -151,9 +154,10 @@ export const mockUsePaginatedQuery = vi.fn( items: T[] total: number page: number - loading: boolean + isLoading: boolean + isRefreshing: boolean error?: Error - hasMore: boolean + hasNext: boolean hasPrev: boolean prevPage: () => void nextPage: () => void @@ -173,9 +177,10 @@ export const mockUsePaginatedQuery = vi.fn( items: mockItems, total: mockItems.length, page: 1, - loading: false, + isLoading: false, + isRefreshing: false, error: undefined, - hasMore: false, + hasNext: false, hasPrev: false, prevPage: vi.fn(), nextPage: vi.fn(), @@ -186,9 +191,10 @@ export const mockUsePaginatedQuery = vi.fn( items: T[] total: number page: number - loading: boolean + isLoading: boolean + isRefreshing: boolean error?: Error - hasMore: boolean + hasNext: boolean hasPrev: boolean prevPage: () => void nextPage: () => void @@ -259,7 +265,8 @@ export const MockUseDataApiUtils = { if (queryPath === path) { return { data, - loading: false, + isLoading: false, + isRefreshing: false, error: undefined, refetch: vi.fn(), mutate: vi.fn().mockResolvedValue(data) @@ -269,7 +276,8 @@ export const MockUseDataApiUtils = { const defaultData = createMockDataForPath(queryPath) return { data: defaultData, - loading: false, + isLoading: false, + isRefreshing: false, error: undefined, refetch: vi.fn(), mutate: vi.fn().mockResolvedValue(defaultData) @@ -285,7 +293,8 @@ export const MockUseDataApiUtils = { if (queryPath === path) { return { data: undefined, - loading: true, + isLoading: true, + isRefreshing: false, error: undefined, refetch: vi.fn(), mutate: vi.fn().mockResolvedValue(undefined) @@ -294,7 +303,8 @@ export const MockUseDataApiUtils = { const defaultData = createMockDataForPath(queryPath) return { data: defaultData, - loading: false, + isLoading: false, + isRefreshing: false, error: undefined, refetch: vi.fn(), mutate: vi.fn().mockResolvedValue(defaultData) @@ -310,7 +320,8 @@ export const MockUseDataApiUtils = { if (queryPath === path) { return { data: undefined, - loading: false, + isLoading: false, + isRefreshing: false, error, refetch: vi.fn(), mutate: vi.fn().mockResolvedValue(undefined) @@ -319,7 +330,8 @@ export const MockUseDataApiUtils = { const defaultData = createMockDataForPath(queryPath) return { data: defaultData, - loading: false, + isLoading: false, + isRefreshing: false, error: undefined, refetch: vi.fn(), mutate: vi.fn().mockResolvedValue(defaultData) @@ -338,15 +350,15 @@ export const MockUseDataApiUtils = { mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => { if (mutationPath === path && mutationMethod === method) { return { - mutate: vi.fn().mockResolvedValue(result), - loading: false, + trigger: vi.fn().mockResolvedValue(result), + isLoading: false, error: undefined } } // Default behavior return { - mutate: vi.fn().mockResolvedValue({ success: true }), - loading: false, + trigger: vi.fn().mockResolvedValue({ success: true }), + isLoading: false, error: undefined } }) @@ -363,15 +375,15 @@ export const MockUseDataApiUtils = { mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => { if (mutationPath === path && mutationMethod === method) { return { - mutate: vi.fn().mockRejectedValue(error), - loading: false, + trigger: vi.fn().mockRejectedValue(error), + isLoading: false, error: undefined } } // Default behavior return { - mutate: vi.fn().mockResolvedValue({ success: true }), - loading: false, + trigger: vi.fn().mockResolvedValue({ success: true }), + isLoading: false, error: undefined } }) @@ -387,15 +399,15 @@ export const MockUseDataApiUtils = { mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => { if (mutationPath === path && mutationMethod === method) { return { - mutate: vi.fn().mockImplementation(() => new Promise(() => {})), // Never resolves - loading: true, + trigger: vi.fn().mockImplementation(() => new Promise(() => {})), // Never resolves + isLoading: true, error: undefined } } // Default behavior return { - mutate: vi.fn().mockResolvedValue({ success: true }), - loading: false, + trigger: vi.fn().mockResolvedValue({ success: true }), + isLoading: false, error: undefined } }) @@ -407,7 +419,7 @@ export const MockUseDataApiUtils = { mockPaginatedData: ( path: TPath, items: any[], - options?: { total?: number; page?: number; hasMore?: boolean; hasPrev?: boolean } + options?: { total?: number; page?: number; hasNext?: boolean; hasPrev?: boolean } ) => { mockUsePaginatedQuery.mockImplementation((queryPath, _queryOptions) => { if (queryPath === path) { @@ -415,9 +427,10 @@ export const MockUseDataApiUtils = { items, total: options?.total ?? items.length, page: options?.page ?? 1, - loading: false, + isLoading: false, + isRefreshing: false, error: undefined, - hasMore: options?.hasMore ?? false, + hasNext: options?.hasNext ?? false, hasPrev: options?.hasPrev ?? false, prevPage: vi.fn(), nextPage: vi.fn(), @@ -430,9 +443,10 @@ export const MockUseDataApiUtils = { items: [], total: 0, page: 1, - loading: false, + isLoading: false, + isRefreshing: false, error: undefined, - hasMore: false, + hasNext: false, hasPrev: false, prevPage: vi.fn(), nextPage: vi.fn(),