diff --git a/.changeset/suspense-query-hook.md b/.changeset/suspense-query-hook.md
new file mode 100644
index 000000000..a64315e54
--- /dev/null
+++ b/.changeset/suspense-query-hook.md
@@ -0,0 +1,65 @@
+---
+"@tanstack/react-db": patch
+---
+
+Add `useLiveSuspenseQuery` hook for React Suspense support
+
+Introduces a new `useLiveSuspenseQuery` hook that integrates with React Suspense and Error Boundaries, following TanStack Query's `useSuspenseQuery` pattern.
+
+**Key features:**
+
+- React 18+ compatible using the throw promise pattern
+- Type-safe API with guaranteed data (never undefined)
+- Automatic error handling via Error Boundaries
+- Reactive updates after initial load via useSyncExternalStore
+- Support for dependency-based re-suspension
+- Works with query functions, config objects, and pre-created collections
+
+**Example usage:**
+
+```tsx
+import { Suspense } from "react"
+import { useLiveSuspenseQuery } from "@tanstack/react-db"
+
+function TodoList() {
+ // Data is guaranteed to be defined - no isLoading needed
+ const { data } = useLiveSuspenseQuery((q) =>
+ q
+ .from({ todos: todosCollection })
+ .where(({ todos }) => eq(todos.completed, false))
+ )
+
+ return (
+
+ {data.map((todo) => (
+ - {todo.text}
+ ))}
+
+ )
+}
+
+function App() {
+ return (
+ Loading...}>
+
+
+ )
+}
+```
+
+**Implementation details:**
+
+- Throws promises when collection is loading (caught by Suspense)
+- Throws errors when collection fails (caught by Error Boundary)
+- Reuses promise across re-renders to prevent infinite loops
+- Detects dependency changes and creates new collection/promise
+- Same TypeScript overloads as useLiveQuery for consistency
+
+**Documentation:**
+
+- Comprehensive guide in live-queries.md covering usage patterns and when to use each hook
+- Comparison with useLiveQuery showing different approaches to loading/error states
+- Router loader pattern recommendation for React Router/TanStack Router users
+- Error handling examples with Suspense and Error Boundaries
+
+Resolves #692
diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md
index 7661c240b..0ec877231 100644
--- a/docs/guides/error-handling.md
+++ b/docs/guides/error-handling.md
@@ -123,6 +123,36 @@ Collection status values:
- `error` - In error state
- `cleaned-up` - Cleaned up and no longer usable
+### Using Suspense and Error Boundaries (React)
+
+For React applications, you can handle loading and error states with `useLiveSuspenseQuery`, React Suspense, and Error Boundaries:
+
+```tsx
+import { useLiveSuspenseQuery } from "@tanstack/react-db"
+import { Suspense } from "react"
+import { ErrorBoundary } from "react-error-boundary"
+
+const TodoList = () => {
+ // No need to check status - Suspense and ErrorBoundary handle it
+ const { data } = useLiveSuspenseQuery(
+ (query) => query.from({ todos: todoCollection })
+ )
+
+ // data is always defined here
+ return {data.map(todo =>
{todo.text}
)}
+}
+
+const App = () => (
+ Failed to load todos}>
+ Loading...}>
+
+
+
+)
+```
+
+With this approach, loading states are handled by `` and error states are handled by `` instead of within your component logic. See the [React Suspense section in Live Queries](../live-queries#using-with-react-suspense) for more details.
+
## Transaction Error Handling
When mutations fail, TanStack DB automatically rolls back optimistic updates:
diff --git a/docs/guides/live-queries.md b/docs/guides/live-queries.md
index 5b1664b20..469edb65c 100644
--- a/docs/guides/live-queries.md
+++ b/docs/guides/live-queries.md
@@ -162,6 +162,181 @@ export class UserListComponent {
For more details on framework integration, see the [React](../../framework/react/adapter), [Vue](../../framework/vue/adapter), and [Angular](../../framework/angular/adapter) adapter documentation.
+### Using with React Suspense
+
+For React applications, you can use the `useLiveSuspenseQuery` hook to integrate with React Suspense boundaries. This hook suspends rendering while data loads initially, then streams updates without re-suspending.
+
+```tsx
+import { useLiveSuspenseQuery } from '@tanstack/react-db'
+import { Suspense } from 'react'
+
+function UserList() {
+ // This will suspend until data is ready
+ const { data } = useLiveSuspenseQuery((q) =>
+ q
+ .from({ user: usersCollection })
+ .where(({ user }) => eq(user.active, true))
+ )
+
+ // data is always defined - no need for optional chaining
+ return (
+
+ {data.map(user => (
+ - {user.name}
+ ))}
+
+ )
+}
+
+function App() {
+ return (
+ Loading users...}>
+
+
+ )
+}
+```
+
+#### Type Safety
+
+The key difference from `useLiveQuery` is that `data` is always defined (never `undefined`). The hook suspends during initial load, so by the time your component renders, data is guaranteed to be available:
+
+```tsx
+function UserStats() {
+ const { data } = useLiveSuspenseQuery((q) =>
+ q.from({ user: usersCollection })
+ )
+
+ // TypeScript knows data is Array, not Array | undefined
+ return Total users: {data.length}
+}
+```
+
+#### Error Handling
+
+Combine with Error Boundaries to handle loading errors:
+
+```tsx
+import { ErrorBoundary } from 'react-error-boundary'
+
+function App() {
+ return (
+ Failed to load users}>
+ Loading users...}>
+
+
+
+ )
+}
+```
+
+#### Reactive Updates
+
+After the initial load, data updates stream in without re-suspending:
+
+```tsx
+function UserList() {
+ const { data } = useLiveSuspenseQuery((q) =>
+ q.from({ user: usersCollection })
+ )
+
+ // Suspends once during initial load
+ // After that, data updates automatically when users change
+ // UI never re-suspends for live updates
+ return (
+
+ {data.map(user => (
+ - {user.name}
+ ))}
+
+ )
+}
+```
+
+#### Re-suspending on Dependency Changes
+
+When dependencies change, the hook re-suspends to load new data:
+
+```tsx
+function FilteredUsers({ minAge }: { minAge: number }) {
+ const { data } = useLiveSuspenseQuery(
+ (q) =>
+ q
+ .from({ user: usersCollection })
+ .where(({ user }) => gt(user.age, minAge)),
+ [minAge] // Re-suspend when minAge changes
+ )
+
+ return (
+
+ {data.map(user => (
+ - {user.name} - {user.age}
+ ))}
+
+ )
+}
+```
+
+#### When to Use Which Hook
+
+- **Use `useLiveSuspenseQuery`** when:
+ - You want to use React Suspense for loading states
+ - You prefer handling loading/error states with `` and `` components
+ - You want guaranteed non-undefined data types
+ - The query always needs to run (not conditional)
+
+- **Use `useLiveQuery`** when:
+ - You need conditional/disabled queries
+ - You prefer handling loading/error states within your component
+ - You want to show loading states inline without Suspense
+ - You need access to `status` and `isLoading` flags
+ - **You're using a router with loaders** (React Router, TanStack Router, etc.) - preload in the loader and use `useLiveQuery` in the component
+
+```tsx
+// useLiveQuery - handle states in component
+function UserList() {
+ const { data, status, isLoading } = useLiveQuery((q) =>
+ q.from({ user: usersCollection })
+ )
+
+ if (isLoading) return Loading...
+ if (status === 'error') return Error loading users
+
+ return {data?.map(user => - {user.name}
)}
+}
+
+// useLiveSuspenseQuery - handle states with Suspense/ErrorBoundary
+function UserList() {
+ const { data } = useLiveSuspenseQuery((q) =>
+ q.from({ user: usersCollection })
+ )
+
+ return {data.map(user => - {user.name}
)}
+}
+
+// useLiveQuery with router loader - recommended pattern
+// In your route configuration:
+const route = {
+ path: '/users',
+ loader: async () => {
+ // Preload the collection in the loader
+ await usersCollection.preload()
+ return null
+ },
+ component: UserList,
+}
+
+// In your component:
+function UserList() {
+ // Collection is already loaded, so data is immediately available
+ const { data } = useLiveQuery((q) =>
+ q.from({ user: usersCollection })
+ )
+
+ return {data?.map(user => - {user.name}
)}
+}
+```
+
### Conditional Queries
In React, you can conditionally disable a query by returning `undefined` or `null` from the `useLiveQuery` callback. When disabled, the hook returns a special state indicating the query is not active.
diff --git a/docs/overview.md b/docs/overview.md
index b5597a335..e4de18ef0 100644
--- a/docs/overview.md
+++ b/docs/overview.md
@@ -549,6 +549,34 @@ const Todos = () => {
}
```
+#### `useLiveSuspenseQuery` hook
+
+For React Suspense support, use `useLiveSuspenseQuery`. This hook suspends rendering during initial data load and guarantees that `data` is always defined:
+
+```tsx
+import { useLiveSuspenseQuery } from '@tanstack/react-db'
+import { Suspense } from 'react'
+
+const Todos = () => {
+ // data is always defined - no need for optional chaining
+ const { data: todos } = useLiveSuspenseQuery((q) =>
+ q
+ .from({ todo: todoCollection })
+ .where(({ todo }) => eq(todo.completed, false))
+ )
+
+ return
+}
+
+const App = () => (
+ Loading...}>
+
+
+)
+```
+
+See the [React Suspense section in Live Queries](../guides/live-queries#using-with-react-suspense) for detailed usage patterns and when to use `useLiveSuspenseQuery` vs `useLiveQuery`.
+
#### `queryBuilder`
You can also build queries directly (outside of the component lifecycle) using the underlying `queryBuilder` API:
diff --git a/packages/react-db/src/index.ts b/packages/react-db/src/index.ts
index bb3cd3ad5..9fc0f22ad 100644
--- a/packages/react-db/src/index.ts
+++ b/packages/react-db/src/index.ts
@@ -1,5 +1,6 @@
// Re-export all public APIs
export * from "./useLiveQuery"
+export * from "./useLiveSuspenseQuery"
export * from "./useLiveInfiniteQuery"
// Re-export everything from @tanstack/db
diff --git a/packages/react-db/src/useLiveSuspenseQuery.ts b/packages/react-db/src/useLiveSuspenseQuery.ts
new file mode 100644
index 000000000..920b27867
--- /dev/null
+++ b/packages/react-db/src/useLiveSuspenseQuery.ts
@@ -0,0 +1,178 @@
+import { useRef } from "react"
+import { useLiveQuery } from "./useLiveQuery"
+import type {
+ Collection,
+ Context,
+ GetResult,
+ InferResultType,
+ InitialQueryBuilder,
+ LiveQueryCollectionConfig,
+ NonSingleResult,
+ QueryBuilder,
+ SingleResult,
+} from "@tanstack/db"
+
+/**
+ * Create a live query with React Suspense support
+ * @param queryFn - Query function that defines what data to fetch
+ * @param deps - Array of dependencies that trigger query re-execution when changed
+ * @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 collection fails (caught by Error boundary)
+ * @example
+ * // Basic usage with Suspense
+ * function TodoList() {
+ * const { data } = useLiveSuspenseQuery((q) =>
+ * q.from({ todos: todosCollection })
+ * .where(({ todos }) => eq(todos.completed, false))
+ * .select(({ todos }) => ({ id: todos.id, text: todos.text }))
+ * )
+ *
+ * return (
+ *
+ * {data.map(todo => - {todo.text}
)}
+ *
+ * )
+ * }
+ *
+ * function App() {
+ * return (
+ * Loading...}>
+ *
+ *
+ * )
+ * }
+ *
+ * @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
+ * // With dependencies that trigger re-suspension
+ * const { data } = useLiveSuspenseQuery(
+ * (q) => q.from({ todos: todosCollection })
+ * .where(({ todos }) => gt(todos.priority, minPriority)),
+ * [minPriority] // Re-suspends when minPriority changes
+ * )
+ *
+ * @example
+ * // With Error boundary
+ * function App() {
+ * return (
+ * Error loading data}>
+ * Loading...}>
+ *
+ *
+ *
+ * )
+ * }
+ */
+// Overload 1: Accept query function that always returns QueryBuilder
+export function useLiveSuspenseQuery(
+ queryFn: (q: InitialQueryBuilder) => QueryBuilder,
+ deps?: Array
+): {
+ state: Map>
+ data: InferResultType
+ collection: Collection, string | number, {}>
+}
+
+// Overload 2: Accept config object
+export function useLiveSuspenseQuery(
+ config: LiveQueryCollectionConfig,
+ deps?: Array
+): {
+ state: Map>
+ data: InferResultType
+ collection: Collection, string | number, {}>
+}
+
+// Overload 3: Accept pre-created live query collection
+export function useLiveSuspenseQuery<
+ TResult extends object,
+ TKey extends string | number,
+ TUtils extends Record,
+>(
+ liveQueryCollection: Collection & NonSingleResult
+): {
+ state: Map
+ data: Array
+ collection: Collection
+}
+
+// Overload 4: Accept pre-created live query collection with singleResult: true
+export function useLiveSuspenseQuery<
+ TResult extends object,
+ TKey extends string | number,
+ TUtils extends Record,
+>(
+ liveQueryCollection: Collection & SingleResult
+): {
+ state: Map
+ data: TResult | undefined
+ collection: Collection & SingleResult
+}
+
+// Implementation - uses useLiveQuery internally and adds Suspense logic
+export function useLiveSuspenseQuery(
+ configOrQueryOrCollection: any,
+ deps: Array = []
+) {
+ const promiseRef = useRef | null>(null)
+ const collectionRef = useRef | null>(null)
+ const hasBeenReadyRef = useRef(false)
+
+ // Use useLiveQuery to handle collection management and reactivity
+ const result = useLiveQuery(configOrQueryOrCollection, deps)
+
+ // Reset promise and ready state when collection changes (deps changed)
+ if (collectionRef.current !== result.collection) {
+ promiseRef.current = null
+ collectionRef.current = result.collection
+ hasBeenReadyRef.current = false
+ }
+
+ // Track when we reach ready state
+ if (result.status === `ready`) {
+ hasBeenReadyRef.current = true
+ promiseRef.current = null
+ }
+
+ // 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
+ throw new Error(
+ `useLiveSuspenseQuery does not support disabled queries. Use useLiveQuery instead for conditional queries.`
+ )
+ }
+
+ // Only throw errors during initial load (before first ready)
+ // After success, errors surface as stale data (matches TanStack Query behavior)
+ if (result.status === `error` && !hasBeenReadyRef.current) {
+ promiseRef.current = null
+ throw new Error(`Collection "${result.collection.id}" failed to load`)
+ }
+
+ if (result.status === `loading` || result.status === `idle`) {
+ // Create or reuse promise for current collection
+ if (!promiseRef.current) {
+ promiseRef.current = result.collection.preload()
+ }
+ // THROW PROMISE - React Suspense catches this (React 18+ compatible)
+ throw promiseRef.current
+ }
+
+ // 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: result.collection,
+ }
+}
diff --git a/packages/react-db/tests/useLiveSuspenseQuery.test.tsx b/packages/react-db/tests/useLiveSuspenseQuery.test.tsx
new file mode 100644
index 000000000..36a878bf9
--- /dev/null
+++ b/packages/react-db/tests/useLiveSuspenseQuery.test.tsx
@@ -0,0 +1,605 @@
+import { describe, expect, it } from "vitest"
+import { renderHook, waitFor } from "@testing-library/react"
+import {
+ createCollection,
+ createLiveQueryCollection,
+ eq,
+ gt,
+} from "@tanstack/db"
+import { StrictMode, Suspense } from "react"
+import { useLiveSuspenseQuery } from "../src/useLiveSuspenseQuery"
+import { mockSyncCollectionOptions } from "../../db/tests/utils"
+import type { ReactNode } from "react"
+
+type Person = {
+ id: string
+ name: string
+ age: number
+ email: string
+ isActive: boolean
+ team: string
+}
+
+const initialPersons: Array = [
+ {
+ id: `1`,
+ name: `John Doe`,
+ age: 30,
+ email: `john.doe@example.com`,
+ isActive: true,
+ team: `team1`,
+ },
+ {
+ id: `2`,
+ name: `Jane Doe`,
+ age: 25,
+ email: `jane.doe@example.com`,
+ isActive: true,
+ team: `team2`,
+ },
+ {
+ id: `3`,
+ name: `John Smith`,
+ age: 35,
+ email: `john.smith@example.com`,
+ isActive: true,
+ team: `team1`,
+ },
+]
+
+// Wrapper component with Suspense
+function SuspenseWrapper({ children }: { children: ReactNode }) {
+ return Loading...}>{children}
+}
+
+describe(`useLiveSuspenseQuery`, () => {
+ it(`should suspend while loading and return data when ready`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `test-persons-suspense-1`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ })
+ )
+
+ const { result } = renderHook(
+ () => {
+ return useLiveSuspenseQuery((q) =>
+ q
+ .from({ persons: collection })
+ .where(({ persons }) => gt(persons.age, 30))
+ .select(({ persons }) => ({
+ id: persons.id,
+ name: persons.name,
+ age: persons.age,
+ }))
+ )
+ },
+ {
+ wrapper: SuspenseWrapper,
+ }
+ )
+
+ // Wait for data to load
+ await waitFor(() => {
+ expect(result.current.state.size).toBe(1)
+ })
+
+ expect(result.current.data).toHaveLength(1)
+ const johnSmith = result.current.data[0]
+ expect(johnSmith).toMatchObject({
+ id: `3`,
+ name: `John Smith`,
+ age: 35,
+ })
+ })
+
+ it(`should return data that is always defined (type-safe)`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `test-persons-suspense-2`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ })
+ )
+
+ const { result } = renderHook(
+ () => {
+ return useLiveSuspenseQuery((q) => q.from({ persons: collection }))
+ },
+ {
+ wrapper: SuspenseWrapper,
+ }
+ )
+
+ await waitFor(() => {
+ expect(result.current.data).toBeDefined()
+ })
+
+ // Data is always defined - no optional chaining needed
+ expect(result.current.data.length).toBe(3)
+ // TypeScript will guarantee data is Array, not Array | undefined
+ })
+
+ it(`should work with single result queries`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `test-persons-suspense-3`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ })
+ )
+
+ const { result } = renderHook(
+ () => {
+ return useLiveSuspenseQuery((q) =>
+ q
+ .from({ collection })
+ .where(({ collection: c }) => eq(c.id, `3`))
+ .findOne()
+ )
+ },
+ {
+ wrapper: SuspenseWrapper,
+ }
+ )
+
+ await waitFor(() => {
+ expect(result.current.state.size).toBe(1)
+ })
+
+ expect(result.current.data).toMatchObject({
+ id: `3`,
+ name: `John Smith`,
+ age: 35,
+ })
+ })
+
+ it(`should work with pre-created live query collection`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `test-persons-suspense-4`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ })
+ )
+
+ const liveQuery = createLiveQueryCollection((q) =>
+ q
+ .from({ persons: collection })
+ .where(({ persons }) => gt(persons.age, 30))
+ )
+
+ const { result } = renderHook(() => useLiveSuspenseQuery(liveQuery), {
+ wrapper: SuspenseWrapper,
+ })
+
+ await waitFor(() => {
+ expect(result.current.data).toHaveLength(1)
+ })
+
+ expect(result.current.data[0]).toMatchObject({
+ id: `3`,
+ name: `John Smith`,
+ age: 35,
+ })
+ })
+
+ it(`should re-suspend when deps change`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `test-persons-suspense-5`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ })
+ )
+
+ const { result, rerender } = renderHook(
+ ({ minAge }) => {
+ return useLiveSuspenseQuery(
+ (q) =>
+ q
+ .from({ persons: collection })
+ .where(({ persons }) => gt(persons.age, minAge)),
+ [minAge]
+ )
+ },
+ {
+ wrapper: SuspenseWrapper,
+ initialProps: { minAge: 30 },
+ }
+ )
+
+ // Initial load - age > 30
+ await waitFor(() => {
+ expect(result.current.data).toHaveLength(1)
+ })
+ expect(result.current.data[0]?.age).toBe(35)
+
+ // Change deps - age > 20
+ rerender({ minAge: 20 })
+
+ // Should re-suspend and load new data
+ await waitFor(() => {
+ expect(result.current.data).toHaveLength(3)
+ })
+ })
+
+ it(`should reactively update data after initial load`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `test-persons-suspense-6`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ })
+ )
+
+ const { result } = renderHook(
+ () => useLiveSuspenseQuery((q) => q.from({ persons: collection })),
+ {
+ wrapper: SuspenseWrapper,
+ }
+ )
+
+ // Wait for initial data
+ await waitFor(() => {
+ expect(result.current.data).toHaveLength(3)
+ })
+
+ // Insert new person
+ collection.insert({
+ id: `4`,
+ name: `New Person`,
+ age: 40,
+ email: `new@example.com`,
+ isActive: true,
+ team: `team1`,
+ })
+
+ // Should reactively update
+ await waitFor(() => {
+ expect(result.current.data).toHaveLength(4)
+ })
+ })
+
+ it(`should throw error when query function returns undefined`, () => {
+ expect(() => {
+ renderHook(
+ () => {
+ return useLiveSuspenseQuery(() => undefined as any)
+ },
+ {
+ wrapper: SuspenseWrapper,
+ }
+ )
+ }).toThrow(/does not support disabled queries/)
+ })
+
+ it(`should work with config object`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `test-persons-suspense-7`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ })
+ )
+
+ const { result } = renderHook(
+ () => {
+ return useLiveSuspenseQuery({
+ query: (q) => q.from({ persons: collection }),
+ })
+ },
+ {
+ wrapper: SuspenseWrapper,
+ }
+ )
+
+ await waitFor(() => {
+ expect(result.current.data).toHaveLength(3)
+ })
+ })
+
+ it(`should keep stable data references when data hasn't changed`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `test-persons-suspense-8`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ })
+ )
+
+ const { result, rerender } = renderHook(
+ () => useLiveSuspenseQuery((q) => q.from({ persons: collection })),
+ {
+ wrapper: SuspenseWrapper,
+ }
+ )
+
+ await waitFor(() => {
+ expect(result.current.data).toHaveLength(3)
+ })
+
+ const data1 = result.current.data
+
+ rerender()
+
+ const data2 = result.current.data
+
+ // Data objects should be stable
+ expect(data1[0]).toBe(data2[0])
+ expect(data1[1]).toBe(data2[1])
+ expect(data1[2]).toBe(data2[2])
+ })
+
+ it(`should handle multiple queries in same component (serial execution)`, async () => {
+ const personsCollection = createCollection(
+ mockSyncCollectionOptions({
+ id: `test-persons-suspense-9`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ })
+ )
+
+ const { result } = renderHook(
+ () => {
+ const persons = useLiveSuspenseQuery((q) =>
+ q.from({ persons: personsCollection })
+ )
+ const johnDoe = useLiveSuspenseQuery((q) =>
+ q
+ .from({ persons: personsCollection })
+ .where(({ persons: p }) => eq(p.id, `1`))
+ .findOne()
+ )
+ return { persons, johnDoe }
+ },
+ {
+ wrapper: SuspenseWrapper,
+ }
+ )
+
+ await waitFor(() => {
+ expect(result.current.persons.data).toHaveLength(3)
+ expect(result.current.johnDoe.data).toBeDefined()
+ })
+
+ expect(result.current.johnDoe.data).toMatchObject({
+ id: `1`,
+ name: `John Doe`,
+ })
+ })
+
+ it(`should cleanup collection when unmounted`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `test-persons-suspense-10`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ })
+ )
+
+ const { result, unmount } = renderHook(
+ () => useLiveSuspenseQuery((q) => q.from({ persons: collection })),
+ {
+ wrapper: SuspenseWrapper,
+ }
+ )
+
+ await waitFor(() => {
+ expect(result.current.data).toHaveLength(3)
+ })
+
+ const liveQueryCollection = result.current.collection
+ expect(liveQueryCollection.subscriberCount).toBeGreaterThan(0)
+
+ unmount()
+
+ // Collection should eventually be cleaned up (gcTime is 1ms)
+ await waitFor(
+ () => {
+ expect(liveQueryCollection.status).toBe(`cleaned-up`)
+ },
+ { timeout: 1000 }
+ )
+ })
+
+ it(`should NOT re-suspend on live updates after initial load`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `test-persons-suspense-11`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ })
+ )
+
+ let suspenseCount = 0
+ const SuspenseCounter = ({ children }: { children: ReactNode }) => {
+ return (
+
+ {(() => {
+ suspenseCount++
+ return `Loading...`
+ })()}
+
+ }
+ >
+ {children}
+
+ )
+ }
+
+ const { result } = renderHook(
+ () => useLiveSuspenseQuery((q) => q.from({ persons: collection })),
+ {
+ wrapper: SuspenseCounter,
+ }
+ )
+
+ // Wait for initial load
+ await waitFor(() => {
+ expect(result.current.data).toHaveLength(3)
+ })
+
+ const initialSuspenseCount = suspenseCount
+
+ // Make multiple live updates
+ collection.insert({
+ id: `4`,
+ name: `New Person 1`,
+ age: 40,
+ email: `new1@example.com`,
+ isActive: true,
+ team: `team1`,
+ })
+
+ await waitFor(() => {
+ expect(result.current.data).toHaveLength(4)
+ })
+
+ collection.insert({
+ id: `5`,
+ name: `New Person 2`,
+ age: 45,
+ email: `new2@example.com`,
+ isActive: true,
+ team: `team2`,
+ })
+
+ await waitFor(() => {
+ expect(result.current.data).toHaveLength(5)
+ })
+
+ collection.delete(`4`)
+
+ await waitFor(() => {
+ expect(result.current.data).toHaveLength(4)
+ })
+
+ // Verify suspense count hasn't increased (no re-suspension)
+ expect(suspenseCount).toBe(initialSuspenseCount)
+ })
+
+ it(`should only suspend on deps change, not on every re-render`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `test-persons-suspense-12`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ })
+ )
+
+ const { result, rerender } = renderHook(
+ ({ minAge }) =>
+ useLiveSuspenseQuery(
+ (q) =>
+ q
+ .from({ persons: collection })
+ .where(({ persons }) => gt(persons.age, minAge)),
+ [minAge]
+ ),
+ {
+ wrapper: SuspenseWrapper,
+ initialProps: { minAge: 20 },
+ }
+ )
+
+ // Wait for initial load
+ await waitFor(() => {
+ expect(result.current.data).toHaveLength(3)
+ })
+
+ const dataAfterInitial = result.current.data
+
+ // Re-render with SAME deps - should NOT suspend (data stays available)
+ rerender({ minAge: 20 })
+ expect(result.current.data).toHaveLength(3)
+ expect(result.current.data).toBe(dataAfterInitial)
+
+ rerender({ minAge: 20 })
+ expect(result.current.data).toHaveLength(3)
+
+ rerender({ minAge: 20 })
+ expect(result.current.data).toHaveLength(3)
+
+ // Change deps - SHOULD suspend and get new data
+ rerender({ minAge: 30 })
+
+ await waitFor(() => {
+ expect(result.current.data).toHaveLength(1)
+ })
+
+ expect(result.current.data[0]?.age).toBe(35)
+ })
+
+ it(`should work with pre-created SingleResult collection`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `test-persons-suspense-single`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ })
+ )
+
+ // Pre-create a SingleResult live query collection
+ const singlePersonQuery = createLiveQueryCollection((q) =>
+ q
+ .from({ persons: collection })
+ .where(({ persons }) => eq(persons.id, `1`))
+ .findOne()
+ )
+
+ const { result } = renderHook(
+ () => useLiveSuspenseQuery(singlePersonQuery),
+ {
+ wrapper: SuspenseWrapper,
+ }
+ )
+
+ await waitFor(() => {
+ expect(result.current.data).toBeDefined()
+ })
+
+ expect(result.current.data).toMatchObject({
+ id: `1`,
+ name: `John Doe`,
+ age: 30,
+ })
+ })
+
+ it(`should handle StrictMode double-invocation correctly`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `test-persons-suspense-strict`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ })
+ )
+
+ const StrictModeWrapper = ({ children }: { children: ReactNode }) => (
+
+ Loading...}>{children}
+
+ )
+
+ const { result } = renderHook(
+ () => useLiveSuspenseQuery((q) => q.from({ persons: collection })),
+ {
+ wrapper: StrictModeWrapper,
+ }
+ )
+
+ await waitFor(() => {
+ expect(result.current.data).toHaveLength(3)
+ })
+
+ // Verify data is correct despite double-invocation
+ expect(result.current.data).toHaveLength(3)
+ expect(result.current.data[0]).toMatchObject({
+ id: `1`,
+ name: `John Doe`,
+ })
+ })
+})