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 ( + + ) +} + +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 ( + + ) +} + +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`, + }) + }) +})