UNPKG

@tanstack/react-db

Version:

React integration for @tanstack/db

299 lines (278 loc) 10 kB
import { useCallback, useRef, useSyncExternalStore } from 'react' import { assertLiveQueryWindowManyResult, compareLiveQueryWindowDependencies, createLiveQueryCollection, createLiveQueryWindowController, fetchNextLiveQueryWindowPage, getLiveQueryWindowCollectionWarning, getLiveQueryWindowInputKind, normalizeLiveQueryWindowPageSize, resolveLiveQueryWindowInput, shouldPreserveLiveQueryWindowPageCount, } from '@tanstack/db' // Type-only: used in `ReturnType<typeof useLiveQuery>` in UseLiveInfiniteQueryReturn. import type { useLiveQuery } from './useLiveQuery' import type { Collection, Context, InferResultType, InitialQueryBuilder, LiveQueryWindowController, NonSingleResult, QueryBuilder, } from '@tanstack/db' // Live queries created here are cleaned up immediately (0 disables GC). const DEFAULT_GC_TIME_MS = 1 export type UseLiveInfiniteQueryConfig<TContext extends Context> = { pageSize?: number initialPageParam?: number /** * @deprecated This callback is not used by the current implementation. * Pagination is determined internally via a peek-ahead strategy. * Provided for API compatibility with TanStack Query conventions. */ getNextPageParam?: ( lastPage: Array<InferResultType<TContext>[number]>, allPages: Array<Array<InferResultType<TContext>[number]>>, lastPageParam: number, allPageParams: Array<number>, ) => number | undefined } export type UseLiveInfiniteQueryReturn<TContext extends Context> = Omit< ReturnType<typeof useLiveQuery<TContext>>, `data` > & { data: InferResultType<TContext> pages: Array<Array<InferResultType<TContext>[number]>> pageParams: Array<number> fetchNextPage: () => Promise<void> hasNextPage: boolean isFetchingNextPage: boolean error: unknown } type EnabledLiveQueryReturn<TContext extends Context> = ReturnType< typeof useLiveQuery<TContext> > type InfiniteQueryRenderState = { inputKind: `collection` | `query` inputCollection: Collection<any, any, any> | null dependencies: Array<unknown> | null pageSize: number initialPageParam: number collection: Collection<any, any, any> controller: LiveQueryWindowController<any, any> warning: string | null warned: boolean } /** * Create an infinite query using a query function with live updates * * Uses `utils.setWindow()` to dynamically adjust the limit/offset window * without recreating the live query collection on each page change. * * @param queryFn - Query function that defines what data to fetch. Must include `.orderBy()` for setWindow to work. * @param config - Configuration including pageSize and getNextPageParam * @param deps - Array of dependencies that trigger query re-execution when changed * @returns Object with pages, data, and pagination controls * * @example * // Basic infinite query * const { data, pages, fetchNextPage, hasNextPage } = useLiveInfiniteQuery( * (q) => q * .from({ posts: postsCollection }) * .orderBy(({ posts }) => posts.createdAt, 'desc') * .select(({ posts }) => ({ * id: posts.id, * title: posts.title * })), * { * pageSize: 20, * getNextPageParam: (lastPage, allPages) => * lastPage.length === 20 ? allPages.length : undefined * } * ) * * @example * // With dependencies * const { pages, fetchNextPage } = useLiveInfiniteQuery( * (q) => q * .from({ posts: postsCollection }) * .where(({ posts }) => eq(posts.category, category)) * .orderBy(({ posts }) => posts.createdAt, 'desc'), * { * pageSize: 10, * getNextPageParam: (lastPage) => * lastPage.length === 10 ? lastPage.length : undefined * }, * [category] * ) * * @example * // Router loader pattern with pre-created collection * // In loader: * const postsQuery = createLiveQueryCollection({ * query: (q) => q * .from({ posts: postsCollection }) * .orderBy(({ posts }) => posts.createdAt, 'desc') * .limit(20) * }) * await postsQuery.preload() * return { postsQuery } * * // In component: * const { postsQuery } = useLoaderData() * const { data, fetchNextPage, hasNextPage } = useLiveInfiniteQuery( * postsQuery, * { * pageSize: 20, * getNextPageParam: (lastPage) => lastPage.length === 20 ? lastPage.length : undefined * } * ) */ // Overload for pre-created collection (non-single result) export function useLiveInfiniteQuery< TResult extends object, TKey extends string | number, TUtils extends Record<string, any>, >( liveQueryCollection: Collection<TResult, TKey, TUtils> & NonSingleResult, config: UseLiveInfiniteQueryConfig<any>, ): UseLiveInfiniteQueryReturn<any> // Overload for query function export function useLiveInfiniteQuery<TContext extends Context>( queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>, config: UseLiveInfiniteQueryConfig<TContext>, deps?: Array<unknown>, ): UseLiveInfiniteQueryReturn<TContext> // Implementation export function useLiveInfiniteQuery<TContext extends Context>( queryFnOrCollection: any, config: UseLiveInfiniteQueryConfig<TContext>, deps: Array<unknown> = [], ): UseLiveInfiniteQueryReturn<TContext> { const pageSize = normalizeLiveQueryWindowPageSize(config.pageSize) const initialPageParam = config.initialPageParam ?? 0 const inputIsCollection = getLiveQueryWindowInputKind(queryFnOrCollection) === `collection` const committedRef = useRef<InfiniteQueryRenderState | null>(null) const committed = committedRef.current const inputKind = inputIsCollection ? `collection` : `query` const dependencyComparison = compareLiveQueryWindowDependencies( committed?.dependencies, deps, ) const dependenciesChanged = !inputIsCollection && dependencyComparison.changed const dependenciesStructurallyEqual = !inputIsCollection && dependencyComparison.structurallyEqual const needsNewCollection = committed === null || committed.inputKind !== inputKind || (inputIsCollection && committed.inputCollection !== queryFnOrCollection) || dependenciesChanged const pageShapeChanged = committed === null || committed.pageSize !== pageSize || committed.initialPageParam !== initialPageParam const needsNewController = committed === null || needsNewCollection || pageShapeChanged let renderState = committed if (needsNewController) { let collection = committed?.collection let warning: string | null = null if (needsNewCollection) { const input = resolveLiveQueryWindowInput<TContext>(queryFnOrCollection) if (input.kind === `collection`) { collection = input.collection } else { // Wrap the query with the first page's peek-ahead window; the controller // grows the limit from here via setWindow. collection = createLiveQueryCollection({ query: input.query.limit(pageSize + 1).offset(0), // Construction happens during render. Synchronization starts only when // useSyncExternalStore commits the controller subscription. startSync: false, gcTime: DEFAULT_GC_TIME_MS, }) } } if (!collection) { throw new Error(`useLiveInfiniteQuery: Failed to create a collection.`) } if (inputIsCollection) { warning = getLiveQueryWindowCollectionWarning(collection, pageSize + 1) ?? null } else { assertLiveQueryWindowManyResult(collection) } const canPreservePageCount = shouldPreserveLiveQueryWindowPageCount({ hasPreviousController: committed !== null, previousInputKind: committed?.inputKind, inputKind, sameCollection: inputIsCollection && committed?.inputCollection === collection, dependenciesChanged, dependenciesStructurallyEqual, pageShapeChanged, }) const previousPageCount = committed ? Math.max(1, committed.controller.getSnapshot().pages.length) : 1 const initialPageCount = canPreservePageCount ? previousPageCount : 1 renderState = { inputKind, inputCollection: inputIsCollection ? collection : null, dependencies: inputIsCollection ? null : [...deps], pageSize, initialPageParam, collection, controller: createLiveQueryWindowController(collection, { pageSize, initialPageParam, initialPageCount, }), warning, warned: false, } } const currentRenderState = renderState! const controller = currentRenderState.controller const subscribe = useCallback( (onStoreChange: () => void) => { const unsubscribe = controller.subscribe(onStoreChange) committedRef.current = currentRenderState if (currentRenderState.warning && !currentRenderState.warned) { currentRenderState.warned = true console.warn(currentRenderState.warning) } return unsubscribe }, [controller, currentRenderState], ) const getSnapshot = useCallback(() => controller.getSnapshot(), [controller]) const snapshot = useSyncExternalStore(subscribe, getSnapshot) const fetchNextPage = useCallback( () => fetchNextLiveQueryWindowPage(controller), [controller], ) return { data: snapshot.data as InferResultType<TContext>, state: snapshot.state as EnabledLiveQueryReturn<TContext>[`state`], status: snapshot.status as EnabledLiveQueryReturn<TContext>[`status`], isLoading: snapshot.isLoading, isReady: snapshot.isReady, isIdle: snapshot.isIdle, isError: snapshot.isError, isCleanedUp: snapshot.isCleanedUp, collection: snapshot.collection as EnabledLiveQueryReturn<TContext>[`collection`], isEnabled: snapshot.isEnabled as EnabledLiveQueryReturn<TContext>[`isEnabled`], pages: snapshot.pages as Array<Array<InferResultType<TContext>[number]>>, pageParams: snapshot.pageParams as Array<number>, fetchNextPage, hasNextPage: snapshot.hasNextPage, isFetchingNextPage: snapshot.isFetchingNextPage, error: snapshot.error, } }