UNPKG

@tanstack/react-db

Version:

React integration for @tanstack/db

318 lines (297 loc) • 11 kB
'use client' import { useCallback, useRef, useSyncExternalStore } from 'react' import { bindLiveQueryWindow, createLiveQuerySourceIdClaims, fetchNextLiveQueryWindowPage, getLiveQueryKey, isCollection, readLiveQueryWindowPageShape, rejectLiveQueryInput, releaseDeferredOnReclaim, resumeDeferredLiveQueryCollections, } from '@tanstack/db' import { useOptionalDbClient } from './DbProvider' import { getIdentityScope, prepareDerivedQuery, prepareQueryValue, } from './useLiveQuery' import type { DerivedIdentityProfiler, LiveQueryKey, useLiveQuery, } from './useLiveQuery' import type { Collection, CollectionImpl as CollectionImplType, CollectionStatus, Context, DbClient, InferResultType, InitialQueryBuilder, LiveQuerySourceIdClaims, LiveQueryStatusFlags, LiveQueryWindowBinding, NonSingleResult, QueryBuilder, } from '@tanstack/db' const unpreparedQueryValue = Symbol(`unpreparedQueryValue`) // Keep the generic parameter for existing typed config wrappers. export type UseLiveInfiniteQueryConfig<_TContext extends Context> = { /** * Explicit identity for queries that contain opaque functional variants or * are hot enough that deriving identity from structured IR is too expensive. * Structured queries should omit this so DB can derive identity directly. */ queryKey?: LiveQueryKey /** Override the nearest DbProvider for this query. */ client?: DbClient pageSize?: number /** First result-page label, not a server cursor or remote offset. */ initialPageParam?: number } 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 } export type UseLiveInfiniteQueryReturnWithCollection< TResult extends object, TKey extends string | number, TUtils extends Record<string, any>, > = LiveQueryStatusFlags & { data: Array<TResult> state: Map<TKey, TResult> collection: Collection<TResult, TKey, TUtils> & NonSingleResult status: CollectionStatus isEnabled: true pages: Array<Array<TResult>> pageParams: Array<number> fetchNextPage: () => Promise<void> hasNextPage: boolean isFetchingNextPage: boolean } type EnabledLiveQueryReturn<TContext extends Context> = ReturnType< typeof useLiveQuery<TContext> > /** One render's window binding and what its commit still owes. */ type InfiniteQueryRender = { binding: LiveQueryWindowBinding /** The query callback whose identity this render derived, if any. */ derivedQuery: unknown deferredCollections: Set< CollectionImplType<any, string | number, any, any, any> > /** Drops the reclaim release when this render commits. */ cancelReclaimRelease: (() => void) | undefined 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 an optional initial page label * @returns Object with pages, data, and pagination controls */ // 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>, ): UseLiveInfiniteQueryReturnWithCollection<TResult, TKey, TUtils> // Overload for query function export function useLiveInfiniteQuery<TContext extends Context>( queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>, config: UseLiveInfiniteQueryConfig<TContext>, ): UseLiveInfiniteQueryReturn<TContext> // Implementation export function useLiveInfiniteQuery<TContext extends Context>( queryFnOrCollection: any, config: UseLiveInfiniteQueryConfig<TContext>, ): UseLiveInfiniteQueryReturn<TContext> { const pageShape = readLiveQueryWindowPageShape(config) const contextDbClient = useOptionalDbClient() const dbClient = config.client ?? contextDbClient const inputIsCollection = isCollection(queryFnOrCollection) const committedRef = useRef<InfiniteQueryRender | null>(null) const committed = committedRef.current // Like useLiveQuery's instance memo, keep the latest render whose binding // built a window collection. A second render before commit, such as a // StrictMode double render, reuses that collection instead of starting // another. Each render still builds its own controller and page count from // the committed binding, which an uncommitted render never changes. const renderedRef = useRef<InfiniteQueryRender | null>(null) const sourceIdsRef = useRef<LiveQuerySourceIdClaims | null>(null) const derivedIdentityProfilerRef = useRef<DerivedIdentityProfiler>({ renderCount: 0, totalMs: 0, maxMs: 0, warned: false, }) const deferredCollections = new Set< CollectionImplType<any, string | number, any, any, any> >() const identityScope = () => getIdentityScope( (sourceIdsRef.current ??= createLiveQuerySourceIdClaims()), dbClient, `useLiveInfiniteQuery`, ) const queryKey = getLiveQueryKey(config) let preparedQueryValue: unknown | typeof unpreparedQueryValue = unpreparedQueryValue let dependencies: ReadonlyArray<unknown> | undefined let derivedQuery: unknown = undefined const rendered = renderedRef.current let binding: LiveQueryWindowBinding // The render boundary: a render that throws anywhere after preparation, // such as an unhashable, single-result, or uncompilable query, never // commits, so it releases the sync starts it deferred here. try { if (!inputIsCollection) { if (queryKey !== undefined) { dependencies = queryKey } else if ( committed !== null && committed.derivedQuery === queryFnOrCollection && committed.binding.scope === dbClient ) { // The same callback in the same client scope keeps its identity. derivedQuery = queryFnOrCollection dependencies = committed.binding.dependencies } else { const preparation = prepareDerivedQuery( queryFnOrCollection, derivedIdentityProfilerRef.current, identityScope(), deferredCollections, ) preparedQueryValue = preparation.value derivedQuery = queryFnOrCollection dependencies = preparation.identityDeps } } // The binding calls this only when it compiles a new window collection. const compileQuery = () => { if (preparedQueryValue === unpreparedQueryValue) { preparedQueryValue = prepareQueryValue( queryFnOrCollection, dbClient, deferredCollections, ) } return preparedQueryValue } binding = bindLiveQueryWindow( committed?.binding ?? null, { ...pageShape, input: typeof queryFnOrCollection === `function` ? compileQuery : queryFnOrCollection, dependencies, scope: dbClient, client: dbClient, queryKey, recompile: `identity`, }, rendered?.binding, ) } catch (error) { rejectLiveQueryInput(deferredCollections, error) } let currentRenderState = committed if (binding !== committed?.binding) { const newCollection = binding.collection !== committed?.binding.collection if (newCollection && binding.collection === rendered?.binding.collection) { // Sources the earlier render deferred still resume at this commit. for (const deferred of rendered.deferredCollections) { deferredCollections.add(deferred) } } const reusesRendered = newCollection && binding.collection === rendered?.binding.collection currentRenderState = { binding, derivedQuery, deferredCollections, // A render that never commits releases its deferred sync starts when // garbage collection reclaims the window collection it built. A reused // collection keeps the release its first render registered. cancelReclaimRelease: reusesRendered ? rendered.cancelReclaimRelease : newCollection && binding.ownsCollection ? releaseDeferredOnReclaim(binding.collection, deferredCollections) : undefined, warned: false, } // Keep a created collection for the next render only once it validated // and started, so a retry after a startup error builds a new one. if (binding.supplied) renderedRef.current = null else if (newCollection && binding.ownsCollection) { renderedRef.current = currentRenderState } } const renderState = currentRenderState! const controller = renderState.binding.controller const subscribe = useCallback( (onStoreChange: () => void) => { const unsubscribe = controller.subscribe(onStoreChange) committedRef.current = renderState if (renderState.binding.warning && !renderState.warned) { renderState.warned = true console.warn(renderState.binding.warning) } // A commit releases the starts itself, so it drops the reclaim release. renderState.cancelReclaimRelease?.() resumeDeferredLiveQueryCollections(renderState.deferredCollections) return unsubscribe }, [controller, renderState], ) const getSnapshot = useCallback(() => controller.getSnapshot(), [controller]) const snapshot = useSyncExternalStore(subscribe, getSnapshot, () => controller.getServerSnapshot(), ) const fetchNextPage = useCallback( () => fetchNextLiveQueryWindowPage(controller), [controller], ) return { data: snapshot.data as InferResultType<TContext>, get state() { return snapshot.state as EnabledLiveQueryReturn<TContext>[`state`] }, status: snapshot.status as EnabledLiveQueryReturn<TContext>[`status`], isLoading: snapshot.isLoading, isReady: snapshot.isReady, isIdle: snapshot.isIdle, isLoadingError: snapshot.isLoadingError, isCrashed: snapshot.isCrashed, isCleanedUp: snapshot.isCleanedUp, isSyncError: snapshot.isSyncError, collection: snapshot.collection as EnabledLiveQueryReturn<TContext>[`collection`], isEnabled: snapshot.isEnabled, pages: snapshot.pages as Array<Array<InferResultType<TContext>[number]>>, pageParams: snapshot.pageParams as Array<number>, fetchNextPage, hasNextPage: snapshot.hasNextPage, isFetchingNextPage: snapshot.isFetchingNextPage, health: snapshot.health, } }