UNPKG

@tanstack/react-db

Version:

React integration for @tanstack/db

1 lines • 13.3 kB
{"version":3,"file":"useLiveSuspenseQuery.cjs","sources":["../../src/useLiveSuspenseQuery.ts"],"sourcesContent":["'use client'\n\nimport { useRef } from 'react'\nimport { useLiveQueryForSuspense } from './useLiveQuery'\nimport { getLiveQueryResultInfo } from './live-query-internals'\nimport type { UseLiveQueryConfig } from './useLiveQuery'\nimport type {\n Collection,\n Context,\n DbClient,\n GetResult,\n InferResultType,\n InitialQueryBuilder,\n LiveQueryCollectionConfig,\n NonSingleResult,\n QueryBuilder,\n SingleResult,\n} from '@tanstack/db'\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 {\n byClient: WeakMap<DbClient, { error: unknown; clientQuery?: object }>\n unscoped?: { error: unknown; clientQuery?: object }\n }\n>()\n\nfunction clearInitialRenderError(\n collection: Collection<any, any, any>,\n client: DbClient | undefined,\n) {\n const entry = initialRenderErrors.get(collection)\n if (!entry) return\n if (client) entry.byClient.delete(client)\n else delete entry.unscoped\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 = { byClient: new WeakMap() }\n collection.once(`status:cleaned-up`, () => {\n initialRenderErrors.delete(collection)\n })\n initialRenderErrors.set(collection, entry)\n }\n if (client) entry.byClient.set(client, { error, clientQuery })\n else entry.unscoped = { error }\n}\n\n/**\n * Create a live query with React Suspense support\n * @param queryFn - Query function that defines what data to fetch\n * @param deps - Deprecated array of dependencies that trigger query re-execution when changed\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 collection fails (caught by Error boundary)\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 deps?: Array<unknown>,\n): {\n state: Map<string | number, GetResult<TContext>>\n data: InferResultType<TContext>\n collection: Collection<GetResult<TContext>, string | number, {}>\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}\n\n// Overload 3: Accept legacy config object\nexport function useLiveSuspenseQuery<TContext extends Context>(\n config: LiveQueryCollectionConfig<TContext>,\n deps?: Array<unknown>,\n): {\n state: Map<string | number, GetResult<TContext>>\n data: InferResultType<TContext>\n collection: Collection<GetResult<TContext>, string | number, {}>\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}\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}\n\n// Implementation - uses useLiveQuery internally and adds Suspense logic\nexport function useLiveSuspenseQuery(\n configOrQueryOrCollection: any,\n deps?: Array<unknown>,\n) {\n const promiseRef = useRef<Promise<void> | null>(null)\n const collectionRef = useRef<Collection<any, any, any> | null>(null)\n const hasBeenReadyRef = useRef(false)\n\n // Use useLiveQuery to handle collection management and reactivity\n const result =\n deps === undefined\n ? useLiveQueryForSuspense(configOrQueryOrCollection, undefined)\n : useLiveQueryForSuspense(configOrQueryOrCollection, deps)\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 const queryInfo = getLiveQueryResultInfo(result)\n\n // Reset promise and ready state when query identity changes\n if (collectionRef.current !== result.collection) {\n promiseRef.current = null\n collectionRef.current = result.collection\n hasBeenReadyRef.current = false\n }\n\n // SUSPENSE LOGIC: Throw promise or error based on collection status\n\n const collectionStatus = result.collection.status\n\n // Track when we reach ready state\n if (result.isReady || queryInfo.observer.isInitialRenderReady()) {\n hasBeenReadyRef.current = true\n promiseRef.current = null\n clearInitialRenderError(result.collection, queryInfo.client)\n }\n\n const observerError = queryInfo.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 queryInfo.client && queryInfo.queryHash\n ? queryInfo.client._getLiveQuery(queryInfo.queryHash)\n : undefined\n // A configured persisted restore may still satisfy this render after the\n // client query fails. Let the observer classify that failure below.\n if (\n !hasBeenReadyRef.current &&\n (result.persistedStatus === `unavailable` ||\n result.persistedStatus === `error`) &&\n (observerError !== undefined || clientQuery?.status === `error`)\n ) {\n promiseRef.current = null\n throw observerError === undefined ? clientQuery?.error : observerError\n }\n\n const errorEntry = initialRenderErrors.get(result.collection)\n const initialRenderError = queryInfo.client\n ? errorEntry?.byClient.get(queryInfo.client)\n : errorEntry?.unscoped\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(result.collection, queryInfo.client)\n } else if (initialRenderError && !hasBeenReadyRef.current) {\n promiseRef.current = null\n throw initialRenderError.error\n }\n\n // Only throw errors during initial load (before first ready)\n // After success, errors surface as stale data (matches TanStack Query behavior)\n if (\n collectionStatus === `error` &&\n !hasBeenReadyRef.current &&\n (result.persistedStatus === `unavailable` ||\n result.persistedStatus === `error`)\n ) {\n promiseRef.current = null\n // TODO: Once collections hold a reference to their last error object (#671),\n // we should rethrow that actual error instead of creating a generic message\n throw new Error(`Collection \"${result.collection.id}\" failed to load`)\n }\n\n if (\n !hasBeenReadyRef.current &&\n (result.isLoading ||\n result.isIdle ||\n result.isError ||\n (collectionStatus === `error` &&\n result.persistedStatus !== `unavailable`))\n ) {\n if (queryInfo.client?._isSsrStreamingEnabled() && !queryInfo.queryHash) {\n const reason = queryInfo.identityError\n ? `${queryInfo.identityError.reason} at ${queryInfo.identityError.path}`\n : `the query has no stable identity`\n throw new Error(\n `Cannot stream this live query during SSR because ${reason}. Provide an explicit serializable queryKey.`,\n )\n }\n // Create or reuse promise for current collection\n if (!promiseRef.current) {\n const collection = result.collection\n const client = queryInfo.client\n const queryHash = queryInfo.queryHash\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 = queryInfo.observer.preloadForInitialRender()\n } catch (error) {\n stopWatchingCleanup()\n throw error\n }\n const preloadClientQuery =\n client && queryHash ? client._getLiveQuery(queryHash) : undefined\n promiseRef.current = 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 promiseRef.current\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: result.collection,\n }\n}\n"],"names":[],"mappings":";;;;;;AAsBA;AAQA;AAIE;AACA;AACA;AAAwC;AAE1C;AAEA;AAME;AACA;AACA;AACE;AACA;AACE;AAAqC;AAEvC;AAAyC;AAE3C;AAA6D;AAE/D;AAoJO;AAIL;AACA;AACA;AAGA;AAKA;AAEE;AAAU;AACR;AAAA;AAQJ;AAGA;AACE;AACA;AACA;AAA0B;AAK5B;AAGA;AACE;AACA;AACA;AAA2D;AAG7D;AAGA;AAMA;AAME;AACA;AAAyD;AAG3D;AACA;AAIA;AAIE;AAA2D;AAE3D;AACA;AAAyB;AAK3B;AAME;AAGA;AAAqE;AAGvE;AAQE;AACE;AAGA;AAAU;AACkD;AAAA;AAI9D;AACE;AACA;AACA;AACA;AACA;AACE;AAAS;AAEX;AACA;AACE;AAA6B;AAE7B;AACA;AAAM;AAER;AAEA;AAEI;AAEA;AACE;AAAA;AACE;AACA;AACA;AACA;AAEJ;AAAM;AAEoB;AAGhC;AAAiB;AAKnB;AAAO;AACS;AACD;AACM;AAEvB;;"}