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.
This commit is contained in:
fullex 2026-01-05 00:12:01 +08:00
parent 7cac5b55f6
commit 6567d9d255
3 changed files with 518 additions and 314 deletions

View File

@ -12,7 +12,7 @@ Fetch data with automatic caching and revalidation via SWR.
import { useQuery } from '@data/hooks/useDataApi' import { useQuery } from '@data/hooks/useDataApi'
// Basic usage // Basic usage
const { data, loading, error } = useQuery('/topics') const { data, isLoading, error } = useQuery('/topics')
// With query parameters // With query parameters
const { data: messages } = useQuery('/messages', { const { data: messages } = useQuery('/messages', {
@ -23,12 +23,12 @@ const { data: messages } = useQuery('/messages', {
const { data: topic } = useQuery('/topics/abc123') const { data: topic } = useQuery('/topics/abc123')
// Conditional fetching // Conditional fetching
const { data } = useQuery(topicId ? `/topics/${topicId}` : null) const { data } = useQuery('/topics', { enabled: !!topicId })
// With refresh callback // With refresh callback
const { data, mutate } = useQuery('/topics') const { data, mutate, refetch } = useQuery('/topics')
// Refresh data // Refresh data
await mutate() refetch() // or await mutate()
``` ```
### useMutation (POST/PUT/PATCH/DELETE) ### useMutation (POST/PUT/PATCH/DELETE)
@ -39,22 +39,68 @@ Perform data modifications with loading states.
import { useMutation } from '@data/hooks/useDataApi' import { useMutation } from '@data/hooks/useDataApi'
// Create (POST) // Create (POST)
const { trigger: createTopic, isMutating } = useMutation('/topics', 'POST') const { trigger: createTopic, isLoading } = useMutation('POST', '/topics')
const newTopic = await createTopic({ body: { name: 'New Topic' } }) const newTopic = await createTopic({ body: { name: 'New Topic' } })
// Update (PUT - full replacement) // 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: '...' } }) await replaceTopic({ body: { name: 'Updated Name', description: '...' } })
// Partial Update (PATCH) // Partial Update (PATCH)
const { trigger: updateTopic } = useMutation('/topics/abc123', 'PATCH') const { trigger: updateTopic } = useMutation('PATCH', '/topics/abc123')
await updateTopic({ body: { name: 'New Name' } }) await updateTopic({ body: { name: 'New Name' } })
// Delete // Delete
const { trigger: deleteTopic } = useMutation('/topics/abc123', 'DELETE') const { trigger: deleteTopic } = useMutation('DELETE', '/topics/abc123')
await deleteTopic() 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 ## DataApiService Direct Usage
For non-React code or more control. For non-React code or more control.
@ -94,9 +140,9 @@ await dataApiService.delete('/topics/abc123')
```typescript ```typescript
function TopicList() { function TopicList() {
const { data, loading, error } = useQuery('/topics') const { data, isLoading, error } = useQuery('/topics')
if (loading) return <Loading /> if (isLoading) return <Loading />
if (error) { if (error) {
if (error.code === ErrorCode.NOT_FOUND) { if (error.code === ErrorCode.NOT_FOUND) {
return <NotFound /> return <NotFound />
@ -146,39 +192,18 @@ if (error instanceof DataApiError && error.isRetryable) {
## Common Patterns ## Common Patterns
### List with Pagination
```typescript
function TopicListWithPagination() {
const [page, setPage] = useState(1)
const { data, loading } = useQuery('/topics', {
query: { page, limit: 20 }
})
return (
<>
<List items={data?.items ?? []} />
<Pagination
current={page}
total={data?.total ?? 0}
onChange={setPage}
/>
</>
)
}
```
### Create Form ### Create Form
```typescript ```typescript
function CreateTopicForm() { function CreateTopicForm() {
const { trigger: createTopic, isMutating } = useMutation('/topics', 'POST') // Use refresh option to auto-refresh /topics after creation
const { mutate } = useQuery('/topics') // For revalidation const { trigger: createTopic, isLoading } = useMutation('POST', '/topics', {
refresh: ['/topics']
})
const handleSubmit = async (data: CreateTopicDto) => { const handleSubmit = async (data: CreateTopicDto) => {
try { try {
await createTopic({ body: data }) await createTopic({ body: data })
await mutate() // Refresh list
toast.success('Topic created') toast.success('Topic created')
} catch (error) { } catch (error) {
toast.error('Failed to create topic') toast.error('Failed to create topic')
@ -188,8 +213,8 @@ function CreateTopicForm() {
return ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
{/* form fields */} {/* form fields */}
<button disabled={isMutating}> <button disabled={isLoading}>
{isMutating ? 'Creating...' : 'Create'} {isLoading ? 'Creating...' : 'Create'}
</button> </button>
</form> </form>
) )
@ -200,26 +225,16 @@ function CreateTopicForm() {
```typescript ```typescript
function TopicItem({ topic }: { topic: Topic }) { function TopicItem({ topic }: { topic: Topic }) {
const { trigger: updateTopic } = useMutation(`/topics/${topic.id}`, 'PATCH') // Use optimisticData for automatic optimistic updates with rollback
const { mutate } = useQuery('/topics') const { trigger: updateTopic } = useMutation('PATCH', `/topics/${topic.id}`, {
optimisticData: { ...topic, starred: !topic.starred }
})
const handleToggleStar = async () => { 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 { try {
await updateTopic({ body: { starred: !topic.starred } }) await updateTopic({ body: { starred: !topic.starred } })
} catch (error) { } catch (error) {
// Revert on failure // Rollback happens automatically when optimisticData is set
await mutate()
toast.error('Failed to update') toast.error('Failed to update')
} }
} }
@ -279,7 +294,7 @@ The API is fully typed based on schema definitions:
const { data } = useQuery('/topics') const { data } = useQuery('/topics')
// data is typed as PaginatedResponse<Topic> // data is typed as PaginatedResponse<Topic>
const { trigger } = useMutation('/topics', 'POST') const { trigger } = useMutation('POST', '/topics')
// trigger expects { body: CreateTopicDto } // trigger expects { body: CreateTopicDto }
// returns Topic // returns Topic
@ -291,8 +306,9 @@ const { data: topic } = useQuery('/topics/abc123')
## Best Practices ## Best Practices
1. **Use hooks for components**: `useQuery` and `useMutation` handle loading/error states 1. **Use hooks for components**: `useQuery` and `useMutation` handle loading/error states
2. **Handle loading states**: Always show feedback while data is loading 2. **Choose the right pagination hook**: Use `useInfiniteQuery` for infinite scroll, `usePaginatedQuery` for page navigation
3. **Handle errors gracefully**: Provide meaningful error messages to users 3. **Handle loading states**: Always show feedback while data is loading
4. **Revalidate after mutations**: Keep the UI in sync with the database 4. **Handle errors gracefully**: Provide meaningful error messages to users
5. **Use conditional fetching**: Pass `null` to skip queries when dependencies aren't ready 5. **Revalidate after mutations**: Use `refresh` option to keep the UI in sync
6. **Batch related operations**: Consider using transactions for multiple updates 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

View File

@ -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 { BodyForPath, QueryParamsForPath, ResponseForPath } from '@shared/data/api/apiPaths'
import type { ConcreteApiPaths } from '@shared/data/api/apiTypes' import type { ConcreteApiPaths } from '@shared/data/api/apiTypes'
import { import {
isCursorPaginationResponse, type CursorPaginationResponse,
type OffsetPaginationResponse, type OffsetPaginationResponse,
type PaginationResponse type PaginationResponse
} from '@shared/data/api/apiTypes' } from '@shared/data/api/apiTypes'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { KeyedMutator } from 'swr' import type { KeyedMutator, SWRConfiguration } from 'swr'
import useSWR, { useSWRConfig } from 'swr' import useSWR, { preload, useSWRConfig } from 'swr'
import type { SWRInfiniteConfiguration } from 'swr/infinite'
import useSWRInfinite from 'swr/infinite' import useSWRInfinite from 'swr/infinite'
import type { SWRMutationConfiguration } from 'swr/mutation'
import useSWRMutation 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 // Hook Result Types
@ -24,7 +69,15 @@ type InferPaginatedItem<TPath extends ConcreteApiPaths> = ResponseForPath<TPath,
? T ? T
: unknown : unknown
/** useQuery result type */ /**
* useQuery result type
* @property data - The fetched data, undefined while loading or on error
* @property isLoading - True during initial load (no cached data)
* @property isRefreshing - True during background revalidation (has cached data)
* @property error - Error object if the request failed
* @property refetch - Trigger a revalidation from the server
* @property mutate - SWR mutator for advanced cache control (optimistic updates, manual cache manipulation)
*/
export interface UseQueryResult<TPath extends ConcreteApiPaths> { export interface UseQueryResult<TPath extends ConcreteApiPaths> {
data?: ResponseForPath<TPath, 'GET'> data?: ResponseForPath<TPath, 'GET'>
isLoading: boolean isLoading: boolean
@ -34,12 +87,17 @@ export interface UseQueryResult<TPath extends ConcreteApiPaths> {
mutate: KeyedMutator<ResponseForPath<TPath, 'GET'>> mutate: KeyedMutator<ResponseForPath<TPath, 'GET'>>
} }
/** 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< export interface UseMutationResult<
TPath extends ConcreteApiPaths, TPath extends ConcreteApiPaths,
TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH' TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH'
> { > {
mutate: (data?: { trigger: (data?: {
body?: BodyForPath<TPath, TMethod> body?: BodyForPath<TPath, TMethod>
query?: QueryParamsForPath<TPath> query?: QueryParamsForPath<TPath>
}) => Promise<ResponseForPath<TPath, TMethod>> }) => Promise<ResponseForPath<TPath, TMethod>>
@ -47,24 +105,45 @@ export interface UseMutationResult<
error: Error | undefined 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<T> { export interface UseInfiniteQueryResult<T> {
items: T[] items: T[]
pages: PaginationResponse<T>[]
total: number
size: number
isLoading: boolean isLoading: boolean
isRefreshing: boolean isRefreshing: boolean
error?: Error error?: Error
hasNext: boolean hasNext: boolean
loadNext: () => void loadNext: () => void
setSize: (size: number | ((size: number) => number)) => void
refresh: () => void refresh: () => void
reset: () => void reset: () => void
mutate: KeyedMutator<PaginationResponse<T>[]> mutate: KeyedMutator<CursorPaginationResponse<T>[]>
} }
/** 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<T> { export interface UsePaginatedQueryResult<T> {
items: T[] items: T[]
total: number total: number
@ -80,94 +159,50 @@ export interface UsePaginatedQueryResult<T> {
reset: () => void reset: () => void
} }
// ============================================================================
// Utilities
// ============================================================================
/** /**
* Unified API fetcher with type-safe method dispatching * Data fetching hook with SWR caching and revalidation.
*/ *
function createApiFetcher<TPath extends ConcreteApiPaths, TMethod extends 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'>( * Features:
method: TMethod * - Automatic caching and deduplication
) { * - Background revalidation on focus/reconnect
return async ( * - Error retry with exponential backoff
path: TPath, *
options?: { * @param path - API endpoint path (e.g., '/topics', '/messages')
body?: BodyForPath<TPath, TMethod> * @param options - Query options
query?: Record<string, any> * @param options.query - Query parameters for filtering, pagination, etc.
} * @param options.enabled - Set to false to disable the request (default: true)
): Promise<ResponseForPath<TPath, TMethod>> => { * @param options.swrOptions - Override default SWR configuration
switch (method) { * @returns Query result with data, loading states, and cache controls
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<TPath extends ConcreteApiPaths>(
path: TPath,
query?: Record<string, any>
): [TPath, Record<string, any>?] | null {
if (query && Object.keys(query).length > 0) {
return [path, query]
}
return [path]
}
/**
* GET request fetcher for SWR
*/
function getFetcher<TPath extends ConcreteApiPaths>([path, query]: [TPath, Record<string, any>?]): Promise<
ResponseForPath<TPath, 'GET'>
> {
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
* *
* @example * @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<TPath extends ConcreteApiPaths>( export function useQuery<TPath extends ConcreteApiPaths>(
path: TPath, path: TPath,
options?: { options?: {
/** Query parameters for filtering, pagination, etc. */ /** Query parameters for filtering, pagination, etc. */
query?: QueryParamsForPath<TPath> query?: QueryParamsForPath<TPath>
/** Disable the request */ /** Disable the request (default: true) */
enabled?: boolean enabled?: boolean
/** Custom SWR options */ /** Override default SWR configuration */
swrOptions?: Parameters<typeof useSWR>[2] swrOptions?: SWRConfiguration
} }
): UseQueryResult<TPath> { ): UseQueryResult<TPath> {
// Internal type conversion for SWR compatibility const key = options?.enabled !== false ? buildSWRKey(path, options?.query) : null
const key = options?.enabled !== false ? buildSWRKey(path, options?.query as Record<string, any>) : null
const { data, error, isLoading, isValidating, mutate } = useSWR(key, getFetcher, { const { data, error, isLoading, isValidating, mutate } = useSWR(key, getFetcher, {
...DEFAULT_SWR_OPTIONS, ...DEFAULT_SWR_OPTIONS,
@ -187,29 +222,59 @@ export function useQuery<TPath extends ConcreteApiPaths>(
} }
/** /**
* 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 * @example
* const { mutate, isLoading } = useMutation('POST', '/items', { * // Basic POST
* onSuccess: (data) => console.log(data), * const { trigger, isLoading } = useMutation('POST', '/topics')
* revalidate: ['/items'] * 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<TPath extends ConcreteApiPaths, TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH'>( export function useMutation<TPath extends ConcreteApiPaths, TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH'>(
method: TMethod, method: TMethod,
path: TPath, path: TPath,
options?: { options?: {
/** Called when mutation succeeds */ /** Callback when mutation succeeds */
onSuccess?: (data: ResponseForPath<TPath, TMethod>) => void onSuccess?: (data: ResponseForPath<TPath, TMethod>) => void
/** Called when mutation fails */ /** Callback when mutation fails */
onError?: (error: Error) => void onError?: (error: Error) => void
/** Automatically revalidate these SWR keys on success */ /** API paths to revalidate on success */
revalidate?: boolean | string[] refresh?: ConcreteApiPaths[]
/** Enable optimistic updates */ /** If provided, updates cache immediately (with auto-rollback on error) */
optimistic?: boolean
/** Optimistic data to use for updates */
optimisticData?: ResponseForPath<TPath, TMethod> optimisticData?: ResponseForPath<TPath, TMethod>
/** Override SWR mutation configuration (fetcher, onSuccess, onError are handled internally) */
swrOptions?: Omit<
SWRMutationConfiguration<ResponseForPath<TPath, TMethod>, Error>,
'fetcher' | 'onSuccess' | 'onError'
>
} }
): UseMutationResult<TPath, TMethod> { ): UseMutationResult<TPath, TMethod> {
const { mutate: globalMutate } = useSWRConfig() const { mutate: globalMutate } = useSWRConfig()
@ -220,7 +285,7 @@ export function useMutation<TPath extends ConcreteApiPaths, TMethod extends 'POS
optionsRef.current = options optionsRef.current = options
}, [options]) }, [options])
const apiFetcher = createApiFetcher(method) const apiFetcher = createApiFetcher<TPath, TMethod>(method)
const fetcher = async ( const fetcher = async (
_key: string, _key: string,
@ -229,79 +294,87 @@ export function useMutation<TPath extends ConcreteApiPaths, TMethod extends 'POS
}: { }: {
arg?: { arg?: {
body?: BodyForPath<TPath, TMethod> body?: BodyForPath<TPath, TMethod>
query?: Record<string, any> query?: QueryParamsForPath<TPath>
} }
} }
): Promise<ResponseForPath<TPath, TMethod>> => { ): Promise<ResponseForPath<TPath, TMethod>> => {
return apiFetcher(path, { body: arg?.body, query: arg?.query }) 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, populateCache: false,
revalidate: false, revalidate: false,
onSuccess: async (data) => { onSuccess: async (data) => {
optionsRef.current?.onSuccess?.(data) optionsRef.current?.onSuccess?.(data)
if (optionsRef.current?.revalidate === true) { // Refresh specified keys on success
await globalMutate(() => true) if (optionsRef.current?.refresh?.length) {
} else if (Array.isArray(optionsRef.current?.revalidate)) { await Promise.all(optionsRef.current.refresh.map((key) => globalMutate(key)))
await Promise.all(optionsRef.current.revalidate.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<TPath, TMethod> body?: BodyForPath<TPath, TMethod>
query?: QueryParamsForPath<TPath> query?: QueryParamsForPath<TPath>
}): Promise<ResponseForPath<TPath, TMethod>> => { }): Promise<ResponseForPath<TPath, TMethod>> => {
const opts = optionsRef.current const opts = optionsRef.current
if (opts?.optimistic && opts?.optimisticData) { const hasOptimisticData = opts?.optimisticData !== undefined
await globalMutate(path, opts.optimisticData, false)
// Apply optimistic update if optimisticData is provided
if (hasOptimisticData) {
await globalMutate(path, opts!.optimisticData, false)
} }
try { try {
const convertedData = data ? { body: data.body, query: data.query as Record<string, any> } : undefined const result = await swrTrigger(data)
const result = await trigger(convertedData) // Revalidate after optimistic update completes
if (hasOptimisticData) {
if (opts?.optimistic) {
await globalMutate(path) await globalMutate(path)
} }
return result return result
} catch (err) { } catch (err) {
if (opts?.optimistic && opts?.optimisticData) { // Rollback optimistic update on error
if (hasOptimisticData) {
await globalMutate(path) await globalMutate(path)
} }
throw err throw err
} }
} }
const normalMutate = async (data?: {
body?: BodyForPath<TPath, TMethod>
query?: QueryParamsForPath<TPath>
}): Promise<ResponseForPath<TPath, TMethod>> => {
const convertedData = data ? { body: data.body, query: data.query as Record<string, any> } : undefined
return trigger(convertedData)
}
return { return {
mutate: optionsRef.current?.optimistic ? optimisticMutate : normalMutate, trigger,
isLoading: isMutating, isLoading: isMutating,
error 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 * @example
* const invalidate = useInvalidateCache() * const invalidate = useInvalidateCache()
* await invalidate('/items') // specific key *
* await invalidate(['/a', '/b']) // multiple keys * // Invalidate specific path
* await invalidate(true) // all keys * await invalidate('/topics')
*
* // Invalidate multiple paths
* await invalidate(['/topics', '/messages'])
*
* // Invalidate all cached data
* await invalidate(true)
*/ */
export function useInvalidateCache() { export function useInvalidateCache() {
const { mutate } = useSWRConfig() 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 * @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<TPath extends ConcreteApiPaths>( export function prefetch<TPath extends ConcreteApiPaths>(
path: TPath, path: TPath,
@ -331,8 +419,8 @@ export function prefetch<TPath extends ConcreteApiPaths>(
query?: QueryParamsForPath<TPath> query?: QueryParamsForPath<TPath>
} }
): Promise<ResponseForPath<TPath, 'GET'>> { ): Promise<ResponseForPath<TPath, 'GET'>> {
const apiFetcher = createApiFetcher('GET') const key = buildSWRKey(path, options?.query)
return apiFetcher(path, { query: options?.query as Record<string, any> }) return preload(key, getFetcher)
} }
// ============================================================================ // ============================================================================
@ -340,100 +428,94 @@ export function prefetch<TPath extends ConcreteApiPaths>(
// ============================================================================ // ============================================================================
/** /**
* 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 * @example
* const { items, hasNext, loadNext, isLoading } = useInfiniteQuery('/items', { * // Basic infinite scroll
* limit: 20, * const { items, hasNext, loadNext, isLoading } = useInfiniteQuery('/messages')
* mode: 'cursor' // or 'offset' *
* return (
* <div>
* {items.map(item => <Item key={item.id} {...item} />)}
* {hasNext && <button onClick={loadNext}>Load More</button>}
* </div>
* )
*
* @example
* // With filters and custom limit
* const { items, loadNext } = useInfiniteQuery('/messages', {
* query: { topicId: 'abc' },
* limit: 50
* }) * })
*/ */
export function useInfiniteQuery<TPath extends ConcreteApiPaths>( export function useInfiniteQuery<TPath extends ConcreteApiPaths>(
path: TPath, path: TPath,
options?: { options?: {
/** Additional query parameters (excluding pagination params) */ /** Additional query parameters (cursor/limit are managed internally) */
query?: Omit<QueryParamsForPath<TPath>, 'page' | 'limit' | 'cursor'> query?: Omit<QueryParamsForPath<TPath>, 'cursor' | 'limit'>
/** Items per page (default: 10) */ /** Items per page (default: 10) */
limit?: number limit?: number
/** Pagination mode (default: 'cursor') */ /** Set to false to disable fetching (default: true) */
mode?: 'offset' | 'cursor'
/** Whether to enable the query (default: true) */
enabled?: boolean enabled?: boolean
/** SWR options (including initialSize, revalidateAll, etc.) */ /** Override SWR infinite configuration */
swrOptions?: Parameters<typeof useSWRInfinite>[2] swrOptions?: SWRInfiniteConfiguration
} }
): UseInfiniteQueryResult<InferPaginatedItem<TPath>> { ): UseInfiniteQueryResult<InferPaginatedItem<TPath>> {
const limit = options?.limit ?? 10 const limit = options?.limit ?? 10
const mode = options?.mode ?? 'cursor' // Default: cursor mode
const enabled = options?.enabled !== false const enabled = options?.enabled !== false
const getKey = useCallback( const getKey = useCallback(
(pageIndex: number, previousPageData: PaginationResponse<any> | null) => { (_pageIndex: number, previousPageData: CursorPaginationResponse<unknown> | null) => {
if (!enabled) return null if (!enabled) return null
if (previousPageData) { // Stop if previous page has no nextCursor
if (mode === 'cursor') { if (previousPageData && !previousPageData.nextCursor) {
if (!isCursorPaginationResponse(previousPageData) || !previousPageData.nextCursor) { return null
return null
}
} else {
// Offset mode: check if we've reached the end
if (isCursorPaginationResponse(previousPageData)) {
return null
}
const offsetData = previousPageData as OffsetPaginationResponse<any>
// 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
}
}
} }
const paginationQuery: Record<string, any> = { const paginationQuery = {
...(options?.query as Record<string, any>), ...options?.query,
limit limit,
...(previousPageData?.nextCursor ? { cursor: previousPageData.nextCursor } : {})
} }
if (mode === 'cursor' && previousPageData && isCursorPaginationResponse(previousPageData)) { return [path, paginationQuery] as [TPath, typeof paginationQuery]
paginationQuery.cursor = previousPageData.nextCursor
} else if (mode === 'offset') {
paginationQuery.page = pageIndex + 1
}
return [path, paginationQuery] as [TPath, Record<string, any>]
}, },
[path, options?.query, limit, mode, enabled] [path, options?.query, limit, enabled]
) )
const infiniteFetcher = (key: [ConcreteApiPaths, Record<string, any>?]) => { const infiniteFetcher = (key: [TPath, Record<string, unknown>]) => {
return getFetcher(key) as Promise<PaginationResponse<any>> return getFetcher(key as unknown as [TPath, QueryParamsForPath<TPath>?]) as Promise<
CursorPaginationResponse<InferPaginatedItem<TPath>>
>
} }
const swrResult = useSWRInfinite(getKey, infiniteFetcher, { const swrResult = useSWRInfinite(getKey, infiniteFetcher, {
...DEFAULT_SWR_OPTIONS, ...DEFAULT_SWR_OPTIONS,
initialSize: 1,
revalidateAll: false,
revalidateFirstPage: true,
parallel: false,
...options?.swrOptions ...options?.swrOptions
}) })
const { error, isLoading, isValidating, mutate, size, setSize } = swrResult const { error, isLoading, isValidating, mutate, setSize } = swrResult
const data = swrResult.data as PaginationResponse<any>[] | undefined const data = swrResult.data as CursorPaginationResponse<InferPaginatedItem<TPath>>[] | undefined
const items = useMemo(() => data?.flatMap((p) => p.items) ?? [], [data]) const items = useMemo(() => data?.flatMap((p) => p.items) ?? [], [data])
const hasNext = useMemo(() => { const hasNext = useMemo(() => {
if (!data?.length) return false if (!data?.length) return false
const last = data[data.length - 1] const last = data[data.length - 1]
if (mode === 'cursor') { return !!last.nextCursor
return isCursorPaginationResponse(last) && !!last.nextCursor }, [data])
}
// Offset mode: check if there are more items
if (isCursorPaginationResponse(last)) return false
const offsetData = last as OffsetPaginationResponse<any>
return offsetData.page * limit < offsetData.total
}, [data, mode, limit])
const loadNext = useCallback(() => { const loadNext = useCallback(() => {
if (!hasNext || isValidating) return if (!hasNext || isValidating) return
@ -443,29 +525,17 @@ export function useInfiniteQuery<TPath extends ConcreteApiPaths>(
const refresh = useCallback(() => mutate(), [mutate]) const refresh = useCallback(() => mutate(), [mutate])
const reset = useCallback(() => setSize(1), [setSize]) 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<any>).total
}, [data])
return { return {
items, items,
pages: data ?? [],
total,
size,
isLoading, isLoading,
isRefreshing: isValidating, isRefreshing: isValidating,
error: error as Error | undefined, error: error as Error | undefined,
hasNext, hasNext,
loadNext, loadNext,
setSize,
refresh, refresh,
reset, reset,
mutate mutate
} as UseInfiniteQueryResult<InferPaginatedItem<TPath>> }
} }
// ============================================================================ // ============================================================================
@ -473,25 +543,50 @@ export function useInfiniteQuery<TPath extends ConcreteApiPaths>(
// ============================================================================ // ============================================================================
/** /**
* 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 * @example
* const { items, page, hasNext, nextPage, prevPage } = usePaginatedQuery('/items', { * // Basic pagination
* limit: 20, * const { items, page, hasNext, hasPrev, nextPage, prevPage } = usePaginatedQuery('/topics')
* query: { search: 'hello' } *
* return (
* <div>
* {items.map(item => <Item key={item.id} {...item} />)}
* <button onClick={prevPage} disabled={!hasPrev}>Prev</button>
* <span>Page {page}</span>
* <button onClick={nextPage} disabled={!hasNext}>Next</button>
* </div>
* )
*
* @example
* // With search filter
* const { items, total } = usePaginatedQuery('/topics', {
* query: { search: searchTerm },
* limit: 20
* }) * })
*/ */
export function usePaginatedQuery<TPath extends ConcreteApiPaths>( export function usePaginatedQuery<TPath extends ConcreteApiPaths>(
path: TPath, path: TPath,
options?: { options?: {
/** Additional query parameters (excluding pagination params) */ /** Additional query parameters (page/limit are managed internally) */
query?: Omit<QueryParamsForPath<TPath>, 'page' | 'limit'> query?: Omit<QueryParamsForPath<TPath>, 'page' | 'limit'>
/** Items per page (default: 10) */ /** Items per page (default: 10) */
limit?: number limit?: number
/** Whether to enable the query (default: true) */ /** Set to false to disable fetching (default: true) */
enabled?: boolean enabled?: boolean
/** SWR options */ /** Override SWR configuration */
swrOptions?: Parameters<typeof useSWR>[2] swrOptions?: SWRConfiguration
} }
): UsePaginatedQueryResult<InferPaginatedItem<TPath>> { ): UsePaginatedQueryResult<InferPaginatedItem<TPath>> {
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
@ -503,13 +598,15 @@ export function usePaginatedQuery<TPath extends ConcreteApiPaths>(
setCurrentPage(1) setCurrentPage(1)
}, [queryKey]) }, [queryKey])
// Build query with pagination params
const queryWithPagination = { const queryWithPagination = {
...options?.query, ...options?.query,
page: currentPage, page: currentPage,
limit limit
} as Record<string, any> }
const { data, isLoading, isRefreshing, error, refetch } = useQuery(path, { 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<TPath>, query: queryWithPagination as QueryParamsForPath<TPath>,
enabled: options?.enabled, enabled: options?.enabled,
swrOptions: options?.swrOptions swrOptions: options?.swrOptions
@ -555,3 +652,80 @@ export function usePaginatedQuery<TPath extends ConcreteApiPaths>(
reset reset
} as UsePaginatedQueryResult<InferPaginatedItem<TPath>> } as UsePaginatedQueryResult<InferPaginatedItem<TPath>>
} }
// ============================================================================
// 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<TPath extends ConcreteApiPaths, TMethod extends 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'>(
method: TMethod
) {
return async (
path: TPath,
options?: {
body?: BodyForPath<TPath, TMethod>
query?: QueryParamsForPath<TPath>
}
): Promise<ResponseForPath<TPath, TMethod>> => {
// Internal type assertion for dataApiService boundary (accepts any)
const query = options?.query as Record<string, unknown> | 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<TPath extends ConcreteApiPaths, TQuery extends QueryParamsForPath<TPath>>(
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<TPath extends ConcreteApiPaths>([path, query]: [TPath, QueryParamsForPath<TPath>?]): Promise<
ResponseForPath<TPath, 'GET'>
> {
const apiFetcher = createApiFetcher<TPath, 'GET'>('GET')
return apiFetcher(path, { query })
}

View File

@ -46,7 +46,7 @@ function createMockDataForPath(path: ConcreteApiPaths): any {
/** /**
* Mock useQuery hook * 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( export const mockUseQuery = vi.fn(
<TPath extends ConcreteApiPaths>( <TPath extends ConcreteApiPaths>(
@ -58,7 +58,8 @@ export const mockUseQuery = vi.fn(
} }
): { ): {
data?: ResponseForPath<TPath, 'GET'> data?: ResponseForPath<TPath, 'GET'>
loading: boolean isLoading: boolean
isRefreshing: boolean
error?: Error error?: Error
refetch: () => void refetch: () => void
mutate: KeyedMutator<ResponseForPath<TPath, 'GET'>> mutate: KeyedMutator<ResponseForPath<TPath, 'GET'>>
@ -67,7 +68,8 @@ export const mockUseQuery = vi.fn(
if (options?.enabled === false) { if (options?.enabled === false) {
return { return {
data: undefined, data: undefined,
loading: false, isLoading: false,
isRefreshing: false,
error: undefined, error: undefined,
refetch: vi.fn(), refetch: vi.fn(),
mutate: vi.fn().mockResolvedValue(undefined) as unknown as KeyedMutator<ResponseForPath<TPath, 'GET'>> mutate: vi.fn().mockResolvedValue(undefined) as unknown as KeyedMutator<ResponseForPath<TPath, 'GET'>>
@ -78,7 +80,8 @@ export const mockUseQuery = vi.fn(
return { return {
data: mockData as ResponseForPath<TPath, 'GET'>, data: mockData as ResponseForPath<TPath, 'GET'>,
loading: false, isLoading: false,
isRefreshing: false,
error: undefined, error: undefined,
refetch: vi.fn(), refetch: vi.fn(),
mutate: vi.fn().mockResolvedValue(mockData) as unknown as KeyedMutator<ResponseForPath<TPath, 'GET'>> mutate: vi.fn().mockResolvedValue(mockData) as unknown as KeyedMutator<ResponseForPath<TPath, 'GET'>>
@ -88,7 +91,7 @@ export const mockUseQuery = vi.fn(
/** /**
* Mock useMutation hook * 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( export const mockUseMutation = vi.fn(
<TPath extends ConcreteApiPaths, TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH'>( <TPath extends ConcreteApiPaths, TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH'>(
@ -97,19 +100,19 @@ export const mockUseMutation = vi.fn(
_options?: { _options?: {
onSuccess?: (data: ResponseForPath<TPath, TMethod>) => void onSuccess?: (data: ResponseForPath<TPath, TMethod>) => void
onError?: (error: Error) => void onError?: (error: Error) => void
revalidate?: boolean | string[] refresh?: ConcreteApiPaths[]
optimistic?: boolean
optimisticData?: ResponseForPath<TPath, TMethod> optimisticData?: ResponseForPath<TPath, TMethod>
swrOptions?: any
} }
): { ): {
mutate: (data?: { trigger: (data?: {
body?: BodyForPath<TPath, TMethod> body?: BodyForPath<TPath, TMethod>
query?: QueryParamsForPath<TPath> query?: QueryParamsForPath<TPath>
}) => Promise<ResponseForPath<TPath, TMethod>> }) => Promise<ResponseForPath<TPath, TMethod>>
loading: boolean isLoading: boolean
error: Error | undefined error: Error | undefined
} => { } => {
const mockMutate = vi.fn( const mockTrigger = vi.fn(
async (_data?: { body?: BodyForPath<TPath, TMethod>; query?: QueryParamsForPath<TPath> }) => { async (_data?: { body?: BodyForPath<TPath, TMethod>; query?: QueryParamsForPath<TPath> }) => {
// Simulate different responses based on method // Simulate different responses based on method
switch (method) { switch (method) {
@ -127,8 +130,8 @@ export const mockUseMutation = vi.fn(
) )
return { return {
mutate: mockMutate, trigger: mockTrigger,
loading: false, isLoading: false,
error: undefined error: undefined
} }
} }
@ -136,7 +139,7 @@ export const mockUseMutation = vi.fn(
/** /**
* Mock usePaginatedQuery hook * 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( export const mockUsePaginatedQuery = vi.fn(
<TPath extends ConcreteApiPaths>( <TPath extends ConcreteApiPaths>(
@ -151,9 +154,10 @@ export const mockUsePaginatedQuery = vi.fn(
items: T[] items: T[]
total: number total: number
page: number page: number
loading: boolean isLoading: boolean
isRefreshing: boolean
error?: Error error?: Error
hasMore: boolean hasNext: boolean
hasPrev: boolean hasPrev: boolean
prevPage: () => void prevPage: () => void
nextPage: () => void nextPage: () => void
@ -173,9 +177,10 @@ export const mockUsePaginatedQuery = vi.fn(
items: mockItems, items: mockItems,
total: mockItems.length, total: mockItems.length,
page: 1, page: 1,
loading: false, isLoading: false,
isRefreshing: false,
error: undefined, error: undefined,
hasMore: false, hasNext: false,
hasPrev: false, hasPrev: false,
prevPage: vi.fn(), prevPage: vi.fn(),
nextPage: vi.fn(), nextPage: vi.fn(),
@ -186,9 +191,10 @@ export const mockUsePaginatedQuery = vi.fn(
items: T[] items: T[]
total: number total: number
page: number page: number
loading: boolean isLoading: boolean
isRefreshing: boolean
error?: Error error?: Error
hasMore: boolean hasNext: boolean
hasPrev: boolean hasPrev: boolean
prevPage: () => void prevPage: () => void
nextPage: () => void nextPage: () => void
@ -259,7 +265,8 @@ export const MockUseDataApiUtils = {
if (queryPath === path) { if (queryPath === path) {
return { return {
data, data,
loading: false, isLoading: false,
isRefreshing: false,
error: undefined, error: undefined,
refetch: vi.fn(), refetch: vi.fn(),
mutate: vi.fn().mockResolvedValue(data) mutate: vi.fn().mockResolvedValue(data)
@ -269,7 +276,8 @@ export const MockUseDataApiUtils = {
const defaultData = createMockDataForPath(queryPath) const defaultData = createMockDataForPath(queryPath)
return { return {
data: defaultData, data: defaultData,
loading: false, isLoading: false,
isRefreshing: false,
error: undefined, error: undefined,
refetch: vi.fn(), refetch: vi.fn(),
mutate: vi.fn().mockResolvedValue(defaultData) mutate: vi.fn().mockResolvedValue(defaultData)
@ -285,7 +293,8 @@ export const MockUseDataApiUtils = {
if (queryPath === path) { if (queryPath === path) {
return { return {
data: undefined, data: undefined,
loading: true, isLoading: true,
isRefreshing: false,
error: undefined, error: undefined,
refetch: vi.fn(), refetch: vi.fn(),
mutate: vi.fn().mockResolvedValue(undefined) mutate: vi.fn().mockResolvedValue(undefined)
@ -294,7 +303,8 @@ export const MockUseDataApiUtils = {
const defaultData = createMockDataForPath(queryPath) const defaultData = createMockDataForPath(queryPath)
return { return {
data: defaultData, data: defaultData,
loading: false, isLoading: false,
isRefreshing: false,
error: undefined, error: undefined,
refetch: vi.fn(), refetch: vi.fn(),
mutate: vi.fn().mockResolvedValue(defaultData) mutate: vi.fn().mockResolvedValue(defaultData)
@ -310,7 +320,8 @@ export const MockUseDataApiUtils = {
if (queryPath === path) { if (queryPath === path) {
return { return {
data: undefined, data: undefined,
loading: false, isLoading: false,
isRefreshing: false,
error, error,
refetch: vi.fn(), refetch: vi.fn(),
mutate: vi.fn().mockResolvedValue(undefined) mutate: vi.fn().mockResolvedValue(undefined)
@ -319,7 +330,8 @@ export const MockUseDataApiUtils = {
const defaultData = createMockDataForPath(queryPath) const defaultData = createMockDataForPath(queryPath)
return { return {
data: defaultData, data: defaultData,
loading: false, isLoading: false,
isRefreshing: false,
error: undefined, error: undefined,
refetch: vi.fn(), refetch: vi.fn(),
mutate: vi.fn().mockResolvedValue(defaultData) mutate: vi.fn().mockResolvedValue(defaultData)
@ -338,15 +350,15 @@ export const MockUseDataApiUtils = {
mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => { mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => {
if (mutationPath === path && mutationMethod === method) { if (mutationPath === path && mutationMethod === method) {
return { return {
mutate: vi.fn().mockResolvedValue(result), trigger: vi.fn().mockResolvedValue(result),
loading: false, isLoading: false,
error: undefined error: undefined
} }
} }
// Default behavior // Default behavior
return { return {
mutate: vi.fn().mockResolvedValue({ success: true }), trigger: vi.fn().mockResolvedValue({ success: true }),
loading: false, isLoading: false,
error: undefined error: undefined
} }
}) })
@ -363,15 +375,15 @@ export const MockUseDataApiUtils = {
mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => { mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => {
if (mutationPath === path && mutationMethod === method) { if (mutationPath === path && mutationMethod === method) {
return { return {
mutate: vi.fn().mockRejectedValue(error), trigger: vi.fn().mockRejectedValue(error),
loading: false, isLoading: false,
error: undefined error: undefined
} }
} }
// Default behavior // Default behavior
return { return {
mutate: vi.fn().mockResolvedValue({ success: true }), trigger: vi.fn().mockResolvedValue({ success: true }),
loading: false, isLoading: false,
error: undefined error: undefined
} }
}) })
@ -387,15 +399,15 @@ export const MockUseDataApiUtils = {
mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => { mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => {
if (mutationPath === path && mutationMethod === method) { if (mutationPath === path && mutationMethod === method) {
return { return {
mutate: vi.fn().mockImplementation(() => new Promise(() => {})), // Never resolves trigger: vi.fn().mockImplementation(() => new Promise(() => {})), // Never resolves
loading: true, isLoading: true,
error: undefined error: undefined
} }
} }
// Default behavior // Default behavior
return { return {
mutate: vi.fn().mockResolvedValue({ success: true }), trigger: vi.fn().mockResolvedValue({ success: true }),
loading: false, isLoading: false,
error: undefined error: undefined
} }
}) })
@ -407,7 +419,7 @@ export const MockUseDataApiUtils = {
mockPaginatedData: <TPath extends ConcreteApiPaths>( mockPaginatedData: <TPath extends ConcreteApiPaths>(
path: TPath, path: TPath,
items: any[], items: any[],
options?: { total?: number; page?: number; hasMore?: boolean; hasPrev?: boolean } options?: { total?: number; page?: number; hasNext?: boolean; hasPrev?: boolean }
) => { ) => {
mockUsePaginatedQuery.mockImplementation((queryPath, _queryOptions) => { mockUsePaginatedQuery.mockImplementation((queryPath, _queryOptions) => {
if (queryPath === path) { if (queryPath === path) {
@ -415,9 +427,10 @@ export const MockUseDataApiUtils = {
items, items,
total: options?.total ?? items.length, total: options?.total ?? items.length,
page: options?.page ?? 1, page: options?.page ?? 1,
loading: false, isLoading: false,
isRefreshing: false,
error: undefined, error: undefined,
hasMore: options?.hasMore ?? false, hasNext: options?.hasNext ?? false,
hasPrev: options?.hasPrev ?? false, hasPrev: options?.hasPrev ?? false,
prevPage: vi.fn(), prevPage: vi.fn(),
nextPage: vi.fn(), nextPage: vi.fn(),
@ -430,9 +443,10 @@ export const MockUseDataApiUtils = {
items: [], items: [],
total: 0, total: 0,
page: 1, page: 1,
loading: false, isLoading: false,
isRefreshing: false,
error: undefined, error: undefined,
hasMore: false, hasNext: false,
hasPrev: false, hasPrev: false,
prevPage: vi.fn(), prevPage: vi.fn(),
nextPage: vi.fn(), nextPage: vi.fn(),