Created
January 9, 2026 10:54
-
-
Save zbeyens/bbea784e01cbb4780c94a9f820cef861 to your computer and use it in GitHub Desktop.
Convex infinite query
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** biome-ignore-all lint/suspicious/noExplicitAny: Convex query/mutation type compatibility */ | |
| import type { PaginatedQueryItem, PaginatedQueryReference } from 'convex/react'; | |
| import type { FunctionReference } from 'convex/server'; | |
| import { getFunctionName } from 'convex/server'; | |
| import { useEffect, useMemo } from 'react'; | |
| import type { PartialDeep } from 'type-fest'; | |
| import { CRPCClientError, isCRPCClientError } from '../crpc/error'; | |
| import type { ConvexInfiniteQueryOptions } from '../crpc/types'; | |
| import { useAuthValue } from './auth-store'; | |
| import { useConvexInfiniteQuery as useInfiniteQueryInternal } from './use-infinite-query'; | |
| type PaginationStatus = | |
| | 'CanLoadMore' | |
| | 'Exhausted' | |
| | 'LoadingFirstPage' | |
| | 'LoadingMore'; | |
| /** Return type for useCRPCInfiniteQuery */ | |
| export type UseCRPCInfiniteQueryResult<T> = { | |
| /** Flattened array of all loaded items */ | |
| data: T[]; | |
| /** First error encountered, if any */ | |
| error: Error | null; | |
| /** Fetch the next page */ | |
| fetchNextPage: (numItems?: number) => void; | |
| /** Whether the query has a next page */ | |
| hasNextPage: boolean; | |
| /** Whether any page has an error */ | |
| isError: boolean; | |
| /** Whether the query is fetching */ | |
| isFetching: boolean; | |
| /** Whether fetching next page failed */ | |
| isFetchNextPageError: boolean; | |
| /** Whether the query is fetching the next page */ | |
| isFetchingNextPage: boolean; | |
| /** Whether the query is loading the first page */ | |
| isLoading: boolean; | |
| /** Whether the data is placeholder data */ | |
| isPlaceholderData: boolean; | |
| /** Whether we're refetching (have data but still fetching) */ | |
| isRefetching: boolean; | |
| /** Whether the query has loaded successfully */ | |
| isSuccess: boolean; | |
| /** Current pagination status */ | |
| status: PaginationStatus; | |
| }; | |
| /** Options for useCRPCInfiniteQuery hook */ | |
| export type UseCRPCInfiniteQueryOptions<T> = { | |
| /** Enable/disable query */ | |
| enabled?: boolean; | |
| /** Placeholder data while loading */ | |
| placeholderData?: PartialDeep<T>[]; | |
| }; | |
| /** | |
| * Infinite query hook using cRPC-style options. | |
| * Accepts options from `crpc.posts.list.infiniteQueryOptions()`. | |
| * | |
| * @example | |
| * ```tsx | |
| * const crpc = useCRPC(); | |
| * const { data, fetchNextPage } = useCRPCInfiniteQuery( | |
| * crpc.posts.list.infiniteQueryOptions({ userId }, { initialNumItems: 20 }) | |
| * ); | |
| * ``` | |
| */ | |
| export function useCRPCInfiniteQuery< | |
| T extends FunctionReference<'query'>, | |
| TItem = T extends PaginatedQueryReference ? PaginatedQueryItem<T> : unknown, | |
| >( | |
| infiniteOptions: ConvexInfiniteQueryOptions<T>, | |
| options?: UseCRPCInfiniteQueryOptions<TItem> | |
| ): UseCRPCInfiniteQueryResult<TItem> { | |
| const onQueryUnauthorized = useAuthValue('onQueryUnauthorized'); | |
| const isAuthLoading = useAuthValue('isLoading'); | |
| const isAuthenticated = useAuthValue('isAuthenticated'); | |
| // Extract metadata from infiniteOptions | |
| const { _infinite } = infiniteOptions; | |
| const { query, args, initialNumItems, authType, skipUnauth, debug } = | |
| _infinite; | |
| // Default skipUnauth to false (throws CRPCClientError) | |
| const skipUnauthFinal = skipUnauth ?? false; | |
| // Auth required but user not authenticated (after auth loads) | |
| // Only check auth if query is enabled (enabled !== false) | |
| const isUnauthorized = | |
| options?.enabled !== false && | |
| authType === 'required' && | |
| !isAuthLoading && | |
| !isAuthenticated; | |
| // Determine if we should skip the query | |
| // Only wait for auth loading on required queries (not optional/public) | |
| const shouldSkip = | |
| (authType === 'required' && isAuthLoading) || | |
| options?.enabled === false || | |
| (authType === 'required' && !isAuthenticated); | |
| // Create error when unauthorized (unless skipUnauth) | |
| // Both cases skip query, but skipUnauth returns empty instead of error | |
| const authError = useMemo(() => { | |
| if (isUnauthorized && !skipUnauthFinal) { | |
| return new CRPCClientError({ | |
| code: 'UNAUTHORIZED', | |
| functionName: getFunctionName(query), | |
| }); | |
| } | |
| return null; | |
| }, [isUnauthorized, skipUnauthFinal, query]); | |
| // Call callback in useEffect (not during render) to avoid setState-in-render | |
| useEffect(() => { | |
| if (isUnauthorized && !skipUnauthFinal) { | |
| onQueryUnauthorized({ queryName: getFunctionName(query) }); | |
| } | |
| }, [isUnauthorized, skipUnauthFinal, query, onQueryUnauthorized]); | |
| const result = useInfiniteQueryInternal(query as any, args as any, { | |
| initialNumItems, | |
| debug, | |
| // Internal hook handles prefetch detection and will bypass skip if data exists | |
| enabled: !shouldSkip, | |
| placeholderData: options?.placeholderData as any, | |
| }); | |
| // Return auth error state when auth required and not skipUnauth | |
| if (authError) { | |
| return { | |
| data: [] as TItem[], | |
| error: authError, | |
| fetchNextPage: () => {}, | |
| hasNextPage: false, | |
| isError: true, | |
| isFetching: false, | |
| isFetchNextPageError: false, | |
| isFetchingNextPage: false, | |
| isLoading: false, | |
| isPlaceholderData: false, | |
| isRefetching: false, | |
| isSuccess: false, | |
| status: 'Exhausted' as const, | |
| }; | |
| } | |
| // Return skipped state when skipUnauth triggers (not placeholder data) | |
| if (skipUnauthFinal && isUnauthorized) { | |
| return { | |
| data: [] as TItem[], | |
| error: null, | |
| fetchNextPage: () => {}, | |
| hasNextPage: false, | |
| isError: false, | |
| isFetching: false, | |
| isFetchNextPageError: false, | |
| isFetchingNextPage: false, | |
| isLoading: false, | |
| isPlaceholderData: false, | |
| isRefetching: false, | |
| isSuccess: false, | |
| status: 'Exhausted' as const, | |
| }; | |
| } | |
| // Include auth loading in loading state for optional and required types | |
| const authLoadingApplies = authType === 'optional' || authType === 'required'; | |
| // Check if we got an auth error | |
| const isAuthError = isCRPCClientError(result.error); | |
| return { | |
| ...result, | |
| data: result.data as TItem[], | |
| isLoading: | |
| (authLoadingApplies && isAuthLoading) || | |
| (!isAuthError && result.isLoading), | |
| }; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** biome-ignore-all lint/suspicious/noExplicitAny: Convex query/mutation type compatibility */ | |
| import { useQueries, useQueryClient } from '@tanstack/react-query'; | |
| import type { | |
| PaginatedQueryArgs, | |
| PaginatedQueryItem, | |
| PaginatedQueryReference, | |
| } from 'convex/react'; | |
| import { getFunctionName, type PaginationResult } from 'convex/server'; | |
| import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | |
| import type { PartialDeep } from 'type-fest'; | |
| import { CRPCClientError, isCRPCClientError } from '../crpc/error'; | |
| import { convexQuery } from '../crpc/query-options'; | |
| import { useAuthSkip } from '../internal/auth'; | |
| import { useAuthValue } from './auth-store'; | |
| import { useMeta } from './context'; | |
| /** | |
| * Pagination state persisted in queryClient. | |
| * Enables scroll restoration when navigating back to a paginated list. | |
| */ | |
| export type PaginationState = { | |
| id: number; | |
| nextPageKey: number; | |
| pageKeys: number[]; | |
| queries: Record< | |
| number, | |
| { | |
| args: Record<string, unknown> & { | |
| paginationOpts: { cursor: string | null; id: number; numItems: number }; | |
| }; | |
| endCursor?: string | null; | |
| } | |
| >; | |
| version: number; | |
| }; | |
| // Query key prefix for pagination state storage | |
| const PAGINATION_KEY_PREFIX = '__pagination__' as const; | |
| // Pagination ID store - persists across mounts for cache reuse | |
| // Key: query name + args, Value: pagination ID | |
| const paginationIdStore = new Map<string, number>(); | |
| let paginationIdCounter = 0; | |
| const getOrCreatePaginationId = (storeKey: string): number => { | |
| const existing = paginationIdStore.get(storeKey); | |
| if (existing !== undefined) { | |
| return existing; | |
| } | |
| const newId = ++paginationIdCounter; | |
| paginationIdStore.set(storeKey, newId); | |
| return newId; | |
| }; | |
| type PaginationStatus = | |
| | 'CanLoadMore' | |
| | 'Exhausted' | |
| | 'LoadingFirstPage' | |
| | 'LoadingMore'; | |
| type PageState = { | |
| args: Record<string, unknown> & { | |
| paginationOpts: { cursor: string | null; id: number; numItems: number }; | |
| }; | |
| endCursor?: string | null; // For page splitting - the cursor where this page ends | |
| }; | |
| // Page splitting: when a page gets too large, Convex may return splitCursor | |
| // - SplitRecommended: page is large, should split on next render | |
| // - SplitRequired: page MUST be split (too large to return) | |
| type PageResultWithSplit<T> = PaginationResult<T> & { | |
| splitCursor?: string | null; | |
| }; | |
| /** | |
| * Infinite query for users using TanStack Query + convexQuery. | |
| * Each page gets: | |
| * - Convex WebSocket subscription (real-time reactivity) | |
| * - TanStack Query retry on timeout errors | |
| */ | |
| const useInfiniteQuery = <Query extends PaginatedQueryReference>( | |
| query: Query, | |
| args: PaginatedQueryArgs<Query>, | |
| options: { | |
| initialNumItems: number; | |
| debug?: boolean; | |
| enabled?: boolean; | |
| placeholderData?: PartialDeep<PaginatedQueryItem<Query>>[]; | |
| } | |
| ): { | |
| /** Flattened array of all loaded items */ | |
| data: PaginatedQueryItem<Query>[]; | |
| /** First error encountered, if any */ | |
| error: Error | null; | |
| /** Fetch the next page */ | |
| fetchNextPage: (numItems?: number) => void; | |
| /** Whether the query has a next page */ | |
| hasNextPage: boolean; | |
| /** Whether any page has an error */ | |
| isError: boolean; | |
| /** Whether the query is fetching */ | |
| isFetching: boolean; | |
| /** Whether fetching next page failed */ | |
| isFetchNextPageError: boolean; | |
| /** Whether the query is fetching the next page */ | |
| isFetchingNextPage: boolean; | |
| /** Whether the query is loading the first page */ | |
| isLoading: boolean; | |
| /** Whether the data is placeholder data */ | |
| isPlaceholderData: boolean; | |
| /** Whether we're refetching (have data but still fetching) */ | |
| isRefetching: boolean; | |
| /** Whether the query has loaded successfully */ | |
| isSuccess: boolean; | |
| /** Current pagination status */ | |
| status: PaginationStatus; | |
| } => { | |
| const isAuthLoading = useAuthValue('isLoading'); | |
| const meta = useMeta(); | |
| const queryClient = useQueryClient(); | |
| // Look up server-prefetched data using server-compatible queryKey | |
| // Server key: ['convexQuery', funcName, { ...args, paginationOpts: { cursor: null, numItems } }] | |
| const prefetchedFirstPage = useMemo(() => { | |
| const serverQueryKey = [ | |
| 'convexQuery', | |
| getFunctionName(query), | |
| { | |
| ...(args as Record<string, unknown>), | |
| paginationOpts: { | |
| cursor: null, | |
| numItems: options.initialNumItems, | |
| }, | |
| }, | |
| ]; | |
| const data = queryClient.getQueryData(serverQueryKey); | |
| if (options.debug && data) { | |
| console.log('[useInfiniteQuery] Found prefetched data:', data); | |
| } | |
| return data ?? null; | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [query, JSON.stringify(args), options.initialNumItems, queryClient, options.debug]); | |
| // Don't skip if we have prefetched data - use it for instant hydration | |
| // Prefetched data bypasses both auth loading AND explicit disabled | |
| const skip = | |
| !prefetchedFirstPage && (isAuthLoading || options.enabled === false); | |
| // Helper to get/set pagination state from queryClient with gcTime: Infinity | |
| const getPaginationState = useCallback( | |
| (key: string): PaginationState | undefined => { | |
| const queryKey = [PAGINATION_KEY_PREFIX, key] as const; | |
| const state = queryClient.getQueryData<PaginationState>(queryKey); | |
| return state; | |
| }, | |
| [queryClient] | |
| ); | |
| const setPaginationState = useCallback( | |
| (key: string, state: PaginationState) => { | |
| const queryKey = [PAGINATION_KEY_PREFIX, key] as const; | |
| queryClient.setQueryData<PaginationState>(queryKey, state); | |
| }, | |
| [queryClient] | |
| ); | |
| const argsObject = useMemo( | |
| () => (skip ? {} : args) as Record<string, unknown>, | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| [skip, JSON.stringify(args)] | |
| ); | |
| // Stable store key for pagination ID persistence across mounts | |
| const storeKey = useMemo( | |
| () => JSON.stringify({ query: getFunctionName(query), args: argsObject }), | |
| [query, argsObject] | |
| ); | |
| // Helper to create initial state | |
| const createInitialState = useCallback((): PaginationState => { | |
| const id = getOrCreatePaginationId(storeKey); | |
| return { | |
| id, | |
| nextPageKey: 1, | |
| pageKeys: skip ? [] : [0], | |
| queries: skip | |
| ? {} | |
| : { | |
| 0: { | |
| args: { | |
| ...argsObject, | |
| paginationOpts: { | |
| cursor: null, | |
| id, | |
| numItems: options.initialNumItems, | |
| }, | |
| }, | |
| }, | |
| }, | |
| version: 0, | |
| }; | |
| }, [storeKey, skip, argsObject, options.initialNumItems]); | |
| // Track previous args to detect changes (in effect, not during render) | |
| const prevArgsRef = useRef<{ storeKey: string; skip: boolean } | null>(null); | |
| // State: tracks pages with cursors (mirrors Convex's usePaginatedQuery) | |
| // Check queryClient first for state persistence across navigations | |
| const [state, setLocalState] = useState<PaginationState>(() => { | |
| if (skip) { | |
| return { id: 0, nextPageKey: 1, pageKeys: [], queries: {}, version: 0 }; | |
| } | |
| // Try to restore from queryClient (enables scroll restoration) | |
| const existingState = getPaginationState(storeKey); | |
| if (existingState) { | |
| return existingState; | |
| } | |
| const initial = createInitialState(); | |
| return initial; | |
| }); | |
| // Sync state changes to queryClient for persistence across navigations | |
| const setState = useCallback( | |
| ( | |
| updater: PaginationState | ((prev: PaginationState) => PaginationState) | |
| ) => { | |
| setLocalState((prev) => { | |
| const newState = | |
| typeof updater === 'function' ? updater(prev) : updater; | |
| setPaginationState(storeKey, newState); | |
| return newState; | |
| }); | |
| }, | |
| [storeKey, setPaginationState] | |
| ); | |
| // Handle initialization and args changes | |
| // This effect initializes state when skip becomes false, or resets when args change | |
| useEffect(() => { | |
| const prev = prevArgsRef.current; | |
| const isFirstRun = prev === null; | |
| const argsChanged = | |
| prev !== null && (prev.storeKey !== storeKey || prev.skip !== skip); | |
| const skipBecameFalse = prev?.skip && !skip; | |
| // Update ref for next render | |
| prevArgsRef.current = { storeKey, skip }; | |
| // Skip state - don't initialize | |
| if (skip) { | |
| return; | |
| } | |
| // First run with skip=false: state was initialized in useState, sync to queryClient | |
| if (isFirstRun) { | |
| setPaginationState(storeKey, state); | |
| return; | |
| } | |
| // Skip just became false (auth loaded): initialize state | |
| if (skipBecameFalse) { | |
| // Try to restore from queryClient first | |
| const existingState = getPaginationState(storeKey); | |
| if (existingState) { | |
| setLocalState(existingState); | |
| return; | |
| } | |
| // Create new initial state | |
| const newState = createInitialState(); | |
| setLocalState(newState); | |
| setPaginationState(storeKey, newState); | |
| return; | |
| } | |
| // Args changed (different query/args): reset state | |
| if (argsChanged) { | |
| // Try to restore from queryClient first (for back navigation) | |
| const existingState = getPaginationState(storeKey); | |
| if (existingState) { | |
| setLocalState(existingState); | |
| return; | |
| } | |
| // Create new initial state | |
| const newState = createInitialState(); | |
| setLocalState(newState); | |
| setPaginationState(storeKey, newState); | |
| } | |
| }, [ | |
| skip, | |
| storeKey, | |
| state, | |
| createInitialState, | |
| getPaginationState, | |
| setPaginationState, | |
| ]); | |
| // Reset pagination on InvalidCursor error (data changed, cursors invalid) | |
| const initialNumItems = options.initialNumItems; | |
| const resetPagination = useCallback(() => { | |
| // Invalidate old pagination ID by removing from store | |
| paginationIdStore.delete(storeKey); | |
| const newId = getOrCreatePaginationId(storeKey); | |
| setState((prev) => ({ | |
| id: newId, | |
| nextPageKey: 1, | |
| pageKeys: [0], | |
| queries: { | |
| 0: { | |
| args: { | |
| ...argsObject, | |
| paginationOpts: { | |
| cursor: null, | |
| id: newId, | |
| numItems: initialNumItems, | |
| }, | |
| }, | |
| }, | |
| }, | |
| version: prev.version + 1, | |
| })); | |
| }, [storeKey, argsObject, initialNumItems]); | |
| // Build TanStack queries from state (each page = separate convexQuery) | |
| // structuralSharing: false ensures Convex WebSocket updates trigger re-renders | |
| const tanstackQueries = useMemo( | |
| () => | |
| state.pageKeys.map((key, index) => ({ | |
| ...convexQuery( | |
| query, | |
| (state.queries[key]?.args ?? 'skip') as any, | |
| meta | |
| ), | |
| enabled: !skip && !!state.queries[key], | |
| structuralSharing: false, | |
| // Use server-prefetched data for first page (hydration) | |
| ...(index === 0 && prefetchedFirstPage | |
| ? { initialData: prefetchedFirstPage } | |
| : {}), | |
| })), | |
| [ | |
| query, | |
| state.pageKeys, | |
| state.queries, | |
| skip, | |
| meta, | |
| prefetchedFirstPage, | |
| ] | |
| ); | |
| // Use combine to aggregate all page states in one place | |
| const combined = useQueries({ | |
| queries: tanstackQueries as any, | |
| combine: (results) => { | |
| // Aggregate pages with deduplication | |
| const allItems: PaginatedQueryItem<Query>[] = []; | |
| const seenIds = new Set<string>(); | |
| let lastPage: PageResultWithSplit<PaginatedQueryItem<Query>> | undefined; | |
| let paginationStatus: PaginationStatus = 'LoadingFirstPage'; | |
| for (let i = 0; i < results.length; i++) { | |
| const pageQuery = results[i]; | |
| if (pageQuery.isLoading || pageQuery.data === undefined) { | |
| paginationStatus = i === 0 ? 'LoadingFirstPage' : 'LoadingMore'; | |
| break; | |
| } | |
| const page = pageQuery.data as PageResultWithSplit< | |
| PaginatedQueryItem<Query> | |
| >; | |
| lastPage = page; | |
| for (const item of page.page) { | |
| const id = | |
| (item as { _id?: string })._id || (item as { id?: string }).id; | |
| if (id && seenIds.has(id)) continue; | |
| if (id) seenIds.add(id); | |
| allItems.push(item); | |
| } | |
| paginationStatus = page.isDone ? 'Exhausted' : 'CanLoadMore'; | |
| } | |
| // Reuse TanStack Query states directly | |
| const isPlaceholderData = results[0]?.isPlaceholderData ?? false; | |
| const isFetching = results.some((r) => r.isFetching); | |
| // Track dataUpdatedAt to force re-computation on Convex WebSocket updates | |
| const dataUpdatedAt = results.map((r) => r.dataUpdatedAt ?? 0).join('|'); | |
| return { | |
| // Aggregated data | |
| data: allItems, | |
| dataUpdatedAt, | |
| lastPage, | |
| status: paginationStatus, | |
| // TanStack Query states - reuse directly | |
| error: results.find((r) => r.isError)?.error ?? null, | |
| isError: results.some((r) => r.isError), | |
| isFetching, | |
| isFetchNextPageError: | |
| results.length > 1 && (results.at(-1)?.isError ?? false), | |
| isLoading: results[0]?.isLoading ?? false, | |
| isPending: results[0]?.isPending ?? true, | |
| isPlaceholderData, | |
| isRefetching: isFetching && allItems.length > 0 && !isPlaceholderData, | |
| isSuccess: results.length > 0 && (results[0]?.isSuccess ?? false), | |
| // Keep raw results for effects (InvalidCursor detection, page splitting) | |
| _rawResults: results, | |
| }; | |
| }, | |
| }); | |
| // Check for InvalidCursor errors and reset if needed | |
| useEffect(() => { | |
| for (const pageQuery of combined._rawResults) { | |
| if (pageQuery.error) { | |
| const errorMessage = | |
| pageQuery.error instanceof Error ? pageQuery.error.message : ''; | |
| if (errorMessage.includes('InvalidCursor')) { | |
| resetPagination(); | |
| return; | |
| } | |
| } | |
| } | |
| }, [combined._rawResults, resetPagination]); | |
| // Handle page splitting - when a page returns splitCursor, we need to split it | |
| useEffect(() => { | |
| for (let i = 0; i < combined._rawResults.length; i++) { | |
| const pageQuery = combined._rawResults[i]; | |
| if (pageQuery.data) { | |
| const page = pageQuery.data as PageResultWithSplit< | |
| PaginatedQueryItem<Query> | |
| >; | |
| const pageKey = state.pageKeys[i]; | |
| const pageState = state.queries[pageKey]; | |
| // Check if this page needs splitting and we haven't already split it | |
| if (page.splitCursor && pageState && !pageState.endCursor) { | |
| setState((prev) => { | |
| const currentPageState = prev.queries[pageKey]; | |
| if (!currentPageState || currentPageState.endCursor) return prev; | |
| const newKey = prev.nextPageKey; | |
| const splitCursor = page.splitCursor!; // Checked above: page.splitCursor is truthy | |
| const splitPageArgs = { | |
| ...argsObject, | |
| paginationOpts: { | |
| cursor: splitCursor, | |
| id: prev.id, | |
| numItems: currentPageState.args.paginationOpts.numItems, | |
| }, | |
| }; | |
| // Insert new page after the split page | |
| const pageKeyIndex = prev.pageKeys.indexOf(pageKey); | |
| const newPageKeys = [...prev.pageKeys]; | |
| newPageKeys.splice(pageKeyIndex + 1, 0, newKey); | |
| return { | |
| ...prev, | |
| nextPageKey: newKey + 1, | |
| pageKeys: newPageKeys, | |
| queries: { | |
| ...prev.queries, | |
| // Mark current page with its end cursor | |
| [pageKey]: { | |
| ...currentPageState, | |
| endCursor: splitCursor, | |
| }, | |
| // Add the new split page | |
| [newKey]: { | |
| args: splitPageArgs, | |
| }, | |
| } as Record<number, PageState>, | |
| }; | |
| }); | |
| return; // Only handle one split per render | |
| } | |
| } | |
| } | |
| }, [combined._rawResults, state.pageKeys, state.queries, argsObject]); | |
| // loadMore: add new page to state | |
| const loadMore = useCallback( | |
| (numItems: number) => { | |
| if ( | |
| combined.status !== 'CanLoadMore' || | |
| !combined.lastPage?.continueCursor | |
| ) | |
| return; | |
| setState((prev) => { | |
| const newKey = prev.nextPageKey; | |
| return { | |
| ...prev, | |
| nextPageKey: newKey + 1, | |
| pageKeys: [...prev.pageKeys, newKey], | |
| queries: { | |
| ...prev.queries, | |
| [newKey]: { | |
| args: { | |
| ...argsObject, | |
| paginationOpts: { | |
| cursor: combined.lastPage!.continueCursor, | |
| id: prev.id, | |
| numItems, | |
| }, | |
| }, | |
| }, | |
| }, | |
| }; | |
| }); | |
| }, | |
| [combined.status, combined.lastPage?.continueCursor, argsObject] | |
| ); | |
| // Apply placeholder data if provided and first page is pending (no data yet) | |
| const showPlaceholder = !!options?.placeholderData && combined.isPending; | |
| const data = showPlaceholder | |
| ? (options.placeholderData as PaginatedQueryItem<Query>[]) | |
| : combined.data; | |
| return { | |
| data, | |
| error: combined.error instanceof Error ? combined.error : null, | |
| fetchNextPage: (numItems?: number) => | |
| loadMore(numItems ?? options.initialNumItems), | |
| hasNextPage: combined.status === 'CanLoadMore', | |
| isError: combined.isError, | |
| isFetching: combined.isFetching, | |
| isFetchNextPageError: combined.isFetchNextPageError, | |
| isFetchingNextPage: combined.status === 'LoadingMore', | |
| isLoading: combined.isLoading, | |
| isPlaceholderData: showPlaceholder, | |
| isRefetching: combined.isRefetching, | |
| isSuccess: combined.isSuccess, | |
| status: combined.status, | |
| }; | |
| }; | |
| /** | |
| * Unified infinite query hook with auto-detected auth behavior. | |
| * Auth type is determined from generated metadata based on the function's builder. | |
| */ | |
| export const useConvexInfiniteQuery = <Query extends PaginatedQueryReference>( | |
| query: Query, | |
| args: PaginatedQueryArgs<Query>, | |
| options: { | |
| initialNumItems: number; | |
| debug?: boolean; | |
| enabled?: boolean; | |
| placeholderData?: PartialDeep<PaginatedQueryItem<Query>>[]; | |
| /** Skip query silently when unauthenticated (default: false, throws CRPCClientError) */ | |
| skipUnauth?: boolean; | |
| } | |
| ) => { | |
| const onQueryUnauthorized = useAuthValue('onQueryUnauthorized'); | |
| const { authType, isAuthenticated, isAuthLoading, shouldSkip } = useAuthSkip( | |
| query, | |
| { enabled: options?.enabled, skipUnauth: options?.skipUnauth } | |
| ); | |
| // Default skipUnauth to false (throws CRPCClientError) | |
| const skipUnauth = options?.skipUnauth ?? false; | |
| // Auth required but user not authenticated (after auth loads) | |
| // Only check auth if query is enabled (enabled !== false) | |
| const isUnauthorized = | |
| options.enabled !== false && | |
| authType === 'required' && | |
| !isAuthLoading && | |
| !isAuthenticated; | |
| // Create error when unauthorized (unless skipUnauth) | |
| // Both cases skip query, but skipUnauth returns empty instead of error | |
| const authError = useMemo(() => { | |
| if (isUnauthorized && !skipUnauth) { | |
| return new CRPCClientError({ | |
| code: 'UNAUTHORIZED', | |
| functionName: getFunctionName(query), | |
| }); | |
| } | |
| return null; | |
| }, [isUnauthorized, skipUnauth, query]); | |
| // Call callback in useEffect (not during render) to avoid setState-in-render | |
| useEffect(() => { | |
| if (isUnauthorized && !skipUnauth) { | |
| onQueryUnauthorized({ queryName: getFunctionName(query) }); | |
| } | |
| }, [isUnauthorized, skipUnauth, query, onQueryUnauthorized]); | |
| const result = useInfiniteQuery(query, args as any, { | |
| ...options, | |
| // Internal hook handles prefetch detection and will bypass skip if data exists | |
| enabled: !shouldSkip, | |
| }); | |
| // Return auth error state when auth required and not skipUnauth | |
| if (authError) { | |
| return { | |
| data: [] as PaginatedQueryItem<Query>[], | |
| error: authError, | |
| fetchNextPage: () => {}, | |
| hasNextPage: false, | |
| isError: true, | |
| isFetching: false, | |
| isFetchNextPageError: false, | |
| isFetchingNextPage: false, | |
| isLoading: false, | |
| isPlaceholderData: false, | |
| isRefetching: false, | |
| isSuccess: false, | |
| status: 'Exhausted' as const, | |
| }; | |
| } | |
| // Return skipped state when skipUnauth triggers (not placeholder data) | |
| if (skipUnauth && isUnauthorized) { | |
| return { | |
| data: [] as PaginatedQueryItem<Query>[], | |
| error: null, | |
| fetchNextPage: () => {}, | |
| hasNextPage: false, | |
| isError: false, | |
| isFetching: false, | |
| isFetchNextPageError: false, | |
| isFetchingNextPage: false, | |
| isLoading: false, | |
| isPlaceholderData: false, | |
| isRefetching: false, | |
| isSuccess: false, | |
| status: 'Exhausted' as const, | |
| }; | |
| } | |
| // Include auth loading in loading state for optional and required types | |
| const authLoadingApplies = authType === 'optional' || authType === 'required'; | |
| // Check if we got an auth error | |
| const isAuthError = isCRPCClientError(result.error); | |
| return { | |
| ...result, | |
| isLoading: | |
| (authLoadingApplies && isAuthLoading) || | |
| (!isAuthError && result.isLoading), | |
| }; | |
| }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment