UNPKG

@tanstack/react-db

Version:

React integration for @tanstack/db

882 lines (831 loc) • 31.4 kB
'use client' import { useEffect, useLayoutEffect, useRef, useSyncExternalStore } from 'react' import { UnhashableQueryIRError, createLiveQueryObserver, createLiveQuerySourceIdClaims, deepEquals, getLiveQueryKey, getSourceObjectToken, getStableValueHash, identifyLiveQuery, isCollection, prepareLiveQueryValue, rejectLiveQueryInput, releaseDeferredOnReclaim, resolveLiveQueryValue, resumeDeferredLiveQueryCollections, } from '@tanstack/db' import { useOptionalDbClient } from './DbProvider' import { shouldWarnInDevelopment } from './development' import { getPreparedSources, getUnboundDescriptorReporter, } from './source-id-bindings' import type { Collection, CollectionImpl, CollectionStatus, Context, DbClient, DeferredLiveQueryCollections, GetResult, InferResultType, InitialQueryBuilder, LiveQueryCollectionConfig, LiveQueryIdentityScope, LiveQueryKey, LiveQueryObserver, LiveQuerySourceIdClaims, LiveQueryStatusFlags, NonSingleResult, PooledLiveQueries, QueryBuilder, SingleResult, } from '@tanstack/db' // Use the client layout commit to schedule window moves. Server rendering has // no commit; React18 diagnoses a layout effect there even when it is empty. const useWindowCommitEffect = typeof window === `undefined` ? useEffect : useLayoutEffect const DEFAULT_GC_TIME_MS = 1 // Live queries created by useLiveQuery are cleaned up immediately (0 disables GC) // Suspense renders can be abandoned before React subscribes. Keep their // collection briefly so a nearby retry can commit without starting over. const DEFAULT_SUSPENSE_GC_TIME_MS = 5000 const DERIVED_IDENTITY_SINGLE_RENDER_WARN_MS = 16 const DERIVED_IDENTITY_RENDER_COUNT_WARN_THRESHOLD = 10 const DERIVED_IDENTITY_TOTAL_WARN_MS = 50 const warnedDerivedIdentityCallsites = new Set<string>() const unpreparedQueryValue = Symbol(`unpreparedQueryValue`) type SuspenseCollection = Collection<object, string | number, {}> type SuspenseCollectionEntry = { collection: SuspenseCollection removeStatusListeners: Array<() => void> } const unscopedSuspenseScope = {} 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 sharing; query identity still 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]) } function getSuspenseCollections( client: DbClient | undefined, ): Map<string, SuspenseCollectionEntry> { const scope = client ?? unscopedSuspenseScope let collections = suspenseCollectionsByClient.get(scope) if (!collections) { collections = new Map() suspenseCollectionsByClient.set(scope, collections) } return collections } export type DerivedIdentityProfiler = { renderCount: number totalMs: number maxMs: number warned: boolean } /** Identity timing and warning state have no work to do in production. */ export function createDerivedIdentityProfiler(): DerivedIdentityProfiler | undefined { try { if (process.env.NODE_ENV !== `production`) { return { renderCount: 0, totalMs: 0, maxMs: 0, warned: false } } } catch { // Unbundled browsers may omit process; the warning policy handles that. } return undefined } export type UseLiveQueryStatus = CollectionStatus | `disabled` export type { LiveQueryKey } type UseLiveQueryConfigOptions<TContext extends Context> = Omit< LiveQueryCollectionConfig<TContext>, `query` > & { /** * 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 /** * Pass `pooledLiveQueries` to share one partition among equality-filtered * queries of one shape. Suspense and DbClient queries never pool. */ pool?: PooledLiveQueries } type ConfiguredQueryBuilder<TContext extends Context> = Extract< LiveQueryCollectionConfig<TContext>[`query`], QueryBuilder<TContext> > export type UseLiveQueryConfig<TContext extends Context> = UseLiveQueryConfigOptions<TContext> & Pick<LiveQueryCollectionConfig<TContext>, `query`> export type ConditionalUseLiveQueryConfig<TContext extends Context> = UseLiveQueryConfigOptions<TContext> & { query: | ConfiguredQueryBuilder<TContext> | (( q: InitialQueryBuilder, ) => ConfiguredQueryBuilder<TContext> | undefined | null) } function getCurrentTime(): number { return typeof performance !== `undefined` && typeof performance.now === `function` ? performance.now() : Date.now() } function getWarningCallsite(stackIndex: number): string { const stack = new Error().stack ?? `unknown` const lines = stack.split(`\n`) const userFrame = lines .slice(1) .find( (line) => !line.includes(`useLiveQuery.ts`) && !line.includes(`useLiveSuspenseQuery.ts`) && !line.includes(`useLiveInfiniteQuery.ts`), ) return userFrame?.trim() ?? lines[stackIndex]?.trim() ?? stack } function warnDerivedIdentityHotPath( profiler: DerivedIdentityProfiler, durationMs: number, ): void { if ( profiler.warned || !( shouldWarnInDevelopment(`TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`) && process.env.NODE_ENV !== `production` ) ) { return } const isSlowSingleRender = durationMs >= DERIVED_IDENTITY_SINGLE_RENDER_WARN_MS const isHotRenderPath = profiler.renderCount >= DERIVED_IDENTITY_RENDER_COUNT_WARN_THRESHOLD && profiler.totalMs >= DERIVED_IDENTITY_TOTAL_WARN_MS if (!isSlowSingleRender && !isHotRenderPath) { return } const callsite = getWarningCallsite(5) if (warnedDerivedIdentityCallsites.has(callsite)) { profiler.warned = true return } warnedDerivedIdentityCallsites.add(callsite) profiler.warned = true const reason = isSlowSingleRender ? `one render took ${durationMs.toFixed(1)}ms` : `${profiler.renderCount} renders took ${profiler.totalMs.toFixed(1)}ms` console.warn( `[useLiveQuery] Deriving live query identity from structured query IR is running on a hot render path (${reason}, max ${profiler.maxMs.toFixed(1)}ms). ` + `Provide an explicit queryKey to skip rebuilding and hashing the IR on every render: useLiveQuery({ queryKey: [...], query }).`, ) } export { prepareLiveQueryValue as prepareQueryValue } type DerivedQueryPreparation = { value: unknown identityDeps: Array<unknown> } /** How a hook checks a prepared query whose identity it compares. */ export function getIdentityScope( claims: LiveQuerySourceIdClaims, dbClient: DbClient | undefined, hookName: string, ): LiveQueryIdentityScope { return { claims, client: dbClient, bindingName: hookName, onUnboundDescriptor: getUnboundDescriptorReporter(dbClient, hookName), } } /** * Prepare a query without `queryKey`, derive its identity, and claim its * source Collection IDs. A query whose IR has no stable identity throws * `UnhashableQueryIRError`. The hook's render boundary releases the sync * starts a rejected render deferred. */ export function prepareDerivedQuery( value: unknown, profiler: DerivedIdentityProfiler | undefined, scope: LiveQueryIdentityScope, deferredCollections: Set<CollectionImpl<any, string | number, any, any, any>>, ): DerivedQueryPreparation { const shouldProfile = profiler !== undefined && /* @__PURE__ */ shouldWarnInDevelopment( `TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`, ) // Keep literal build guards at both timing sites so production bundlers can // erase the timer and warning machinery, as well as the profiler initializer. const start = shouldProfile && process.env.NODE_ENV !== `production` ? getCurrentTime() : 0 const preparedValue = prepareLiveQueryValue( value, scope.client, deferredCollections, ) try { return { value: preparedValue, identityDeps: [`derived`, identifyLiveQuery(preparedValue, scope)], } } finally { if (shouldProfile && process.env.NODE_ENV !== `production`) { const durationMs = getCurrentTime() - start profiler.renderCount += 1 profiler.totalMs += durationMs profiler.maxMs = Math.max(profiler.maxMs, durationMs) warnDerivedIdentityHotPath(profiler, durationMs) } } } /** * Create a live query using a query function. * @param queryFn - Query function that defines what data to fetch * @returns Object with reactive data, state, and status information * @example * // Prefer config object syntax * const { data, isLoading } = useLiveQuery({ * query: (q) => * q.from({ todos: todosCollection }) * .where(({ todos }) => eq(todos.completed, false)) * .select(({ todos }) => ({ id: todos.id, text: todos.text })) * }) * * @example * // Single result query * const { data } = useLiveQuery({ * query: (q) => q.from({ todos: todosCollection }) * .where(({ todos }) => eq(todos.id, 1)) * .findOne() * }) * * @example * // Structured captured values are included in derived query identity * const { data, state } = useLiveQuery({ * query: (q) => q.from({ todos: todosCollection }) * .where(({ todos }) => gt(todos.priority, minPriority)), * }) * * @example * // Return undefined or null to disable a query * const { data, isEnabled } = useLiveQuery({ * query: (q) => { * if (!userId) return undefined * return q.from({ todos: todosCollection }) * .where(({ todos }) => eq(todos.userId, userId)) * }, * }) * * @example * // Join pattern * const { data } = useLiveQuery({ * query: (q) => * q.from({ issues: issueCollection }) * .join({ persons: personCollection }, ({ issues, persons }) => * eq(issues.userId, persons.id) * ) * .select(({ issues, persons }) => ({ * id: issues.id, * title: issues.title, * userName: persons.name * })) * }) * * @example * // Handle loading and error states * const { data, isLoading, isLoadingError, status } = useLiveQuery({ * query: (q) => q.from({ todos: todoCollection }) * }) * * if (isLoading) return <div>Loading...</div> * if (isLoadingError) return <div>Error: {status}</div> * * return ( * <ul> * {data.map(todo => <li key={todo.id}>{todo.text}</li>)} * </ul> * ) */ // Overload 1: Accept query function that always returns QueryBuilder export function useLiveQuery<TContext extends Context>( queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>, ): { state: Map<string | number, GetResult<TContext>> data: InferResultType<TContext> collection: Collection<GetResult<TContext>, string | number, {}> status: CollectionStatus // Can't be disabled if always returns QueryBuilder isEnabled: true // Always true if always returns QueryBuilder } & LiveQueryStatusFlags // Overload 2: Accept query function that can return undefined/null export function useLiveQuery<TContext extends Context>( queryFn: ( q: InitialQueryBuilder, ) => QueryBuilder<TContext> | undefined | null, ): { state: Map<string | number, GetResult<TContext>> | undefined data: InferResultType<TContext> | undefined collection: Collection<GetResult<TContext>, string | number, {}> | undefined status: UseLiveQueryStatus isEnabled: boolean } & LiveQueryStatusFlags // Overload 3: Accept query function that can return LiveQueryCollectionConfig export function useLiveQuery<TContext extends Context>( queryFn: ( q: InitialQueryBuilder, ) => LiveQueryCollectionConfig<TContext> | undefined | null, ): { state: Map<string | number, GetResult<TContext>> | undefined data: InferResultType<TContext> | undefined collection: Collection<GetResult<TContext>, string | number, {}> | undefined status: UseLiveQueryStatus isEnabled: boolean } & LiveQueryStatusFlags // Overload 4: Accept query function that can return Collection export function useLiveQuery< TResult extends object, TKey extends string | number, TUtils extends Record<string, any>, >( queryFn: ( q: InitialQueryBuilder, ) => Collection<TResult, TKey, TUtils> | undefined | null, ): { state: Map<TKey, TResult> | undefined data: Array<TResult> | undefined collection: Collection<TResult, TKey, TUtils> | undefined status: UseLiveQueryStatus isEnabled: boolean } & LiveQueryStatusFlags // Overload 5: Accept query function that can return all types export function useLiveQuery< TContext extends Context, TResult extends object, TKey extends string | number, TUtils extends Record<string, any>, >( queryFn: ( q: InitialQueryBuilder, ) => | QueryBuilder<TContext> | LiveQueryCollectionConfig<TContext> | Collection<TResult, TKey, TUtils> | undefined | null, ): { state: Map<string | number, GetResult<TContext>> | Map<TKey, TResult> | undefined data: InferResultType<TContext> | Array<TResult> | undefined collection: | Collection<GetResult<TContext>, string | number, {}> | Collection<TResult, TKey, TUtils> | undefined status: UseLiveQueryStatus isEnabled: boolean } & LiveQueryStatusFlags /** * Create a live query using configuration object * @param config - Configuration object with query and options * @returns Object with reactive data, state, and status information * @example * // Basic config object usage * const { data, status } = useLiveQuery({ * query: (q) => q.from({ todos: todosCollection }), * gcTime: 60000 * }) * * @example * // With query builder and options * const queryBuilder = new Query() * .from({ persons: collection }) * .where(({ persons }) => gt(persons.age, 30)) * .select(({ persons }) => ({ id: persons.id, name: persons.name })) * * const { data, isReady } = useLiveQuery({ * query: queryBuilder, * }) * * @example * // Handle all states uniformly * const { data, isLoading, isReady, isLoadingError } = useLiveQuery({ * query: (q) => q.from({ items: itemCollection }) * }) * * if (isLoading) return <div>Loading...</div> * if (isLoadingError) return <div>Something went wrong</div> * if (!isReady) return <div>Preparing...</div> * * return <div>{data.length} items loaded</div> */ // Overload 6: Accept config object export function useLiveQuery<TContext extends Context>( config: UseLiveQueryConfig<TContext>, ): { state: Map<string | number, GetResult<TContext>> data: InferResultType<TContext> collection: Collection<GetResult<TContext>, string | number, {}> status: CollectionStatus // Can't be disabled when query always returns a builder isEnabled: true // Always true when query always returns a builder } & LiveQueryStatusFlags // Overload 7: Accept config object with a query that can return undefined/null export function useLiveQuery<TContext extends Context>( config: ConditionalUseLiveQueryConfig<TContext>, ): { state: Map<string | number, GetResult<TContext>> | undefined data: InferResultType<TContext> | undefined collection: Collection<GetResult<TContext>, string | number, {}> | undefined status: UseLiveQueryStatus isEnabled: boolean } & LiveQueryStatusFlags /** * Subscribe to an existing live query collection * @param liveQueryCollection - Pre-created live query collection to subscribe to * @returns Object with reactive data, state, and status information * @example * // Using pre-created live query collection * const myLiveQuery = createLiveQueryCollection((q) => * q.from({ todos: todosCollection }).where(({ todos }) => eq(todos.active, true)) * ) * const { data, collection } = useLiveQuery(myLiveQuery) * * @example * // Access collection methods directly * const { data, collection, isReady } = useLiveQuery(existingCollection) * * // Use collection for mutations * const handleToggle = (id) => { * collection.update(id, draft => { draft.completed = !draft.completed }) * } * * @example * // Handle states consistently * const { data, isLoading, isLoadingError } = useLiveQuery(sharedCollection) * * if (isLoading) return <div>Loading...</div> * if (isLoadingError) return <div>Error loading data</div> * * return <div>{data.map(item => <Item key={item.id} {...item} />)}</div> */ // Overload 10: Accept pre-created live query collection export function useLiveQuery< 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> status: CollectionStatus // Can't be disabled for pre-created live query collections isEnabled: true // Always true for pre-created live query collections } & LiveQueryStatusFlags // Overload 10: Accept pre-created live query collection with singleResult: true export function useLiveQuery< 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 status: CollectionStatus // Can't be disabled for pre-created live query collections isEnabled: true // Always true for pre-created live query collections } & LiveQueryStatusFlags // Implementation - use function overloads to infer the actual collection type export function useLiveQuery(configOrQueryOrCollection: any) { return useLiveQueryImpl(configOrQueryOrCollection) as any } /** @internal Shared implementation for the Suspense wrapper. */ export function useLiveQueryForSuspense(configOrQueryOrCollection: unknown) { return useLiveQueryImpl(configOrQueryOrCollection, true) } // What one hook instance keeps across renders. Fields produced by resolution // are installed with their first value, rather than initialized and overwritten // in the same render. type HookInstance = { collection: Collection<object, string | number, {}> | null identityDeps: Array<unknown> | null deferredCollections: Set<CollectionImpl<any, string | number, any, any, any>> sourceIds: LiveQuerySourceIdClaims config?: unknown client?: DbClient derivedIdentityProfiler?: DerivedIdentityProfiler cancelReclaimRelease?: () => void observer?: LiveQueryObserver<object, string | number> queryHash?: string identityError?: UnhashableQueryIRError subscribe?: (onStoreChange: () => void) => () => void } type LiveQueryResult = ReturnType< LiveQueryObserver<object, string | number>[`getSnapshot`] > function useLiveQueryImpl(configOrQueryOrCollection: unknown): LiveQueryResult function useLiveQueryImpl( configOrQueryOrCollection: unknown, forSuspense: true, ): { result: LiveQueryResult client: DbClient | undefined queryHash: string | undefined identityError: UnhashableQueryIRError | undefined observer: LiveQueryObserver<object, string | number> /** Sync starts this render deferred, released if the render throws. */ deferredCollections: DeferredLiveQueryCollections } function useLiveQueryImpl(configOrQueryOrCollection: any, forSuspense = false) { const contextDbClient = useOptionalDbClient() // Check if it's already a collection const inputIsCollection = isCollection(configOrQueryOrCollection) const dbClient: DbClient | undefined = (inputIsCollection || typeof configOrQueryOrCollection !== `object` ? undefined : configOrQueryOrCollection?.client) ?? contextDbClient const instanceRef = useRef<HookInstance | null>(null) const instance: HookInstance = (instanceRef.current ??= { collection: null, identityDeps: null, derivedIdentityProfiler: createDerivedIdentityProfiler(), deferredCollections: new Set(), sourceIds: createLiveQuerySourceIdClaims(), }) // A commit releases the starts itself, so it drops the reclaim listener. const resumeDeferredCollections = () => { instance.cancelReclaimRelease?.() instance.cancelReclaimRelease = undefined resumeDeferredLiveQueryCollections(instance.deferredCollections) } // The render boundary: a render that throws anywhere after preparation // never commits, so it releases the sync starts it deferred here. try { const queryKey = !inputIsCollection ? getLiveQueryKey(configOrQueryOrCollection) : undefined let preparedQueryValue: unknown | typeof unpreparedQueryValue = unpreparedQueryValue let identityDeps: ReadonlyArray<unknown> let streamIdentity: ReadonlyArray<unknown> let identityError: UnhashableQueryIRError | undefined if (queryKey) { identityDeps = queryKey streamIdentity = [`queryKey`, queryKey] } else if (inputIsCollection) { identityDeps = [] streamIdentity = [`collection`, configOrQueryOrCollection.id] } else { const preparation = prepareDerivedQuery( configOrQueryOrCollection, instance.derivedIdentityProfiler, getIdentityScope(instance.sourceIds, dbClient, `useLiveQuery`), instance.deferredCollections, ) preparedQueryValue = preparation.value identityDeps = preparation.identityDeps streamIdentity = preparation.identityDeps } let queryHash: string | undefined try { queryHash = getStableValueHash(streamIdentity, `queryKey`) } catch (error) { if (!(error instanceof UnhashableQueryIRError) || queryKey !== undefined) throw error identityError = error } // Check if we need to create/recreate the collection let needsNewCollection = !instance.collection || (inputIsCollection ? instance.config !== configOrQueryOrCollection : instance.client !== dbClient || !deepEquals(instance.identityDeps, identityDeps)) if ( needsNewCollection && !inputIsCollection && preparedQueryValue === unpreparedQueryValue ) { preparedQueryValue = prepareLiveQueryValue( configOrQueryOrCollection, dbClient, instance.deferredCollections, ) } let commitWindow: (() => void) | undefined if (needsNewCollection) { if (inputIsCollection) { // Warn when passing a collection directly with on-demand sync mode // In on-demand mode, data is only loaded when queries with predicates request it // Passing the collection directly doesn't provide any predicates, so no data loads if ( /* @__PURE__ */ shouldWarnInDevelopment( `TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`, ) && process.env.NODE_ENV !== `production` && (configOrQueryOrCollection as { config?: { syncMode?: string } }) .config?.syncMode === `on-demand` ) { console.warn( `[useLiveQuery] Warning: Passing a collection with syncMode "on-demand" directly to useLiveQuery ` + `will not load any data. In on-demand mode, data is only loaded when queries with predicates request it.\n\n` + `Instead, use a query builder function:\n` + ` const { data } = useLiveQuery({ query: (q) => q.from({ c: myCollection }).select(({ c }) => c) })\n\n` + `Or switch to syncMode "eager" if you want all data to sync automatically.`, ) } // It's already a collection, ensure sync is started for React hooks configOrQueryOrCollection.startSyncImmediate() instance.collection = configOrQueryOrCollection } else { // Only a new binding consults the shared cache. Mounted bindings keep // their Collection and its first options until query identity changes. // The creation path above has already prepared keyed inputs too. const suspenseKey = forSuspense && queryHash ? getSourceQualifiedSuspenseKey( preparedQueryValue, queryHash, dbClient, ) : undefined const suspenseCollections = suspenseKey ? getSuspenseCollections(dbClient) : undefined const suspenseCollection = suspenseCollections?.get( suspenseKey!, )?.collection if (suspenseCollection) { instance.collection = suspenseCollection } else { // A keyed query claims nothing, so it reports an unbound descriptor // here; the other forms reported it when they claimed. if (!dbClient && queryKey !== undefined) { getPreparedSources(preparedQueryValue, dbClient, `useLiveQuery`) } instance.collection = resolveLiveQueryValue(preparedQueryValue, { gcTime: forSuspense ? DEFAULT_SUSPENSE_GC_TIME_MS : DEFAULT_GC_TIME_MS, // Hydration and Suspense key the live-query Collection by identity, // so they never pool, even when the config passes a pool. pool: forSuspense || dbClient ? false : undefined, previousCollection: forSuspense || dbClient ? undefined : instance.collection, windowOwner: instance, onWindowChange: (move) => { commitWindow = move }, // Without a DbClient, Suspense starts it through a preload below. startSync: !forSuspense || dbClient !== undefined, }) as SuspenseCollection | null // A render that never commits releases its deferred sync starts // when garbage collection reclaims the live query it built. A // Collection the callback returned is not this render's to reclaim. if (instance.collection && !isCollection(preparedQueryValue)) { instance.cancelReclaimRelease?.() instance.cancelReclaimRelease = releaseDeferredOnReclaim( instance.collection, instance.deferredCollections, ) } if (suspenseCollections && instance.collection) { const collection = instance.collection const releaseCollection = () => { const entry = suspenseCollections.get(suspenseKey!) if (entry?.collection !== collection) return suspenseCollections.delete(suspenseKey!) entry.removeStatusListeners.forEach((remove) => remove()) } // Keep a failed or crashed collection through React's immediate // retry so the hook can throw its actual error to an ErrorBoundary. // Retire it afterward so a later boundary reset starts fresh. const retireAfterRetry = () => setTimeout(releaseCollection, 0) suspenseCollections.set(suspenseKey!, { collection, removeStatusListeners: [ collection.on(`status:cleaned-up`, releaseCollection), collection.on(`status:error`, retireAfterRetry), collection.on(`status:crashed`, retireAfterRetry), ], }) } } // An abandoned render must leave invalidation pending. Its retry or // urgent replacement still prepares and commits the requested move. if (!commitWindow) instance.identityDeps = [...identityDeps] } instance.config = configOrQueryOrCollection instance.client = dbClient instance.queryHash = queryHash instance.identityError = identityError } if (commitWindow) needsNewCollection = false useWindowCommitEffect(() => { if (commitWindow) { instance.identityDeps = [...identityDeps] commitWindow() } }) if (needsNewCollection) { // Recreate the observer when the underlying collection changes. The observer // is not disposed explicitly here or on unmount: `useSyncExternalStore` // unsubscribes it when the subscribe changes or the component unmounts, which // detaches the collection subscription; the observer is then GC'd. (An unmount // effect that disposed it would misfire under StrictMode/offscreen effect // replay, leaving a disposed observer in the ref.) // Defer the initial notify: useSyncExternalStore must not be notified // synchronously during subscribe. // Wholesale mode: React re-reads getSnapshot() on notify, keeps the // hook's pre-observer loading policy, and — because wholesale delivers // nothing synchronously during subscribe — never notifies // useSyncExternalStore inside its own subscribe call. const observer = (instance.observer = createLiveQueryObserver( instance.collection, { mode: `wholesale`, client: dbClient, queryHash: instance.queryHash, onPreload: resumeDeferredCollections, suspendUntilReady: forSuspense, }, )) // The subscribe function stays bound to the observer that created it. instance.subscribe = (onStoreChange: () => void) => { const unsubscribe = observer.subscribe(onStoreChange) resumeDeferredCollections() return unsubscribe } } const observer = instance.observer! // A Suspense render asks for its data. Preloading starts the sync run, so a // source that already holds the rows makes it ready before the snapshot is // read instead of suspending once. useLiveSuspenseQuery surfaces failures. // A DbClient defers source starts past render and owns its streamed query. if ( forSuspense && !dbClient && (instance.collection?.status === `idle` || instance.collection?.status === `cleaned-up`) ) { observer.preload().catch(() => {}) } const returned = useSyncExternalStore( instance.subscribe!, () => observer.getSnapshot(), () => observer.getServerSnapshot(forSuspense), ) // Pass render-local metadata directly to the Suspense wrapper. The public // hook returns the observer's stable snapshot without a wrapper allocation. if (forSuspense) { return { result: returned, client: dbClient, queryHash: instance.queryHash, identityError: instance.identityError, observer, deferredCollections: instance.deferredCollections, } } return returned } catch (error) { return rejectLiveQueryInput(instance.deferredCollections, error) } }