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