diff --git a/.changeset/suspense-live-query-undefined-support.md b/.changeset/suspense-live-query-undefined-support.md new file mode 100644 index 000000000..475020232 --- /dev/null +++ b/.changeset/suspense-live-query-undefined-support.md @@ -0,0 +1,55 @@ +--- +"@tanstack/react-db": patch +--- + +Improve error message when `useLiveSuspenseQuery` receives `undefined` from query callback. + +Following TanStack Query's `useSuspenseQuery` design, `useLiveSuspenseQuery` intentionally does not support disabled queries (when callback returns `undefined` or `null`). This maintains the type guarantee that `data` is always `T` (not `T | undefined`), which is a core benefit of using Suspense. + +**What changed:** + +The error message is now more helpful and explains the design decision: + +``` +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. +``` + +**Why this matters:** + +```typescript +// ❌ This pattern doesn't work with Suspense queries: +const { data } = useLiveSuspenseQuery( + (q) => userId + ? q.from({ users }).where(({ users }) => eq(users.id, userId)).findOne() + : undefined, + [userId] +) + +// ✅ Instead, use conditional rendering: +function UserProfile({ userId }: { userId: string }) { + const { data } = useLiveSuspenseQuery( + (q) => q.from({ users }).where(({ users }) => eq(users.id, userId)).findOne(), + [userId] + ) + return
{data.name}
// data is guaranteed non-undefined +} + +function App({ userId }: { userId?: string }) { + if (!userId) return
No user selected
+ return +} + +// ✅ Or use useLiveQuery for conditional queries: +const { data, isEnabled } = useLiveQuery( + (q) => userId + ? q.from({ users }).where(({ users }) => eq(users.id, userId)).findOne() + : undefined, + [userId] +) +``` + +This aligns with TanStack Query's philosophy where Suspense queries prioritize type safety and proper component composition over flexibility. diff --git a/packages/react-db/src/useLiveSuspenseQuery.ts b/packages/react-db/src/useLiveSuspenseQuery.ts index 67163bdc7..4604ea29a 100644 --- a/packages/react-db/src/useLiveSuspenseQuery.ts +++ b/packages/react-db/src/useLiveSuspenseQuery.ts @@ -12,6 +12,14 @@ import type { SingleResult, } from "@tanstack/db" +// Unique symbol for type-level error messages +declare const TypeException: unique symbol + +// Custom compile-time error for disabled queries +type DisabledQueryError = { + [TypeException]: `❌ useLiveSuspenseQuery does not support disabled queries (returning undefined/null).\n\n✅ Solution 1: Use conditional rendering\n Don't render this component until data is ready:\n {userId ? :
No user
}\n\n✅ Solution 2: Use useLiveQuery instead\n It supports the 'isEnabled' flag for conditional queries:\n useLiveQuery((q) => userId ? q.from(...) : undefined, [userId])` +} + /** * Create a live query with React Suspense support * @param queryFn - Query function that defines what data to fetch @@ -71,6 +79,39 @@ import type { * * ) * } + * + * @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( + * (q) => q.from({ users }).where(({ users }) => eq(users.id, userId)) + * ) + * return
{data.name}
+ * } + * + * // In parent component: + * {userId ? :
No user
} + * ``` + * + * ✅ **Or use useLiveQuery for conditional queries:** + * ```ts + * const { data, isEnabled } = useLiveQuery( + * (q) => userId ? q.from({ users }) : undefined, // ✅ Supported! + * [userId] + * ) + * ``` */ // Overload 1: Accept query function that always returns QueryBuilder export function useLiveSuspenseQuery( @@ -118,11 +159,38 @@ export function useLiveSuspenseQuery< collection: Collection & SingleResult } +// "Poison pill" overloads - catch disabled queries and show custom compile-time error +// These MUST come AFTER the specific overloads so TypeScript tries them last +export function useLiveSuspenseQuery( + queryFn: ( + q: InitialQueryBuilder + ) => QueryBuilder | undefined | null, + deps?: Array +): DisabledQueryError + +export function useLiveSuspenseQuery( + queryFn: ( + q: InitialQueryBuilder + ) => LiveQueryCollectionConfig | undefined | null, + deps?: Array +): DisabledQueryError + +export function useLiveSuspenseQuery< + TResult extends object, + TKey extends string | number, + TUtils extends Record, +>( + queryFn: ( + q: InitialQueryBuilder + ) => Collection | undefined | null, + deps?: Array +): DisabledQueryError + // Implementation - uses useLiveQuery internally and adds Suspense logic export function useLiveSuspenseQuery( configOrQueryOrCollection: any, deps: Array = [] -) { +): DisabledQueryError | { state: any; data: any; collection: any } { const promiseRef = useRef | null>(null) const collectionRef = useRef | null>(null) const hasBeenReadyRef = useRef(false) @@ -146,9 +214,13 @@ export function useLiveSuspenseQuery( // SUSPENSE LOGIC: Throw promise or error based on collection status // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!result.isEnabled) { - // Suspense queries cannot be disabled - throw error + // Suspense queries cannot be disabled - this matches TanStack Query's useSuspenseQuery behavior throw new Error( - `useLiveSuspenseQuery does not support disabled queries. Use useLiveQuery instead for conditional queries.` + `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.` ) }