UNPKG

@tanstack/react-db

Version:

React integration for @tanstack/db

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