UNPKG

@tanstack/react-db

Version:

React integration for @tanstack/db

499 lines (468 loc) • 16.8 kB
'use client' import { useRef } from 'react' import { getLiveQueryStatusFlags, getSourceObjectToken, getStableValueHash, rejectLiveQueryInput, toBoundaryError, } from '@tanstack/db' import { useLiveQueryImpl } from './useLiveQuery' import { getPreparedSources } from './source-id-bindings' import type { UseLiveQueryConfig } from './useLiveQuery' import type { Collection, Context, DbClient, GetResult, HealthNode, InferResultType, InitialQueryBuilder, LiveQueryObserver, LiveQuerySnapshot, NonSingleResult, QueryBuilder, SingleResult, } from '@tanstack/db' type SuspenseCollection = Collection<object, string | number, {}> type SuspenseCollectionEntry = { collection: SuspenseCollection windowOwner: object owner?: SuspenseBinding observer?: LiveQueryObserver<object, string | number> release: () => void } // Retry resources and first-load errors use the same scope without a DbClient. const unscopedClient = {} const suspenseCollectionsByClient = new WeakMap< object, Map<string, SuspenseCollectionEntry> >() function getSourceQualifiedSuspenseKey( preparedValue: unknown, queryHash: string, client: DbClient | undefined, ): string { const sourceIds = getPreparedSources( preparedValue, client, `useLiveSuspenseQuery`, ).map(getSourceObjectToken) const { query: _query, pool: _pool, ...options } = (preparedValue as { query?: unknown } | null | undefined)?.query ? (preparedValue as Record<string, unknown>) : {} // Local output options qualify pending retry adoption; query identity binds // mounted options. Object/function declarations compare by exact runtime reference. options.autoIndex ??= `off` for (const key in options) { const value = options[key] if (value === undefined) delete options[key] else if ( value && (typeof value === `object` || typeof value === `function`) ) options[key] = getSourceObjectToken(value) } return getStableValueHash([sourceIds, options, queryHash]) } /** Uncommitted retries may adopt work; successful outputs have one owner. */ export class SuspenseBinding { private entry?: SuspenseCollectionEntry private seed?: LiveQuerySnapshot<object, string | number> collection?: Collection<any, any, any> promise?: Promise<void> ready = false get needsOwnCollection(): boolean { return this.entry !== undefined && this.entry.owner !== this } resolve( input: unknown, hash: string, client: DbClient | undefined, compile: (owned: boolean, windowOwner: object) => SuspenseCollection | null, ): SuspenseCollection | null { const scope = client ?? unscopedClient let cache = suspenseCollectionsByClient.get(scope) if (!cache) { cache = new Map() suspenseCollectionsByClient.set(scope, cache) } const owned = this.entry?.owner === this const key = owned ? undefined : getSourceQualifiedSuspenseKey(input, hash, client) const cached = key === undefined ? undefined : cache.get(key) if (cached) return (this.entry = cached).collection // A new attempt gets a fresh resolver token. Adoption retains that token; // another attempt must not overwrite the adopted Collection's plan. const windowOwner = owned ? this.entry!.windowOwner : {} const collection = compile(owned, windowOwner) if (owned && collection === this.entry!.collection) return collection this.seed = undefined if (collection && this.entry?.collection !== collection) { const entryKey = key ?? getSourceQualifiedSuspenseKey(input, hash, client) const release = () => { if (cache.get(entryKey) === entry) cache.delete(entryKey) removeStatusListener() } const entry = { collection, windowOwner, release } this.entry = entry cache.set(entryKey, entry) const removeStatusListener = collection.on( `status:change`, ({ status }) => { if (status === `cleaned-up`) release() // Preserve failures through React's immediate retry. else if (status === `error` || status === `crashed`) setTimeout(release, 0) }, ) } return collection } // The first successful candidate may keep the preload. Other candidates get // private outputs seeded from its completed snapshot. Keep the retry entry // available until commit, since React can discard a successful candidate. forkReady( observer: LiveQueryObserver<object, string | number>, compile: (owned: boolean, windowOwner: object) => SuspenseCollection | null, ): SuspenseCollection | undefined { if (!this.entry || this.entry.owner === this) return const resource = (this.entry.observer ??= observer) const snapshot = typeof window === `undefined` ? resource.getServerSnapshot(true) : resource.getSnapshot() if (!snapshot.isReady) return if (!this.entry.owner) { this.entry.owner = this return } const windowOwner = {} const collection = compile(false, windowOwner) if (!collection) return this.seed = { ...snapshot, collection } this.entry = { collection, windowOwner, owner: this, release: this.entry.release, } return collection } read(snapshot: LiveQuerySnapshot<object, string | number>) { if (snapshot.isReady || snapshot.isLoadingError || snapshot.isCrashed) { // A copied ready view is provisional until this output's first load. // Its failure must end both the seed and the hook's ready latch. if (this.seed && snapshot.isLoadingError) this.ready = false this.seed = undefined } // Copy only rows from the peer. This output's observer owns its health, // including source notifications and query-local request failures. if (this.seed && snapshot.health !== this.seed.health) { this.seed = { ...this.seed, ...getLiveQueryStatusFlags(`ready`, snapshot.health), } } return this.seed ?? snapshot } claim(): (() => void) | undefined { // Capture this render's entry: React can replay an older commit's effect // after a replacement suspends. That effect must release only its entry. return this.entry?.release } } // React can discard a render that suspends, including its refs. Keep the // initial-render failure across retries, scoped by client because streamed // preloads of the same collection can fail independently. const initialRenderErrors = new WeakMap< Collection<any, any, any>, WeakMap<object, { error: unknown; clientQuery?: object }> >() function clearInitialRenderError( collection: Collection<any, any, any>, client: DbClient | undefined, ) { initialRenderErrors.get(collection)?.delete(client ?? unscopedClient) } function rememberInitialRenderError( collection: Collection<any, any, any>, client: DbClient | undefined, error: unknown, clientQuery?: object, ) { if (collection.status === `cleaned-up`) return let entry = initialRenderErrors.get(collection) if (!entry) { entry = new WeakMap() collection.once(`status:cleaned-up`, () => { initialRenderErrors.delete(collection) }) initialRenderErrors.set(collection, entry) } entry.set(client ?? unscopedClient, { error, clientQuery }) } /** * Create a live query with React Suspense support * @param queryFn - Query function that defines what data to fetch * @returns Object with reactive data and state - data is guaranteed to be defined * @throws Promise when data is loading (caught by Suspense boundary) * @throws Error when the first load fails or the Collection crashes (caught by * an Error boundary): the cause itself when it is an Error, otherwise an Error * with the problem's message (`health.error.message`) whose `cause` is the * value the sync adapter reported * @example * // Basic usage with Suspense * function TodoList() { * const { data } = useLiveSuspenseQuery({ * query: (q) => * q.from({ todos: todosCollection }) * .where(({ todos }) => eq(todos.completed, false)) * .select(({ todos }) => ({ id: todos.id, text: todos.text })) * }) * * return ( * <ul> * {data.map(todo => <li key={todo.id}>{todo.text}</li>)} * </ul> * ) * } * * function App() { * return ( * <Suspense fallback={<div>Loading...</div>}> * <TodoList /> * </Suspense> * ) * } * * @example * // Single result query * const { data } = useLiveSuspenseQuery( * (q) => q.from({ todos: todosCollection }) * .where(({ todos }) => eq(todos.id, 1)) * .findOne() * ) * // data is guaranteed to be the single item (or undefined if not found) * * @example * // Structured captured values are included in derived query identity and trigger re-suspension * const { data } = useLiveSuspenseQuery({ * query: (q) => q.from({ todos: todosCollection }) * .where(({ todos }) => gt(todos.priority, minPriority)), * }) * * @example * // With Error boundary * function App() { * return ( * <ErrorBoundary fallback={<div>Error loading data</div>}> * <Suspense fallback={<div>Loading...</div>}> * <TodoList /> * </Suspense> * </ErrorBoundary> * ) * } * * @remarks * **Important:** This hook does NOT support disabled queries (returning undefined/null). * Following TanStack Query's useSuspenseQuery design, the query callback must always * return a valid query, collection, or config object. * * ❌ **This will cause a type error:** * ```ts * useLiveSuspenseQuery( * (q) => userId ? q.from({ users }) : undefined // ❌ Error! * ) * ``` * * ✅ **Use conditional rendering instead:** * ```ts * function Profile({ userId }: { userId: string }) { * const { data } = useLiveSuspenseQuery({ * query: (q) => q.from({ users }).where(({ users }) => eq(users.id, userId)), * }) * return <div>{data.name}</div> * } * * // In parent component: * {userId ? <Profile userId={userId} /> : <div>No user</div>} * ``` * * ✅ **For optional inputs, conditionally render a component with complete query inputs:** * ```ts * {userId ? <Profile userId={userId} /> : <div>No user</div>} * ``` */ // Overload 1: Accept query function that always returns QueryBuilder export function useLiveSuspenseQuery<TContext extends Context>( queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>, ): { state: Map<string | number, GetResult<TContext>> data: InferResultType<TContext> collection: Collection<GetResult<TContext>, string | number, {}> health: HealthNode | undefined } // Overload 2: Accept config object export function useLiveSuspenseQuery<TContext extends Context>( config: UseLiveQueryConfig<TContext>, ): { state: Map<string | number, GetResult<TContext>> data: InferResultType<TContext> collection: Collection<GetResult<TContext>, string | number, {}> health: HealthNode | undefined } // Overload 4: Accept pre-created live query collection export function useLiveSuspenseQuery< TResult extends object, TKey extends string | number, TUtils extends Record<string, any>, >( liveQueryCollection: Collection<TResult, TKey, TUtils> & NonSingleResult, ): { state: Map<TKey, TResult> data: Array<TResult> collection: Collection<TResult, TKey, TUtils> health: HealthNode | undefined } // Overload 5: Accept pre-created live query collection with singleResult: true export function useLiveSuspenseQuery< TResult extends object, TKey extends string | number, TUtils extends Record<string, any>, >( liveQueryCollection: Collection<TResult, TKey, TUtils> & SingleResult, ): { state: Map<TKey, TResult> data: TResult | undefined collection: Collection<TResult, TKey, TUtils> & SingleResult health: HealthNode | undefined } // Implementation - uses useLiveQuery internally and adds Suspense logic export function useLiveSuspenseQuery(configOrQueryOrCollection: any) { const bindingRef = useRef<SuspenseBinding | null>(null) const binding = (bindingRef.current ??= new SuspenseBinding()) // Use useLiveQuery to handle collection management and reactivity const { result, client, queryHash, observer, deferredCollections } = useLiveQueryImpl(configOrQueryOrCollection, binding) if (!result.isEnabled) { // Suspense queries cannot be disabled - this matches TanStack Query's useSuspenseQuery behavior throw new Error( `useLiveSuspenseQuery does not support disabled queries (callback returned undefined/null). ` + `The Suspense pattern requires data to always be defined (T, not T | undefined). ` + `Solutions: ` + `1) Use conditional rendering - don't render the component until the condition is met. ` + `2) Use useLiveQuery instead, which supports disabled queries with the 'isEnabled' flag.`, ) } // An enabled observer snapshot always has its Collection. const collection = result.collection! // The render boundary: an error thrown before this render commits releases // the sync starts it deferred. A thrown promise suspends; its preload owns // those starts. try { // Reset promise and ready state when query identity changes if (binding.collection !== collection) { binding.promise = undefined binding.collection = collection binding.ready = false } // SUSPENSE LOGIC: Throw promise or error based on collection status const collectionStatus = collection.status // Track when we reach ready state if (result.isReady) { binding.ready = true binding.promise = undefined clearInitialRenderError(collection, client) } const observerError = observer.getError() // A client request can reject with undefined, which getError() cannot // distinguish from no error. The request status preserves that distinction. const clientQuery = client && queryHash ? client._getLiveQuery(queryHash) : undefined if ( !binding.ready && (observerError !== undefined || clientQuery?.status === `error`) ) { throw observerError === undefined ? clientQuery?.error : observerError } const initialRenderError = initialRenderErrors .get(collection) ?.get(client ?? unscopedClient) // A replacement client query must not inherit the prior request's error. if ( initialRenderError?.clientQuery !== undefined && initialRenderError.clientQuery !== clientQuery ) { clearInitialRenderError(collection, client) } else if (initialRenderError && !binding.ready) { throw toBoundaryError(initialRenderError.error) } // A failed first load throws only before the first ready; later sync // problems surface in `health` with the data kept (as in TanStack Query). // A crash throws at any time: the Collection can no longer serve reads. if ( collectionStatus === `crashed` || (collectionStatus === `error` && !binding.ready) ) { throw toBoundaryError(collection.health.error?.cause) } if ( !binding.ready && (result.isLoading || result.isIdle || result.isLoadingError || result.isCrashed) ) { // Create or reuse promise for current collection if (!binding.promise) { let active = true const stopWatchingCleanup = collection.once(`status:cleaned-up`, () => { active = false }) let preload: Promise<void> try { preload = observer.preload() } catch (error) { stopWatchingCleanup() throw error } const preloadClientQuery = client && queryHash ? client._getLiveQuery(queryHash) : undefined binding.promise = preload .catch((error: unknown) => { const latestClientQuery = client && queryHash ? client._getLiveQuery(queryHash) : undefined if (active && latestClientQuery === preloadClientQuery) rememberInitialRenderError( collection, client, error, preloadClientQuery, ) throw error }) .finally(stopWatchingCleanup) } // React Suspense catches this promise and retries after preload settles. throw binding.promise } // Return data without status/loading flags (handled by Suspense/ErrorBoundary) // If error after success, return last known good state (stale data) return { state: result.state, data: result.data, collection, health: result.health, } } catch (error) { if (error instanceof Promise) throw error binding.promise = undefined return rejectLiveQueryInput(deferredCollections, error) } }