@tanstack/react-db
Version:
React integration for @tanstack/db
476 lines (448 loc) • 16.6 kB
text/typescript
'use client'
import { useCallback, useRef, useSyncExternalStore } from 'react'
import {
assertLiveQueryWindowManyResult,
compareLiveQueryWindowDependencies,
createLiveQueryCollection,
createLiveQueryWindowController,
fetchNextLiveQueryWindowPage,
getLiveQueryWindowCollectionWarning,
getLiveQueryWindowInputKind,
liveQueryWindowMatches,
normalizeLiveQueryWindowPageSize,
resolveLiveQueryWindowInput,
shouldPreserveLiveQueryWindowPageCount,
} from '@tanstack/db'
import { useOptionalDbClient } from './DbProvider'
import {
claimSourceIds,
createSourceIdBindings,
resumeSyncStarts,
} from './source-id-bindings'
import {
prepareDerivedQuery,
prepareQueryValue,
warnDeprecatedDepsArray,
warnUnhashableDerivedIdentity,
} from './useLiveQuery'
import type { SourceIdBindings } from './source-id-bindings'
import type {
DerivedIdentityProfiler,
LiveQueryKey,
useLiveQuery,
} from './useLiveQuery'
import type {
Collection,
CollectionImpl as CollectionImplType,
CollectionStatus,
Context,
DbClient,
InferResultType,
InitialQueryBuilder,
LiveQueryPersistedStatus,
LiveQueryWindowController,
NonSingleResult,
QueryBuilder,
} from '@tanstack/db'
// Live queries created here are cleaned up immediately (0 disables GC).
const DEFAULT_GC_TIME_MS = 1
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
error: unknown
}
export type UseLiveInfiniteQueryReturnWithCollection<
TResult extends object,
TKey extends string | number,
TUtils extends Record<string, any>,
> = {
data: Array<TResult>
state: Map<TKey, TResult>
collection: Collection<TResult, TKey, TUtils> & NonSingleResult
status: CollectionStatus
isLoading: boolean
isReady: boolean
persistedStatus: LiveQueryPersistedStatus
isPersistedReady: boolean
persistedError: unknown | undefined
isIdle: boolean
isError: boolean
isCleanedUp: boolean
isEnabled: true
pages: Array<Array<TResult>>
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
inputQuery: unknown
client: DbClient | undefined
identityMode: `collection` | `queryKey` | `legacyDeps` | `derived`
dependencies: Array<unknown> | null
pageSize: number
initialPageParam: number
collection: Collection<any, any, any>
controller: LiveQueryWindowController<any, any>
warning: string | null
warned: boolean
deferredCollections: Set<
CollectionImplType<any, string | number, any, any, any>
>
}
type RenderedWindowCollection = {
client: DbClient | undefined
dependencies: Array<unknown>
collection: Collection<any, any, any>
deferredCollections: Set<
CollectionImplType<any, string | number, any, any, any>
>
}
/**
* Whether a recorded query identity matches the current one. Legacy deps
* compare by reference. Derived identities and query keys compare by structure.
*/
function queryIdentityMatches(
comparison: { changed: boolean; structurallyEqual: boolean },
sameClient: boolean,
usesLegacyDeps: boolean,
): boolean {
return (
sameClient &&
(usesLegacyDeps ? !comparison.changed : comparison.structurallyEqual)
)
}
/**
* 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
* @param deps - Deprecated array of dependencies that trigger query re-execution when changed
* @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>,
deps?: Array<unknown>,
): UseLiveInfiniteQueryReturn<TContext>
// Implementation
export function useLiveInfiniteQuery<TContext extends Context>(
queryFnOrCollection: any,
config: UseLiveInfiniteQueryConfig<TContext>,
deps?: Array<unknown>,
): UseLiveInfiniteQueryReturn<TContext> {
if (`getNextPageParam` in config) {
throw new Error(
`getNextPageParam is not supported by useLiveInfiniteQuery. Use an on-demand collection and fulfill meta.loadSubsetOptions in queryFn for server pagination.`,
)
}
const pageSize = normalizeLiveQueryWindowPageSize(config.pageSize)
const initialPageParam = config.initialPageParam ?? 0
const contextDbClient = useOptionalDbClient()
const dbClient = config.client ?? contextDbClient
const inputIsCollection =
getLiveQueryWindowInputKind(queryFnOrCollection) === `collection`
const committedRef = useRef<InfiniteQueryRenderState | null>(null)
const committed = committedRef.current
// Like useLiveQuery's instance memo, keep the window collection built by the
// latest render. A second render before commit, such as a StrictMode double
// render, reuses it instead of starting another one. Only the collection is
// kept. Each render still builds its own controller and page count from the
// committed state, which an uncommitted render never changes.
const renderedCollectionRef = useRef<RenderedWindowCollection | null>(null)
const sourceIdsRef = useRef<SourceIdBindings | null>(null)
const inputKind = inputIsCollection ? `collection` : `query`
const derivedIdentityProfilerRef = useRef<DerivedIdentityProfiler>({
renderCount: 0,
totalMs: 0,
maxMs: 0,
warned: false,
})
const legacyUnhashableIdentityRef = useRef<Array<unknown>>([
`legacy-unhashable`,
])
const deferredCollections = new Set<
CollectionImplType<any, string | number, any, any, any>
>()
let preparedQueryValue: unknown | typeof unpreparedQueryValue =
unpreparedQueryValue
let identityDeps: ReadonlyArray<unknown> = []
let identityMode: InfiniteQueryRenderState[`identityMode`] = `collection`
if (!inputIsCollection) {
if (config.queryKey !== undefined) {
identityMode = `queryKey`
identityDeps = config.queryKey
} else if (deps !== undefined) {
identityMode = `legacyDeps`
identityDeps = deps
warnDeprecatedDepsArray(`useLiveInfiniteQuery`)
} else if (
committed?.identityMode === `derived` &&
committed.inputQuery === queryFnOrCollection &&
committed.client === dbClient
) {
identityMode = `derived`
identityDeps = committed.dependencies ?? []
} else {
identityMode = `derived`
const preparation = prepareDerivedQuery(
queryFnOrCollection,
dbClient,
derivedIdentityProfilerRef.current,
deferredCollections,
)
preparedQueryValue = preparation.value
claimSourceIds(
(sourceIdsRef.current ??= createSourceIdBindings()),
preparation.value,
dbClient,
`useLiveInfiniteQuery`,
() => resumeSyncStarts(deferredCollections),
)
if (preparation.status === `hashable`) {
identityDeps = preparation.identityDeps
} else {
warnUnhashableDerivedIdentity(preparation.error)
identityDeps = legacyUnhashableIdentityRef.current
}
}
}
const usesLegacyDeps =
!inputIsCollection && config.queryKey === undefined && deps !== undefined
const dependencyComparison = compareLiveQueryWindowDependencies(
committed?.dependencies,
identityDeps,
)
const sameClient = committed?.client === dbClient
const dependenciesChanged =
!inputIsCollection &&
!queryIdentityMatches(dependencyComparison, sameClient, usesLegacyDeps)
const dependenciesStructurallyEqual =
usesLegacyDeps && sameClient && 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
let startInRender = false
let suppliedCollection = false
let createdCollection = false
const canPreservePageCount = shouldPreserveLiveQueryWindowPageCount({
hasPreviousController: committed !== null,
previousInputKind: committed?.inputKind,
inputKind,
sameCollection:
inputIsCollection && committed?.inputCollection === queryFnOrCollection,
dependenciesChanged,
dependenciesStructurallyEqual,
pageShapeChanged,
})
const previousPageCount = committed
? Math.max(1, committed.controller.getSnapshot().pages.length)
: 1
const initialPageCount = canPreservePageCount ? previousPageCount : 1
// The peek-ahead window for every retained page.
const requiredLimit = initialPageCount * pageSize + 1
if (needsNewCollection) {
let inputValue = queryFnOrCollection
if (!inputIsCollection) {
if (preparedQueryValue === unpreparedQueryValue) {
preparedQueryValue = prepareQueryValue(
queryFnOrCollection,
dbClient,
deferredCollections,
)
}
inputValue = () => preparedQueryValue
}
const rendered = renderedCollectionRef.current
if (
!inputIsCollection &&
rendered !== null &&
queryIdentityMatches(
compareLiveQueryWindowDependencies(
rendered.dependencies,
identityDeps,
),
rendered.client === dbClient,
usesLegacyDeps,
) &&
rendered.collection.status !== `cleaned-up` &&
rendered.collection.status !== `error` &&
liveQueryWindowMatches(rendered.collection, requiredLimit)
) {
// An earlier render built this collection and nothing has committed
// since. Its window is exactly the retained pages, so its first
// rows are correct. Sources it deferred still resume at commit. A
// collection in terminal error, such as after its source restarted,
// cannot serve the query again, so a new one replaces it.
collection = rendered.collection
for (const deferred of rendered.deferredCollections) {
deferredCollections.add(deferred)
}
} else {
const input = resolveLiveQueryWindowInput<TContext>(inputValue)
if (input.kind === `collection`) {
collection = input.collection
suppliedCollection = true
// A supplied collection is never reused, so release the last one.
renderedCollectionRef.current = null
} else {
// Wrap the query with the peek-ahead window for every retained page,
// so a collection that starts syncing now never publishes fewer rows
// than the controller's pages. The controller grows the limit via
// setWindow.
collection = createLiveQueryCollection({
query: input.query.limit(requiredLimit).offset(0),
gcTime: DEFAULT_GC_TIME_MS,
})
startInRender = true
createdCollection = true
}
}
}
if (!collection) {
throw new Error(`useLiveInfiniteQuery: Failed to create a collection.`)
}
if (inputIsCollection) {
warning =
getLiveQueryWindowCollectionWarning(collection, pageSize + 1) ?? null
} else {
assertLiveQueryWindowManyResult(collection)
}
// Like useLiveQuery, start sync during render once the input is valid. A
// render reads only rows the sources already hold, such as a preloaded
// collection's, and sends no request until the subscription commits. GC
// reclaims a render that never commits. A supplied window that the
// controller must still adjust waits for the subscription.
if (suppliedCollection) {
startInRender = liveQueryWindowMatches(collection, requiredLimit)
}
if (startInRender) collection.startSyncImmediate()
// 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 and throws.
if (createdCollection) {
renderedCollectionRef.current = {
client: dbClient,
dependencies: [...identityDeps],
collection,
deferredCollections,
}
}
renderState = {
inputKind,
inputCollection: inputIsCollection ? collection : null,
inputQuery: inputIsCollection ? null : queryFnOrCollection,
client: dbClient,
identityMode,
dependencies: inputIsCollection ? null : [...identityDeps],
pageSize,
initialPageParam,
collection,
controller: createLiveQueryWindowController(collection, {
pageSize,
initialPageParam,
initialPageCount,
}),
warning,
warned: false,
deferredCollections,
}
}
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)
}
resumeSyncStarts(currentRenderState.deferredCollections)
return unsubscribe
},
[controller, currentRenderState],
)
const getSnapshot = useCallback(() => controller.getSnapshot(), [controller])
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
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,
persistedStatus: snapshot.persistedStatus,
isPersistedReady: snapshot.isPersistedReady,
persistedError: snapshot.persistedError,
isIdle: snapshot.isIdle,
isError: snapshot.isError,
isCleanedUp: snapshot.isCleanedUp,
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,
error: snapshot.error,
}
}