@tanstack/react-db
Version:
React integration for @tanstack/db
318 lines (297 loc) • 11 kB
text/typescript
'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,
}
}