(collection)
+ return {
+ collection,
+ insert: (row) => write(`insert`, row),
+ update: (row) => write(`update`, row),
+ remove: (row) => write(`delete`, row),
+ emit: (rows) => {
+ collection.utils.begin()
+ rows.forEach((value) => collection.utils.write({ type: `insert`, value }))
+ collection.utils.commit()
+ },
+ markReady: () => collection.utils.markReady(),
+ }
+}
+
+function makePrecreated(build: QueryBuild, opts?: { startSync?: boolean }) {
+ const collection = createLiveQueryCollection({
+ query: build as any,
+ startSync: opts?.startSync ?? true,
+ })
+ return { collection }
+}
+
+function makeErrorSource() {
+ const collection = createCollection<{ id: string }>({
+ id: `conformance-octane-err-${sourceSeq++}`,
+ getKey: (r) => r.id,
+ startSync: false,
+ sync: {
+ sync: () => {
+ throw new Error(`conformance: sync failure`)
+ },
+ },
+ })
+ // Starting sync throws → engine catches and sets status to `error`.
+ try {
+ collection.startSyncImmediate()
+ } catch {
+ // expected: the rethrown sync error; status is already `error`
+ }
+ return { collection }
+}
+
+function mount(build: QueryBuild) {
+ const hook = renderHook(() => useLiveQuery(build as any))
+ return makeHandle(hook)
+}
+
+function mountCollection(collection: any) {
+ const hook = renderHook(() => useLiveQuery(collection))
+ return makeHandle(hook)
+}
+
+function mountConfig(build: QueryBuild) {
+ const hook = renderHook(() => useLiveQuery({ query: build as any }))
+ return makeHandle(hook)
+}
+
+function mountDisabled() {
+ // React's disabled convention: the query callback returns null.
+ const hook = renderHook(() => useLiveQuery(() => null as any))
+ return makeHandle(hook)
+}
+
+function mountControllable(
+ build: (q: any, param: P) => any,
+ initial: P,
+): ControllableHandle
{
+ const hook = renderHook(
+ ({ param }: { param: P }) =>
+ // Param goes in the dependency list so the hook recompiles when it changes.
+ useLiveQuery((q: any) => build(q, param), [param]),
+ { initialProps: { param: initial } },
+ )
+ const handle = makeHandle(hook)
+ return {
+ ...handle,
+ async setParam(param: P) {
+ await act(async () => {
+ hook.rerender({ param })
+ })
+ await handle.flush()
+ },
+ }
+}
+
+function makeHandle(hook: RenderHookResult) {
+ return {
+ current(): ConformanceResult {
+ const r: any = hook.result.current
+ return {
+ data: r?.data,
+ status: r?.status ?? `idle`,
+ isReady: Boolean(r?.isReady),
+ isError: Boolean(r?.isError),
+ // Read octane-db's real `isEnabled` field so the suite catches a broken
+ // one (deriving from status would mask it).
+ isEnabled: Boolean(r?.isEnabled),
+ }
+ },
+ async flush() {
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ })
+ },
+ async apply(fn: () => void) {
+ await act(async () => {
+ fn()
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ })
+ },
+ unmount() {
+ hook.unmount()
+ },
+ }
+}
+
+const octaneDriver: LiveQueryDriver = {
+ name: `octane`,
+ ops: { eq, gt, count, sum, coalesce, createOptimisticAction },
+ makeSource,
+ makeDeferredSource,
+ makePrecreated,
+ makeErrorSource,
+ mount,
+ mountControllable,
+ mountCollection,
+ mountConfig,
+ mountDisabled,
+ knownGaps: [],
+ features: { serverSnapshot: true, suspense: true },
+}
+
+runSuite(octaneDriver)
diff --git a/packages/octane-db/tests/test-setup.ts b/packages/octane-db/tests/test-setup.ts
new file mode 100644
index 0000000000..6f97af2d70
--- /dev/null
+++ b/packages/octane-db/tests/test-setup.ts
@@ -0,0 +1,10 @@
+import '@testing-library/jest-dom/vitest'
+import { cleanup } from '@octanejs/testing-library'
+import { afterEach } from 'vitest'
+
+declare global {
+ var IS_REACT_ACT_ENVIRONMENT: boolean
+}
+
+global.IS_REACT_ACT_ENVIRONMENT = true
+afterEach(() => cleanup())
diff --git a/packages/octane-db/tests/useLiveInfiniteQuery.test.tsx b/packages/octane-db/tests/useLiveInfiniteQuery.test.tsx
new file mode 100644
index 0000000000..e7c1be467a
--- /dev/null
+++ b/packages/octane-db/tests/useLiveInfiniteQuery.test.tsx
@@ -0,0 +1,1907 @@
+import { describe, expect, it } from 'vitest'
+import { act, renderHook, waitFor } from '@octanejs/testing-library'
+import {
+ BTreeIndex,
+ createCollection,
+ createLiveQueryCollection,
+ eq,
+} from '@tanstack/db'
+import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery'
+import { mockSyncCollectionOptions } from '../../db/tests/utils'
+import { createFilterFunctionFromExpression } from '../../db/src/collection/change-events'
+import type { LoadSubsetOptions } from '@tanstack/db'
+
+type Post = {
+ id: string
+ title: string
+ content: string
+ createdAt: number
+ category: string
+}
+
+function createMockPosts(count: number): Array {
+ const posts: Array = []
+ for (let i = 1; i <= count; i++) {
+ posts.push({
+ id: `${i}`,
+ title: `Post ${i}`,
+ content: `Content ${i}`,
+ createdAt: 1000000 - i * 1000, // Descending order
+ category: i % 2 === 0 ? `tech` : `life`,
+ })
+ }
+ return posts
+}
+
+type OnDemandCollectionOptions = {
+ id: string
+ allPosts: Array
+ autoIndex?: `off` | `eager`
+ asyncDelay?: number
+}
+
+/**
+ * Creates an on-demand collection with a loadSubset handler that supports
+ * sorting, cursor-based pagination, and limit. Returns the collection and
+ * a reference to recorded loadSubset calls for test assertions.
+ */
+function createOnDemandCollection(opts: OnDemandCollectionOptions) {
+ const loadSubsetCalls: Array = []
+ const { id, allPosts, autoIndex, asyncDelay } = opts
+
+ const collection = createCollection({
+ id,
+ getKey: (post: Post) => post.id,
+ syncMode: `on-demand`,
+ startSync: true,
+ autoIndex: autoIndex ?? `eager`,
+ defaultIndexType: BTreeIndex,
+ sync: {
+ sync: ({ markReady, begin, write, commit }) => {
+ markReady()
+
+ return {
+ loadSubset: (subsetOpts: LoadSubsetOptions) => {
+ loadSubsetCalls.push({ ...subsetOpts })
+
+ let filtered = [...allPosts].sort(
+ (a, b) => b.createdAt - a.createdAt,
+ )
+
+ if (subsetOpts.cursor) {
+ const whereFromFn = createFilterFunctionFromExpression(
+ subsetOpts.cursor.whereFrom,
+ )
+ filtered = filtered.filter(whereFromFn)
+ }
+
+ if (subsetOpts.limit !== undefined) {
+ filtered = filtered.slice(0, subsetOpts.limit)
+ }
+
+ function writeAll(): void {
+ begin()
+ for (const post of filtered) {
+ write({ type: `insert`, value: post })
+ }
+ commit()
+ }
+
+ if (asyncDelay !== undefined) {
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ writeAll()
+ resolve()
+ }, asyncDelay)
+ })
+ }
+
+ writeAll()
+ return true
+ },
+ }
+ },
+ },
+ })
+
+ return { collection, loadSubsetCalls }
+}
+
+describe(`useLiveInfiniteQuery`, () => {
+ it(`should fetch initial page of data`, async () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `initial-page-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const { result } = renderHook(() => {
+ return useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`)
+ .select(({ posts: p }) => ({
+ id: p.id,
+ title: p.title,
+ createdAt: p.createdAt,
+ })),
+ {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ },
+ )
+ })
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+
+ // Should have 1 page initially
+ expect(result.current.pages).toHaveLength(1)
+ expect(result.current.pages[0]).toHaveLength(10)
+
+ // Data should be flattened
+ expect(result.current.data).toHaveLength(10)
+
+ // Should have next page since we have 50 items total
+ expect(result.current.hasNextPage).toBe(true)
+
+ // First item should be Post 1 (most recent by createdAt)
+ expect(result.current.pages[0]![0]).toMatchObject({
+ id: `1`,
+ title: `Post 1`,
+ })
+ })
+
+ it(`should fetch multiple pages`, async () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `multiple-pages-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const { result } = renderHook(() => {
+ return useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ },
+ )
+ })
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+
+ // Initially 1 page
+ expect(result.current.pages).toHaveLength(1)
+ expect(result.current.hasNextPage).toBe(true)
+
+ // Fetch next page
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ await waitFor(() => {
+ expect(result.current.pages).toHaveLength(2)
+ })
+
+ expect(result.current.pages[0]).toHaveLength(10)
+ expect(result.current.pages[1]).toHaveLength(10)
+ expect(result.current.data).toHaveLength(20)
+ expect(result.current.hasNextPage).toBe(true)
+
+ // Fetch another page
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ await waitFor(() => {
+ expect(result.current.pages).toHaveLength(3)
+ })
+
+ expect(result.current.data).toHaveLength(30)
+ expect(result.current.hasNextPage).toBe(true)
+ })
+
+ it(`should detect when no more pages available`, async () => {
+ const posts = createMockPosts(25)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `no-more-pages-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const { result } = renderHook(() => {
+ return useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ },
+ )
+ })
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+
+ // Page 1: 10 items, has more
+ expect(result.current.pages).toHaveLength(1)
+ expect(result.current.hasNextPage).toBe(true)
+
+ // Fetch page 2
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ await waitFor(() => {
+ expect(result.current.pages).toHaveLength(2)
+ })
+
+ // Page 2: 10 items, has more
+ expect(result.current.pages[1]).toHaveLength(10)
+ expect(result.current.hasNextPage).toBe(true)
+
+ // Fetch page 3
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ await waitFor(() => {
+ expect(result.current.pages).toHaveLength(3)
+ })
+
+ // Page 3: 5 items, no more
+ expect(result.current.pages[2]).toHaveLength(5)
+ expect(result.current.data).toHaveLength(25)
+ expect(result.current.hasNextPage).toBe(false)
+ })
+
+ it(`should handle empty results`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `empty-results-test`,
+ getKey: (post: Post) => post.id,
+ initialData: [],
+ }),
+ )
+
+ const { result } = renderHook(() => {
+ return useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ },
+ )
+ })
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+
+ // With no data, we still have 1 page (which is empty)
+ expect(result.current.pages).toHaveLength(1)
+ expect(result.current.pages[0]).toHaveLength(0)
+ expect(result.current.data).toHaveLength(0)
+ expect(result.current.hasNextPage).toBe(false)
+ })
+
+ it(`should update pages when underlying data changes`, async () => {
+ const posts = createMockPosts(30)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `live-updates-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const { result } = renderHook(() => {
+ return useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ },
+ )
+ })
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+
+ // Fetch 2 pages
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ await waitFor(() => {
+ expect(result.current.pages).toHaveLength(2)
+ })
+
+ expect(result.current.data).toHaveLength(20)
+
+ // Insert a new post with most recent timestamp
+ act(() => {
+ collection.utils.begin()
+ collection.utils.write({
+ type: `insert`,
+ value: {
+ id: `new-1`,
+ title: `New Post`,
+ content: `New Content`,
+ createdAt: 1000001, // Most recent
+ category: `tech`,
+ },
+ })
+ collection.utils.commit()
+ })
+
+ await waitFor(() => {
+ // New post should be first
+ expect(result.current.pages[0]![0]).toMatchObject({
+ id: `new-1`,
+ title: `New Post`,
+ })
+ })
+
+ // Still showing 2 pages (20 items), but content has shifted
+ // The new item is included, pushing the last item out of view
+ expect(result.current.pages).toHaveLength(2)
+ expect(result.current.data).toHaveLength(20)
+ expect(result.current.pages[0]).toHaveLength(10)
+ expect(result.current.pages[1]).toHaveLength(10)
+ })
+
+ it(`should handle deletions across pages`, async () => {
+ const posts = createMockPosts(25)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `deletions-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const { result } = renderHook(() => {
+ return useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ },
+ )
+ })
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+
+ // Fetch 2 pages
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ await waitFor(() => {
+ expect(result.current.pages).toHaveLength(2)
+ })
+
+ expect(result.current.data).toHaveLength(20)
+ const firstItemId = result.current.data[0]!.id
+
+ // Delete the first item
+ act(() => {
+ collection.utils.begin()
+ collection.utils.write({
+ type: `delete`,
+ value: posts[0]!,
+ })
+ collection.utils.commit()
+ })
+
+ await waitFor(() => {
+ // First item should have changed
+ expect(result.current.data[0]!.id).not.toBe(firstItemId)
+ })
+
+ // Still showing 2 pages, each pulls from remaining 24 items
+ // Page 1: items 0-9 (10 items)
+ // Page 2: items 10-19 (10 items)
+ // Total: 20 items (item 20-23 are beyond our loaded pages)
+ expect(result.current.pages).toHaveLength(2)
+ expect(result.current.data).toHaveLength(20)
+ expect(result.current.pages[0]).toHaveLength(10)
+ expect(result.current.pages[1]).toHaveLength(10)
+ })
+
+ it(`should handle deletion from partial page with descending order`, async () => {
+ // Create only 5 items - fewer than the pageSize of 20
+ const posts = createMockPosts(5)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `partial-page-deletion-desc-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const { result } = renderHook(() => {
+ return useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 20,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 20 ? lastPage.length : undefined,
+ },
+ )
+ })
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+
+ // Should have all 5 items on one page (partial page)
+ expect(result.current.pages).toHaveLength(1)
+ expect(result.current.data).toHaveLength(5)
+ expect(result.current.hasNextPage).toBe(false)
+
+ // Verify the first item (most recent by createdAt descending)
+ const firstItemId = result.current.data[0]!.id
+ expect(firstItemId).toBe(`1`) // Post 1 has the highest createdAt
+
+ // Delete the first item (the one that appears first in descending order)
+ act(() => {
+ collection.utils.begin()
+ collection.utils.write({
+ type: `delete`,
+ value: posts[0]!, // Post 1
+ })
+ collection.utils.commit()
+ })
+
+ // The deleted item should disappear from the result
+ await waitFor(() => {
+ expect(result.current.data).toHaveLength(4)
+ })
+
+ // Verify the deleted item is no longer in the data
+ expect(
+ result.current.data.find((p) => p.id === firstItemId),
+ ).toBeUndefined()
+
+ // Verify the new first item is Post 2
+ expect(result.current.data[0]!.id).toBe(`2`)
+
+ // Still should have 1 page with 4 items
+ expect(result.current.pages).toHaveLength(1)
+ expect(result.current.pages[0]).toHaveLength(4)
+ expect(result.current.hasNextPage).toBe(false)
+ })
+
+ it(`should handle deletion from partial page with ascending order`, async () => {
+ // Create only 5 items - fewer than the pageSize of 20
+ const posts = createMockPosts(5)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `partial-page-deletion-asc-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const { result } = renderHook(() => {
+ return useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `asc`), // ascending order
+ {
+ pageSize: 20,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 20 ? lastPage.length : undefined,
+ },
+ )
+ })
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+
+ // Should have all 5 items on one page (partial page)
+ expect(result.current.pages).toHaveLength(1)
+ expect(result.current.data).toHaveLength(5)
+ expect(result.current.hasNextPage).toBe(false)
+
+ // In ascending order, Post 5 has the lowest createdAt and appears first
+ const firstItemId = result.current.data[0]!.id
+ expect(firstItemId).toBe(`5`) // Post 5 has the lowest createdAt
+
+ // Delete the first item (the one that appears first in ascending order)
+ act(() => {
+ collection.utils.begin()
+ collection.utils.write({
+ type: `delete`,
+ value: posts[4]!, // Post 5 (index 4 in array)
+ })
+ collection.utils.commit()
+ })
+
+ // The deleted item should disappear from the result
+ await waitFor(() => {
+ expect(result.current.data).toHaveLength(4)
+ })
+
+ // Verify the deleted item is no longer in the data
+ expect(
+ result.current.data.find((p) => p.id === firstItemId),
+ ).toBeUndefined()
+
+ // Still should have 1 page with 4 items
+ expect(result.current.pages).toHaveLength(1)
+ expect(result.current.pages[0]).toHaveLength(4)
+ expect(result.current.hasNextPage).toBe(false)
+ })
+
+ it(`should work with where clauses`, async () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `where-clause-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const { result } = renderHook(() => {
+ return useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .where(({ posts: p }) => eq(p.category, `tech`))
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 5,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 5 ? lastPage.length : undefined,
+ },
+ )
+ })
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+
+ // Should only have tech posts (every even ID)
+ expect(result.current.pages).toHaveLength(1)
+ expect(result.current.pages[0]).toHaveLength(5)
+
+ // All items should be tech category
+ result.current.pages[0]!.forEach((post) => {
+ expect(post.category).toBe(`tech`)
+ })
+
+ // Should have more pages
+ expect(result.current.hasNextPage).toBe(true)
+
+ // Fetch next page
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ await waitFor(() => {
+ expect(result.current.pages).toHaveLength(2)
+ })
+
+ expect(result.current.data).toHaveLength(10)
+ })
+
+ it(`should re-execute query when dependencies change`, async () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `deps-change-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const { result, rerender } = renderHook(
+ ({ category }: { category: string }) => {
+ return useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .where(({ posts: p }) => eq(p.category, category))
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 5,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 5 ? lastPage.length : undefined,
+ },
+ [category],
+ )
+ },
+ { initialProps: { category: `tech` } },
+ )
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+
+ // Fetch 2 pages of tech posts
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ await waitFor(() => {
+ expect(result.current.pages).toHaveLength(2)
+ })
+
+ // Change category to life
+ act(() => {
+ rerender({ category: `life` })
+ })
+
+ await waitFor(() => {
+ // Should reset to 1 page with life posts
+ expect(result.current.pages).toHaveLength(1)
+ })
+
+ // All items should be life category
+ result.current.pages[0]!.forEach((post) => {
+ expect(post.category).toBe(`life`)
+ })
+ })
+
+ it(`should track pageParams correctly`, async () => {
+ const posts = createMockPosts(30)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `page-params-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const { result } = renderHook(() => {
+ return useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ initialPageParam: 0,
+ getNextPageParam: (lastPage, _allPages, lastPageParam) =>
+ lastPage.length === 10 ? lastPageParam + 1 : undefined,
+ },
+ )
+ })
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+
+ expect(result.current.pageParams).toEqual([0])
+
+ // Fetch next page
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ await waitFor(() => {
+ expect(result.current.pageParams).toEqual([0, 1])
+ })
+
+ // Fetch another page
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ await waitFor(() => {
+ expect(result.current.pageParams).toEqual([0, 1, 2])
+ })
+ })
+
+ it(`should handle exact page size boundaries`, async () => {
+ const posts = createMockPosts(20) // Exactly 2 pages
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `exact-boundary-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const { result } = renderHook(() => {
+ return useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ // Better getNextPageParam that checks against total data available
+ getNextPageParam: (lastPage, allPages) => {
+ // If last page is not full, we're done
+ if (lastPage.length < 10) return undefined
+ // Check if we've likely loaded all data (this is a heuristic)
+ // In a real app with backend, you'd check response metadata
+ const totalLoaded = allPages.flat().length
+ // If we have less than a full page left, no more pages
+ return totalLoaded
+ },
+ },
+ )
+ })
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+
+ expect(result.current.hasNextPage).toBe(true)
+
+ // Fetch page 2
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ await waitFor(() => {
+ expect(result.current.pages).toHaveLength(2)
+ })
+
+ expect(result.current.pages[1]).toHaveLength(10)
+ // With setWindow peek-ahead, we can now detect no more pages immediately
+ // We request 21 items (2 * 10 + 1 peek) but only get 20, so we know there's no more
+ expect(result.current.hasNextPage).toBe(false)
+
+ // Verify total data
+ expect(result.current.data).toHaveLength(20)
+ })
+
+ it(`should not fetch when already fetching`, async () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `concurrent-fetch-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const { result } = renderHook(() => {
+ return useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ },
+ )
+ })
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+
+ expect(result.current.pages).toHaveLength(1)
+
+ // With sync data, all fetches complete immediately, so all 3 calls will succeed
+ // The key is that they won't cause race conditions or errors
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ await waitFor(() => {
+ expect(result.current.pages).toHaveLength(2)
+ })
+
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ await waitFor(() => {
+ expect(result.current.pages).toHaveLength(3)
+ })
+
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ await waitFor(() => {
+ expect(result.current.pages).toHaveLength(4)
+ })
+
+ // All fetches should have succeeded
+ expect(result.current.pages).toHaveLength(4)
+ expect(result.current.data).toHaveLength(40)
+ })
+
+ it(`should not fetch when hasNextPage is false`, async () => {
+ const posts = createMockPosts(5)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `no-fetch-when-done-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const { result } = renderHook(() => {
+ return useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ },
+ )
+ })
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+
+ expect(result.current.hasNextPage).toBe(false)
+ expect(result.current.pages).toHaveLength(1)
+
+ // Try to fetch when there's no next page
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ await new Promise((resolve) => setTimeout(resolve, 50))
+
+ // Should still have only 1 page
+ expect(result.current.pages).toHaveLength(1)
+ })
+
+ it(`should support custom initialPageParam`, async () => {
+ const posts = createMockPosts(30)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `initial-param-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const { result } = renderHook(() => {
+ return useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ initialPageParam: 100,
+ getNextPageParam: (lastPage, _allPages, lastPageParam) =>
+ lastPage.length === 10 ? lastPageParam + 1 : undefined,
+ },
+ )
+ })
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+
+ expect(result.current.pageParams).toEqual([100])
+
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ await waitFor(() => {
+ expect(result.current.pageParams).toEqual([100, 101])
+ })
+ })
+
+ it(`should detect hasNextPage change when new items are synced`, async () => {
+ // Start with exactly 20 items (2 pages)
+ const posts = createMockPosts(20)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `sync-detection-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const { result } = renderHook(() => {
+ return useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ },
+ )
+ })
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+
+ // Load both pages
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ await waitFor(() => {
+ expect(result.current.pages).toHaveLength(2)
+ })
+
+ // Should have no next page (exactly 20 items, 2 full pages, peek returns nothing)
+ expect(result.current.hasNextPage).toBe(false)
+ expect(result.current.data).toHaveLength(20)
+
+ // Add 5 more items to the collection
+ act(() => {
+ collection.utils.begin()
+ for (let i = 0; i < 5; i++) {
+ collection.utils.write({
+ type: `insert`,
+ value: {
+ id: `new-${i}`,
+ title: `New Post ${i}`,
+ content: `Content ${i}`,
+ createdAt: Date.now() + i,
+ category: `tech`,
+ },
+ })
+ }
+ collection.utils.commit()
+ })
+
+ // Should now detect that there's a next page available
+ await waitFor(() => {
+ expect(result.current.hasNextPage).toBe(true)
+ })
+
+ // Data should still be 20 items (we haven't fetched the next page yet)
+ expect(result.current.data).toHaveLength(20)
+ expect(result.current.pages).toHaveLength(2)
+
+ // Fetch the next page
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ await waitFor(() => {
+ expect(result.current.pages).toHaveLength(3)
+ })
+
+ // Third page should have the new items
+ expect(result.current.pages[2]).toHaveLength(5)
+ expect(result.current.data).toHaveLength(25)
+
+ // No more pages available now
+ expect(result.current.hasNextPage).toBe(false)
+ })
+
+ it(`should set isFetchingNextPage to false when data is immediately available`, async () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `immediate-data-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const { result } = renderHook(() => {
+ return useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ },
+ )
+ })
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+
+ // Initially 1 page and not fetching
+ expect(result.current.pages).toHaveLength(1)
+ expect(result.current.isFetchingNextPage).toBe(false)
+
+ // Fetch next page - should remain false because data is immediately available
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ // Since data is *synchronously* available, isFetchingNextPage should be false
+ expect(result.current.pages).toHaveLength(2)
+ expect(result.current.isFetchingNextPage).toBe(false)
+ })
+
+ it(`should request limit+1 (peek-ahead) from loadSubset for hasNextPage detection`, async () => {
+ // Verifies that useLiveInfiniteQuery requests pageSize+1 items from loadSubset
+ // to detect whether there are more pages available (peek-ahead strategy)
+ const PAGE_SIZE = 10
+ const { collection, loadSubsetCalls } = createOnDemandCollection({
+ id: `peek-ahead-limit-test`,
+ allPosts: createMockPosts(PAGE_SIZE), // Exactly PAGE_SIZE posts
+ })
+
+ const { result } = renderHook(() => {
+ return useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: PAGE_SIZE,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === PAGE_SIZE ? lastPage.length : undefined,
+ },
+ )
+ })
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+
+ const callWithLimit = loadSubsetCalls.find(
+ (call) => call.limit !== undefined,
+ )
+ expect(callWithLimit).toBeDefined()
+ expect(callWithLimit!.limit).toBe(PAGE_SIZE + 1)
+
+ // With exactly PAGE_SIZE posts, hasNextPage should be false (no peek-ahead item returned)
+ expect(result.current.hasNextPage).toBe(false)
+ expect(result.current.data).toHaveLength(PAGE_SIZE)
+ })
+
+ it(`should detect hasNextPage via peek-ahead with exactly pageSize+1 items in on-demand collection`, async () => {
+ // Boundary test: with exactly pageSize+1 items, the peek-ahead item should
+ // signal hasNextPage=true but NOT appear in user-visible data
+ const PAGE_SIZE = 10
+ const { collection } = createOnDemandCollection({
+ id: `peek-ahead-boundary-test`,
+ allPosts: createMockPosts(PAGE_SIZE + 1),
+ })
+
+ const { result } = renderHook(() => {
+ return useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: PAGE_SIZE,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === PAGE_SIZE ? lastPage.length : undefined,
+ },
+ )
+ })
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+
+ // Peek-ahead item detected: hasNextPage should be true
+ expect(result.current.hasNextPage).toBe(true)
+ // But user-visible data should be exactly pageSize (peek-ahead excluded)
+ expect(result.current.data).toHaveLength(PAGE_SIZE)
+ expect(result.current.pages).toHaveLength(1)
+ expect(result.current.pages[0]).toHaveLength(PAGE_SIZE)
+ })
+
+ it(`should work with on-demand collection and fetch multiple pages`, async () => {
+ // End-to-end test: on-demand collection where ALL data comes from loadSubset
+ // (no initial data). Simulates the real Electric on-demand scenario.
+ const PAGE_SIZE = 10
+ const { collection, loadSubsetCalls } = createOnDemandCollection({
+ id: `on-demand-e2e-test`,
+ allPosts: createMockPosts(25), // 2 full pages + 5 items
+ autoIndex: `eager`,
+ })
+
+ const { result } = renderHook(() => {
+ return useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: PAGE_SIZE,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === PAGE_SIZE ? lastPage.length : undefined,
+ },
+ )
+ })
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+
+ // Page 1: 10 items
+ expect(result.current.pages).toHaveLength(1)
+ expect(result.current.data).toHaveLength(PAGE_SIZE)
+ expect(result.current.hasNextPage).toBe(true)
+ expect(result.current.data[0]!.id).toBe(`1`)
+ expect(result.current.data[9]!.id).toBe(`10`)
+
+ // Fetch page 2
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ await waitFor(() => {
+ expect(result.current.pages).toHaveLength(2)
+ })
+
+ expect(loadSubsetCalls.length).toBeGreaterThan(1)
+ expect(result.current.data).toHaveLength(20)
+ expect(result.current.hasNextPage).toBe(true)
+ expect(result.current.pages[1]![0]!.id).toBe(`11`)
+ expect(result.current.pages[1]![9]!.id).toBe(`20`)
+
+ // Fetch page 3 (partial page)
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ await waitFor(() => {
+ expect(result.current.pages).toHaveLength(3)
+ })
+
+ expect(result.current.data).toHaveLength(25)
+ expect(result.current.pages[2]).toHaveLength(5)
+ expect(result.current.hasNextPage).toBe(false)
+ expect(result.current.pages[2]![0]!.id).toBe(`21`)
+ expect(result.current.pages[2]![4]!.id).toBe(`25`)
+ })
+
+ it(`should work with on-demand collection with async loadSubset`, async () => {
+ // Same as the sync on-demand test, but loadSubset returns a Promise
+ // to simulate async network requests (the real Electric scenario).
+ const PAGE_SIZE = 10
+ const { collection, loadSubsetCalls } = createOnDemandCollection({
+ id: `on-demand-async-test`,
+ allPosts: createMockPosts(25),
+ autoIndex: `eager`,
+ asyncDelay: 10,
+ })
+
+ const { result } = renderHook(() => {
+ return useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: PAGE_SIZE,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === PAGE_SIZE ? lastPage.length : undefined,
+ },
+ )
+ })
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+
+ await waitFor(() => {
+ expect(result.current.data).toHaveLength(PAGE_SIZE)
+ })
+
+ expect(result.current.pages).toHaveLength(1)
+ expect(result.current.hasNextPage).toBe(true)
+
+ const initialCallCount = loadSubsetCalls.length
+
+ // Fetch page 2
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ expect(result.current.isFetchingNextPage).toBe(true)
+
+ await waitFor(
+ () => {
+ expect(result.current.data).toHaveLength(20)
+ },
+ { timeout: 500 },
+ )
+
+ expect(result.current.pages).toHaveLength(2)
+ expect(loadSubsetCalls.length).toBeGreaterThan(initialCallCount)
+ expect(result.current.hasNextPage).toBe(true)
+
+ // Fetch page 3 (partial page) to verify async path handles end-of-data
+ const callCountBeforePage3 = loadSubsetCalls.length
+
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ await waitFor(
+ () => {
+ expect(result.current.data).toHaveLength(25)
+ },
+ { timeout: 500 },
+ )
+
+ expect(result.current.pages).toHaveLength(3)
+ expect(result.current.pages[2]).toHaveLength(5)
+ expect(loadSubsetCalls.length).toBeGreaterThan(callCountBeforePage3)
+ expect(result.current.hasNextPage).toBe(false)
+ })
+
+ it(`should track isFetchingNextPage when async loading is triggered`, async () => {
+ // Define all data upfront
+ const allPosts = createMockPosts(30)
+
+ const collection = createCollection({
+ id: `async-loading-test`,
+ getKey: (post: Post) => post.id,
+ syncMode: `on-demand`,
+ startSync: true,
+ autoIndex: `eager`,
+ defaultIndexType: BTreeIndex,
+ sync: {
+ sync: ({ markReady, begin, write, commit }) => {
+ // Provide initial data by slicing the first 15 elements
+ begin()
+ const initialPosts = allPosts.slice(0, 15)
+ for (const post of initialPosts) {
+ write({
+ type: `insert`,
+ value: post,
+ })
+ }
+ commit()
+ markReady()
+
+ return {
+ loadSubset: (opts: LoadSubsetOptions) => {
+ // Filter the data array based on opts
+ let filtered = allPosts
+
+ // Apply where clause if provided
+ if (opts.where) {
+ const filterFn = createFilterFunctionFromExpression(opts.where)
+ filtered = filtered.filter(filterFn)
+ }
+
+ // Sort by createdAt descending if orderBy is provided
+ if (opts.orderBy && opts.orderBy.length > 0) {
+ filtered = filtered.sort((a, b) => {
+ // We know ordering is always by createdAt descending
+ return b.createdAt - a.createdAt
+ })
+ }
+
+ // Apply cursor expressions if present (new cursor-based pagination)
+ if (opts.cursor) {
+ const { whereFrom, whereCurrent } = opts.cursor
+ try {
+ const whereFromFn =
+ createFilterFunctionFromExpression(whereFrom)
+ const fromData = filtered.filter(whereFromFn)
+
+ const whereCurrentFn =
+ createFilterFunctionFromExpression(whereCurrent)
+ const currentData = filtered.filter(whereCurrentFn)
+
+ // Combine current (ties) with from (next page), deduplicate
+ const seenIds = new Set()
+ filtered = []
+ for (const item of currentData) {
+ if (!seenIds.has(item.id)) {
+ seenIds.add(item.id)
+ filtered.push(item)
+ }
+ }
+ // Apply limit only to fromData
+ const limitedFromData = opts.limit
+ ? fromData.slice(0, opts.limit)
+ : fromData
+ for (const item of limitedFromData) {
+ if (!seenIds.has(item.id)) {
+ seenIds.add(item.id)
+ filtered.push(item)
+ }
+ }
+ // Re-sort after combining
+ filtered.sort((a, b) => b.createdAt - a.createdAt)
+ } catch (e) {
+ throw new Error(`Test loadSubset: cursor parsing failed`, {
+ cause: e,
+ })
+ }
+ } else if (opts.limit !== undefined) {
+ // Apply limit only if no cursor (cursor handles limit internally)
+ filtered = filtered.slice(0, opts.limit)
+ }
+
+ // Subsequent calls simulate async loading with a real timeout
+ const loadPromise = new Promise((resolve) => {
+ setTimeout(() => {
+ begin()
+
+ // Insert the requested posts
+ for (const post of filtered) {
+ write({
+ type: `insert`,
+ value: post,
+ })
+ }
+
+ commit()
+ resolve()
+ }, 50)
+ })
+
+ return loadPromise
+ },
+ }
+ },
+ },
+ })
+
+ const { result } = renderHook(() => {
+ return useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ },
+ )
+ })
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+
+ // Wait for initial window setup to complete
+ await waitFor(() => {
+ expect(result.current.isFetchingNextPage).toBe(false)
+ })
+
+ expect(result.current.pages).toHaveLength(1)
+
+ // Fetch next page which will trigger async loading
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ // Should be fetching now and so isFetchingNextPage should be true *synchronously!*
+ expect(result.current.isFetchingNextPage).toBe(true)
+
+ // Wait for loading to complete
+ await waitFor(
+ () => {
+ expect(result.current.isFetchingNextPage).toBe(false)
+ },
+ { timeout: 200 },
+ )
+
+ // Should have 2 pages now
+ expect(result.current.pages).toHaveLength(2)
+ expect(result.current.data).toHaveLength(20)
+ }, 10000)
+
+ describe(`pre-created collections`, () => {
+ it(`should accept pre-created live query collection`, async () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `pre-created-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const liveQueryCollection = createLiveQueryCollection({
+ query: (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`)
+ .limit(5), // Initial limit
+ })
+
+ await liveQueryCollection.preload()
+
+ const { result } = renderHook(() => {
+ return useLiveInfiniteQuery(liveQueryCollection, {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ })
+ })
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+
+ // Should have 1 page initially
+ expect(result.current.pages).toHaveLength(1)
+ expect(result.current.pages[0]).toHaveLength(10)
+ expect(result.current.data).toHaveLength(10)
+ expect(result.current.hasNextPage).toBe(true)
+
+ // First item should be Post 1 (most recent by createdAt)
+ expect(result.current.pages[0]![0]).toMatchObject({
+ id: `1`,
+ title: `Post 1`,
+ })
+ })
+
+ it(`should fetch multiple pages with pre-created collection`, async () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `pre-created-multi-page-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const liveQueryCollection = createLiveQueryCollection({
+ query: (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`)
+ .limit(10)
+ .offset(0),
+ })
+
+ await liveQueryCollection.preload()
+
+ const { result } = renderHook(() => {
+ return useLiveInfiniteQuery(liveQueryCollection, {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ })
+ })
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+
+ expect(result.current.pages).toHaveLength(1)
+ expect(result.current.hasNextPage).toBe(true)
+
+ // Fetch next page
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ await waitFor(() => {
+ expect(result.current.pages).toHaveLength(2)
+ })
+
+ expect(result.current.pages[0]).toHaveLength(10)
+ expect(result.current.pages[1]).toHaveLength(10)
+ expect(result.current.data).toHaveLength(20)
+ expect(result.current.hasNextPage).toBe(true)
+ })
+
+ it(`should reset pagination when collection instance changes`, async () => {
+ const posts1 = createMockPosts(30)
+ const collection1 = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `pre-created-reset-1`,
+ getKey: (post: Post) => post.id,
+ initialData: posts1,
+ }),
+ )
+
+ const liveQueryCollection1 = createLiveQueryCollection({
+ query: (q) =>
+ q
+ .from({ posts: collection1 })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`)
+ .limit(10)
+ .offset(0),
+ })
+
+ await liveQueryCollection1.preload()
+
+ const posts2 = createMockPosts(40)
+ const collection2 = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `pre-created-reset-2`,
+ getKey: (post: Post) => post.id,
+ initialData: posts2,
+ }),
+ )
+
+ const liveQueryCollection2 = createLiveQueryCollection({
+ query: (q) =>
+ q
+ .from({ posts: collection2 })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`)
+ .limit(10)
+ .offset(0),
+ })
+
+ await liveQueryCollection2.preload()
+
+ const { result, rerender } = renderHook(
+ ({ coll }: { coll: any }) => {
+ return useLiveInfiniteQuery(coll, {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ })
+ },
+ { initialProps: { coll: liveQueryCollection1 } },
+ )
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+
+ // Fetch 2 pages
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ await waitFor(() => {
+ expect(result.current.pages).toHaveLength(2)
+ })
+
+ expect(result.current.data).toHaveLength(20)
+
+ // Switch to second collection
+ act(() => {
+ rerender({ coll: liveQueryCollection2 })
+ })
+
+ await waitFor(() => {
+ // Should reset to 1 page
+ expect(result.current.pages).toHaveLength(1)
+ })
+
+ expect(result.current.data).toHaveLength(10)
+ })
+
+ it.skip(`should throw error if collection lacks orderBy`, async () => {
+ // Octane runs this validation in passive effects; the error is logged but
+ // not surfaced synchronously to renderHook expect().toThrow like React act().
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `no-orderby-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ // Create collection WITHOUT orderBy
+ const liveQueryCollection = createLiveQueryCollection({
+ query: (q) => q.from({ posts: collection }),
+ })
+
+ await liveQueryCollection.preload()
+
+ // Should throw when trying to use a collection without orderBy
+ expect(() => {
+ renderHook(() => {
+ return useLiveInfiniteQuery(liveQueryCollection, {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ })
+ })
+ }).toThrow(/ORDER BY|setWindow\(\)/)
+ })
+
+ it(`should throw error if first argument is not a collection or function`, () => {
+ // Should throw error when passing invalid types
+ expect(() => {
+ renderHook(() => {
+ return useLiveInfiniteQuery(`not a collection or function` as any, {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ })
+ })
+ }).toThrow(/must be either a pre-created live query collection/)
+
+ expect(() => {
+ renderHook(() => {
+ return useLiveInfiniteQuery(123 as any, {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ })
+ })
+ }).toThrow(/must be either a pre-created live query collection/)
+
+ expect(() => {
+ renderHook(() => {
+ return useLiveInfiniteQuery(null as any, {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ })
+ })
+ }).toThrow(/must be either a pre-created live query collection/)
+ })
+
+ it(`should work correctly even if pre-created collection has different initial limit`, async () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `mismatched-window-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const liveQueryCollection = createLiveQueryCollection({
+ query: (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`)
+ .limit(5) // Different from pageSize
+ .offset(0),
+ })
+
+ await liveQueryCollection.preload()
+
+ const { result } = renderHook(() => {
+ return useLiveInfiniteQuery(liveQueryCollection, {
+ pageSize: 10, // Different from the initial limit of 5
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ })
+ })
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+
+ // Should work correctly despite different initial limit
+ // The window will be adjusted to match pageSize
+ expect(result.current.pages).toHaveLength(1)
+ expect(result.current.pages[0]).toHaveLength(10)
+ expect(result.current.data).toHaveLength(10)
+ expect(result.current.hasNextPage).toBe(true)
+ })
+
+ it(`should handle live updates with pre-created collection`, async () => {
+ const posts = createMockPosts(30)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `pre-created-live-updates-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const liveQueryCollection = createLiveQueryCollection({
+ query: (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`)
+ .limit(10)
+ .offset(0),
+ })
+
+ await liveQueryCollection.preload()
+
+ const { result } = renderHook(() => {
+ return useLiveInfiniteQuery(liveQueryCollection, {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ })
+ })
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+
+ // Fetch 2 pages
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ await waitFor(() => {
+ expect(result.current.pages).toHaveLength(2)
+ })
+
+ expect(result.current.data).toHaveLength(20)
+
+ // Insert a new post with most recent timestamp
+ act(() => {
+ collection.utils.begin()
+ collection.utils.write({
+ type: `insert`,
+ value: {
+ id: `new-1`,
+ title: `New Post`,
+ content: `New Content`,
+ createdAt: 1000001, // Most recent
+ category: `tech`,
+ },
+ })
+ collection.utils.commit()
+ })
+
+ await waitFor(() => {
+ // New post should be first
+ expect(result.current.pages[0]![0]).toMatchObject({
+ id: `new-1`,
+ title: `New Post`,
+ })
+ })
+
+ // Still showing 2 pages (20 items), but content has shifted
+ expect(result.current.pages).toHaveLength(2)
+ expect(result.current.data).toHaveLength(20)
+ })
+
+ it(`should work with router loader pattern (preloaded collection)`, async () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `router-loader-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ // Simulate router loader: create and preload collection
+ const loaderQuery = createLiveQueryCollection({
+ query: (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`)
+ .limit(20),
+ })
+
+ // Preload in loader
+ await loaderQuery.preload()
+
+ // Simulate component receiving preloaded collection
+ const { result } = renderHook(() => {
+ return useLiveInfiniteQuery(loaderQuery, {
+ pageSize: 20,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 20 ? lastPage.length : undefined,
+ })
+ })
+
+ // Should be immediately ready since it was preloaded
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+
+ expect(result.current.pages).toHaveLength(1)
+ expect(result.current.pages[0]).toHaveLength(20)
+ expect(result.current.data).toHaveLength(20)
+ expect(result.current.hasNextPage).toBe(true)
+
+ // Can still fetch more pages
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ await waitFor(() => {
+ expect(result.current.pages).toHaveLength(2)
+ })
+
+ expect(result.current.data).toHaveLength(40)
+ })
+ })
+
+ it(`throws a descriptive error when deps contain non-serializable values`, () => {
+ const posts = createMockPosts(10)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `circular-deps-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const circular: Record = { a: 1 }
+ circular.self = circular
+
+ expect(() => {
+ renderHook(() => {
+ return useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 5,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 5 ? lastPage.length : undefined,
+ },
+ [circular],
+ )
+ })
+ }).toThrow(/useLiveInfiniteQuery.*dependency/)
+ })
+})
diff --git a/packages/octane-db/tests/useLiveQuery.eager-onstorechange.test.tsx b/packages/octane-db/tests/useLiveQuery.eager-onstorechange.test.tsx
new file mode 100644
index 0000000000..b60dcbe122
--- /dev/null
+++ b/packages/octane-db/tests/useLiveQuery.eager-onstorechange.test.tsx
@@ -0,0 +1,65 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import { renderHook } from '@octanejs/testing-library'
+import { createCollection, createLiveQueryCollection } from '@tanstack/db'
+import { useLiveQuery } from '../src/useLiveQuery'
+import { mockSyncCollectionOptions } from '../../db/tests/utils'
+import type * as OctaneNS from 'octane'
+
+// Intercept octane.useSyncExternalStore so we can capture the `subscribe`
+// callback that `useLiveQuery` registers and assert that it does not invoke
+// `onStoreChange` synchronously when the collection is already ready.
+let capturedSubscribe: ((cb: () => void) => () => void) | null = null
+
+vi.mock('octane', async () => {
+ const actual = await vi.importActual('octane')
+ return {
+ ...actual,
+ useSyncExternalStore: (subscribe: any, getSnapshot: any) => {
+ capturedSubscribe = subscribe
+ return getSnapshot()
+ },
+ }
+})
+
+type Person = { id: string; name: string; age: number }
+
+const initialPersons: Array = [
+ { id: `1`, name: `A`, age: 10 },
+ { id: `2`, name: `B`, age: 20 },
+]
+
+describe(`useLiveQuery: eager onStoreChange must not fire synchronously during subscribe`, () => {
+ it(`defers the initial ready-state onStoreChange to a microtask`, async () => {
+ const base = createCollection(
+ mockSyncCollectionOptions({
+ id: `eager-onstorechange-persons`,
+ getKey: (p) => p.id,
+ initialData: initialPersons,
+ }),
+ )
+
+ const lqc = createLiveQueryCollection({
+ startSync: true,
+ query: (q) => q.from({ persons: base }),
+ })
+ await lqc.preload()
+ expect(lqc.status).toBe(`ready`)
+
+ capturedSubscribe = null
+ renderHook(() => useLiveQuery(lqc))
+ expect(capturedSubscribe).toBeTypeOf(`function`)
+
+ const onStoreChange = vi.fn()
+ const unsub = capturedSubscribe!(onStoreChange)
+
+ // onStoreChange must not be invoked synchronously inside subscribe;
+ // it should be deferred to a microtask so it lands after the commit.
+ expect(onStoreChange).not.toHaveBeenCalled()
+
+ await Promise.resolve()
+ expect(onStoreChange).toHaveBeenCalledTimes(1)
+
+ unsub()
+ })
+})
diff --git a/packages/octane-db/tests/useLiveQuery.test-d.tsx b/packages/octane-db/tests/useLiveQuery.test-d.tsx
new file mode 100644
index 0000000000..3672385d01
--- /dev/null
+++ b/packages/octane-db/tests/useLiveQuery.test-d.tsx
@@ -0,0 +1,128 @@
+import { describe, expectTypeOf, it } from 'vitest'
+import { renderHook } from '@octanejs/testing-library'
+import { createCollection } from '../../db/src/collection/index'
+import { mockSyncCollectionOptions } from '../../db/tests/utils'
+import {
+ createLiveQueryCollection,
+ eq,
+ liveQueryCollectionOptions,
+} from '../../db/src/query/index'
+import { useLiveQuery } from '../src/useLiveQuery'
+import type { OutputWithVirtual } from '../../db/tests/utils'
+import type { SingleResult } from '../../db/src/types'
+
+type Person = {
+ id: string
+ name: string
+ age: number
+ email: string
+ isActive: boolean
+ team: string
+}
+
+describe(`useLiveQuery type assertions`, () => {
+ it(`should type findOne query builder to return a single row`, () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `test-persons-2`,
+ getKey: (person: Person) => person.id,
+ initialData: [],
+ }),
+ )
+
+ const { result } = renderHook(() => {
+ return useLiveQuery((q) =>
+ q
+ .from({ collection })
+ .where(({ collection: c }) => eq(c.id, `3`))
+ .findOne(),
+ )
+ })
+
+ expectTypeOf(result.current.data).toMatchTypeOf<
+ OutputWithVirtual | undefined
+ >()
+ })
+
+ it(`should type findOne config object to return a single row`, () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `test-persons-2`,
+ getKey: (person: Person) => person.id,
+ initialData: [],
+ }),
+ )
+
+ const { result } = renderHook(() => {
+ return useLiveQuery({
+ query: (q) =>
+ q
+ .from({ collection })
+ .where(({ collection: c }) => eq(c.id, `3`))
+ .findOne(),
+ })
+ })
+
+ expectTypeOf(result.current.data).toMatchTypeOf<
+ OutputWithVirtual | undefined
+ >()
+ })
+
+ it(`should type findOne collection using liveQueryCollectionOptions to return a single row`, () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `test-persons-2`,
+ getKey: (person: Person) => person.id,
+ initialData: [],
+ }),
+ )
+
+ const options = liveQueryCollectionOptions({
+ query: (q) =>
+ q
+ .from({ collection })
+ .where(({ collection: c }) => eq(c.id, `3`))
+ .findOne(),
+ })
+
+ const liveQueryCollection = createCollection(options)
+
+ expectTypeOf(liveQueryCollection).toExtend()
+
+ const { result } = renderHook(() => {
+ return useLiveQuery(liveQueryCollection)
+ })
+
+ expectTypeOf(result.current.data).toMatchTypeOf<
+ OutputWithVirtual | undefined
+ >()
+ })
+
+ it(`should type findOne collection using createLiveQueryCollection to return a single row`, () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `test-persons-2`,
+ getKey: (person: Person) => person.id,
+ initialData: [],
+ }),
+ )
+
+ const liveQueryCollection = createLiveQueryCollection({
+ query: (q) =>
+ q
+ .from({ collection })
+ .where(({ collection: c }) => eq(c.id, `3`))
+ .findOne(),
+ })
+
+ expectTypeOf(liveQueryCollection).toExtend()
+
+ const { result } = renderHook(() => {
+ return useLiveQuery(liveQueryCollection)
+ })
+
+ expectTypeOf(result.current.data).toMatchTypeOf<
+ OutputWithVirtual | undefined
+ >()
+ })
+})
diff --git a/packages/octane-db/tests/useLiveQuery.test.tsx b/packages/octane-db/tests/useLiveQuery.test.tsx
new file mode 100644
index 0000000000..99e00c4991
--- /dev/null
+++ b/packages/octane-db/tests/useLiveQuery.test.tsx
@@ -0,0 +1,2607 @@
+import { describe, expect, it } from 'vitest'
+import { act, renderHook, waitFor } from '@octanejs/testing-library'
+import {
+ Query,
+ coalesce,
+ count,
+ createCollection,
+ createLiveQueryCollection,
+ createOptimisticAction,
+ eq,
+ gt,
+ lte,
+ sum,
+} from '@tanstack/db'
+import { useEffect } from 'octane'
+import { useLiveQuery } from '../src/useLiveQuery'
+import {
+ mockSyncCollectionOptions,
+ stripVirtualProps,
+} from '../../db/tests/utils'
+
+type Person = {
+ id: string
+ name: string
+ age: number
+ email: string
+ isActive: boolean
+ team: string
+}
+
+type Issue = {
+ id: string
+ title: string
+ description: string
+ userId: 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`,
+ },
+]
+
+const initialIssues: Array = [
+ {
+ id: `1`,
+ title: `Issue 1`,
+ description: `Issue 1 description`,
+ userId: `1`,
+ },
+ {
+ id: `2`,
+ title: `Issue 2`,
+ description: `Issue 2 description`,
+ userId: `2`,
+ },
+ {
+ id: `3`,
+ title: `Issue 3`,
+ description: `Issue 3 description`,
+ userId: `1`,
+ },
+]
+
+describe(`Query Collections`, () => {
+ it(`should work with basic collection and select`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `test-persons`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ }),
+ )
+
+ const { result } = renderHook(() => {
+ return useLiveQuery((q) =>
+ q
+ .from({ persons: collection })
+ .where(({ persons }) => gt(persons.age, 30))
+ .select(({ persons }) => ({
+ id: persons.id,
+ name: persons.name,
+ age: persons.age,
+ })),
+ )
+ })
+
+ // Wait for collection to sync and state to update
+ await waitFor(() => {
+ expect(result.current.state.size).toBe(1) // Only John Smith (age 35)
+ })
+ expect(result.current.data).toHaveLength(1)
+
+ const johnSmith = result.current.data[0]
+ expect(johnSmith).toMatchObject({
+ id: `3`,
+ name: `John Smith`,
+ age: 35,
+ })
+ })
+
+ it(`should keep stable ref`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `test-persons`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ }),
+ )
+
+ const { result, rerender } = renderHook(() => {
+ return useLiveQuery((q) =>
+ q
+ .from({ persons: collection })
+ .where(({ persons }) => gt(persons.age, 30))
+ .select(({ persons }) => ({
+ id: persons.id,
+ name: persons.name,
+ age: persons.age,
+ })),
+ )
+ })
+
+ // Wait for collection to sync and state to update
+ await waitFor(() => {
+ expect(result.current.state.size).toBe(1) // Only John Smith (age 35)
+ })
+
+ const data1 = result.current.data
+ expect(result.current.data).toHaveLength(1)
+
+ rerender()
+
+ const data2 = result.current.data
+
+ // Passes cause the underlying objects are stable
+ expect(data1).toEqual(data2)
+ expect(data1[0]).toBe(data2[0])
+
+ // Fails cause array isn't
+ expect(data1).toBe(data2)
+ })
+
+ it(`should be able to return a single row with query builder`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `test-persons-2`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ }),
+ )
+
+ const { result } = renderHook(() => {
+ return useLiveQuery((q) =>
+ q
+ .from({ collection })
+ .where(({ collection: c }) => eq(c.id, `3`))
+ .findOne(),
+ )
+ })
+
+ // Wait for collection to sync
+ await waitFor(() => {
+ expect(result.current.state.size).toBe(1)
+ })
+
+ expect(result.current.state.get(`3`)).toMatchObject({
+ id: `3`,
+ name: `John Smith`,
+ })
+
+ expect(result.current.data).toMatchObject({
+ id: `3`,
+ name: `John Smith`,
+ })
+ })
+
+ it(`should be able to return a single row with config object`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `test-persons-2`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ }),
+ )
+
+ const { result } = renderHook(() => {
+ return useLiveQuery({
+ query: (q) =>
+ q
+ .from({ collection })
+ .where(({ collection: c }) => eq(c.id, `3`))
+ .findOne(),
+ })
+ })
+
+ // Wait for collection to sync
+ await waitFor(() => {
+ expect(result.current.state.size).toBe(1)
+ })
+
+ expect(result.current.state.get(`3`)).toMatchObject({
+ id: `3`,
+ name: `John Smith`,
+ })
+
+ expect(result.current.data).toMatchObject({
+ id: `3`,
+ name: `John Smith`,
+ })
+ })
+
+ it(`should be able to return a single row with collection`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `test-persons-2`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ }),
+ )
+
+ const liveQueryCollection = createLiveQueryCollection({
+ query: (q) =>
+ q
+ .from({ collection })
+ .where(({ collection: c }) => eq(c.id, `3`))
+ .findOne(),
+ })
+
+ const { result } = renderHook(() => {
+ return useLiveQuery(liveQueryCollection)
+ })
+
+ // Wait for collection to sync
+ await waitFor(() => {
+ expect(result.current.state.size).toBe(1)
+ })
+
+ expect(result.current.state.get(`3`)).toMatchObject({
+ id: `3`,
+ name: `John Smith`,
+ })
+
+ expect(result.current.data).toMatchObject({
+ id: `3`,
+ name: `John Smith`,
+ })
+ })
+
+ it(`should be able to query a collection with live updates`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `test-persons-2`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ }),
+ )
+
+ const { result } = renderHook(() => {
+ return useLiveQuery((q) =>
+ q
+ .from({ collection })
+ .where(({ collection: c }) => gt(c.age, 30))
+ .select(({ collection: c }) => ({
+ id: c.id,
+ name: c.name,
+ }))
+ .orderBy(({ collection: c }) => c.id, `asc`),
+ )
+ })
+
+ // Wait for collection to sync
+ await waitFor(() => {
+ expect(result.current.state.size).toBe(1)
+ })
+ expect(result.current.state.get(`3`)).toMatchObject({
+ id: `3`,
+ name: `John Smith`,
+ })
+
+ expect(result.current.data.length).toBe(1)
+ expect(result.current.data[0]).toMatchObject({
+ id: `3`,
+ name: `John Smith`,
+ })
+
+ // Insert a new person using the proper utils pattern
+ act(() => {
+ collection.utils.begin()
+ collection.utils.write({
+ type: `insert`,
+ value: {
+ id: `4`,
+ name: `Kyle Doe`,
+ age: 40,
+ email: `kyle.doe@example.com`,
+ isActive: true,
+ team: `team1`,
+ },
+ })
+ collection.utils.commit()
+ })
+
+ await waitFor(() => {
+ expect(result.current.state.size).toBe(2)
+ })
+ expect(result.current.state.get(`3`)).toMatchObject({
+ id: `3`,
+ name: `John Smith`,
+ })
+ expect(result.current.state.get(`4`)).toMatchObject({
+ id: `4`,
+ name: `Kyle Doe`,
+ })
+
+ expect(result.current.data.length).toBe(2)
+ expect(result.current.data).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: `3`,
+ name: `John Smith`,
+ }),
+ expect.objectContaining({
+ id: `4`,
+ name: `Kyle Doe`,
+ }),
+ ]),
+ )
+
+ // Update the person
+ act(() => {
+ collection.utils.begin()
+ collection.utils.write({
+ type: `update`,
+ value: {
+ id: `4`,
+ name: `Kyle Doe 2`,
+ age: 40,
+ email: `kyle.doe@example.com`,
+ isActive: true,
+ team: `team1`,
+ },
+ })
+ collection.utils.commit()
+ })
+
+ await waitFor(() => {
+ expect(result.current.state.size).toBe(2)
+ })
+ expect(result.current.state.get(`4`)).toMatchObject({
+ id: `4`,
+ name: `Kyle Doe 2`,
+ })
+
+ expect(result.current.data.length).toBe(2)
+ expect(result.current.data).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: `3`,
+ name: `John Smith`,
+ }),
+ expect.objectContaining({
+ id: `4`,
+ name: `Kyle Doe 2`,
+ }),
+ ]),
+ )
+
+ // Delete the person
+ act(() => {
+ collection.utils.begin()
+ collection.utils.write({
+ type: `delete`,
+ value: {
+ id: `4`,
+ name: `Kyle Doe 2`,
+ age: 40,
+ email: `kyle.doe@example.com`,
+ isActive: true,
+ team: `team1`,
+ },
+ })
+ collection.utils.commit()
+ })
+
+ await waitFor(() => {
+ expect(result.current.state.size).toBe(1)
+ })
+ expect(result.current.state.get(`4`)).toBeUndefined()
+
+ expect(result.current.data.length).toBe(1)
+ expect(result.current.data[0]).toMatchObject({
+ id: `3`,
+ name: `John Smith`,
+ })
+ })
+
+ it(`should join collections and return combined results with live updates`, async () => {
+ // Create person collection
+ const personCollection = createCollection(
+ mockSyncCollectionOptions({
+ id: `person-collection-test`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ }),
+ )
+
+ // Create issue collection
+ const issueCollection = createCollection(
+ mockSyncCollectionOptions({
+ id: `issue-collection-test`,
+ getKey: (issue: Issue) => issue.id,
+ initialData: initialIssues,
+ }),
+ )
+
+ const { result } = renderHook(() => {
+ return useLiveQuery((q) =>
+ q
+ .from({ issues: issueCollection })
+ .join({ persons: personCollection }, ({ issues, persons }) =>
+ eq(issues.userId, persons.id),
+ )
+ .select(({ issues, persons }) => ({
+ id: issues.id,
+ title: issues.title,
+ name: persons.name,
+ })),
+ )
+ })
+
+ // Wait for collections to sync
+ await waitFor(() => {
+ expect(result.current.state.size).toBe(3)
+ })
+
+ // Verify that we have the expected joined results
+
+ expect(result.current.state.get(`[1,1]`)).toMatchObject({
+ id: `1`,
+ name: `John Doe`,
+ title: `Issue 1`,
+ })
+
+ expect(result.current.state.get(`[2,2]`)).toMatchObject({
+ id: `2`,
+ name: `Jane Doe`,
+ title: `Issue 2`,
+ })
+
+ expect(result.current.state.get(`[3,1]`)).toMatchObject({
+ id: `3`,
+ name: `John Doe`,
+ title: `Issue 3`,
+ })
+
+ // Add a new issue for user 2
+ act(() => {
+ issueCollection.utils.begin()
+ issueCollection.utils.write({
+ type: `insert`,
+ value: {
+ id: `4`,
+ title: `Issue 4`,
+ description: `Issue 4 description`,
+ userId: `2`,
+ },
+ })
+ issueCollection.utils.commit()
+ })
+
+ await waitFor(() => {
+ expect(result.current.state.size).toBe(4)
+ })
+ expect(result.current.state.get(`[4,2]`)).toMatchObject({
+ id: `4`,
+ name: `Jane Doe`,
+ title: `Issue 4`,
+ })
+
+ // Update an issue we're already joined with
+ act(() => {
+ issueCollection.utils.begin()
+ issueCollection.utils.write({
+ type: `update`,
+ value: {
+ id: `2`,
+ title: `Updated Issue 2`,
+ description: `Issue 2 description`,
+ userId: `2`,
+ },
+ })
+ issueCollection.utils.commit()
+ })
+
+ await waitFor(() => {
+ // The updated title should be reflected in the joined results
+ expect(result.current.state.get(`[2,2]`)).toMatchObject({
+ id: `2`,
+ name: `Jane Doe`,
+ title: `Updated Issue 2`,
+ })
+ })
+
+ // Delete an issue
+ act(() => {
+ issueCollection.utils.begin()
+ issueCollection.utils.write({
+ type: `delete`,
+ value: {
+ id: `3`,
+ title: `Issue 3`,
+ description: `Issue 3 description`,
+ userId: `1`,
+ },
+ })
+ issueCollection.utils.commit()
+ })
+
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ // After deletion, issue 3 should no longer have a joined result
+ expect(result.current.state.get(`[3,1]`)).toBeUndefined()
+ expect(result.current.state.size).toBe(3)
+ })
+
+ it(`should recompile query when parameters change and change results`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `params-change-test`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ }),
+ )
+
+ const { result, rerender } = renderHook(
+ ({ minAge }: { minAge: number }) => {
+ return useLiveQuery(
+ (q) =>
+ q
+ .from({ collection })
+ .where(({ collection: c }) => gt(c.age, minAge))
+ .select(({ collection: c }) => ({
+ id: c.id,
+ name: c.name,
+ age: c.age,
+ })),
+ [minAge],
+ )
+ },
+ { initialProps: { minAge: 30 } },
+ )
+
+ // Wait for collection to sync
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ // Initially should return only people older than 30
+ expect(result.current.state.size).toBe(1)
+ expect(result.current.state.get(`3`)).toMatchObject({
+ id: `3`,
+ name: `John Smith`,
+ age: 35,
+ })
+
+ // Change the parameter to include more people
+ act(() => {
+ rerender({ minAge: 20 })
+ })
+
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ // Now should return all people as they're all older than 20
+ expect(result.current.state.size).toBe(3)
+ expect(result.current.state.get(`1`)).toMatchObject({
+ id: `1`,
+ name: `John Doe`,
+ age: 30,
+ })
+ expect(result.current.state.get(`2`)).toMatchObject({
+ id: `2`,
+ name: `Jane Doe`,
+ age: 25,
+ })
+ expect(result.current.state.get(`3`)).toMatchObject({
+ id: `3`,
+ name: `John Smith`,
+ age: 35,
+ })
+
+ // Change to exclude everyone
+ act(() => {
+ rerender({ minAge: 50 })
+ })
+
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ // Should now be empty
+ expect(result.current.state.size).toBe(0)
+ })
+
+ it(`should stop old query when parameters change`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `stop-query-test`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ }),
+ )
+
+ const { result, rerender } = renderHook(
+ ({ minAge }: { minAge: number }) => {
+ return useLiveQuery(
+ (q) =>
+ q
+ .from({ collection })
+ .where(({ collection: c }) => gt(c.age, minAge))
+ .select(({ collection: c }) => ({
+ id: c.id,
+ name: c.name,
+ })),
+ [minAge],
+ )
+ },
+ { initialProps: { minAge: 30 } },
+ )
+
+ // Wait for collection to sync
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ // Initial query should return only people older than 30
+ expect(result.current.state.size).toBe(1)
+ expect(result.current.state.get(`3`)).toMatchObject({
+ id: `3`,
+ name: `John Smith`,
+ })
+
+ // Change the parameter to include more people
+ act(() => {
+ rerender({ minAge: 25 })
+ })
+
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ // Query should now return all people older than 25
+ expect(result.current.state.size).toBe(2)
+ expect(result.current.state.get(`1`)).toMatchObject({
+ id: `1`,
+ name: `John Doe`,
+ })
+ expect(result.current.state.get(`3`)).toMatchObject({
+ id: `3`,
+ name: `John Smith`,
+ })
+
+ // Change to a value that excludes everyone
+ act(() => {
+ rerender({ minAge: 50 })
+ })
+
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ // Should now be empty
+ expect(result.current.state.size).toBe(0)
+ })
+
+ it(`should be able to query a result collection with live updates`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `optimistic-changes-test`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ }),
+ )
+
+ // Initial query
+ const { result } = renderHook(() => {
+ return useLiveQuery((q) =>
+ q
+ .from({ collection })
+ .where(({ collection: c }) => gt(c.age, 30))
+ .select(({ collection: c }) => ({
+ id: c.id,
+ name: c.name,
+ team: c.team,
+ }))
+ .orderBy(({ collection: c }) => c.id, `asc`),
+ )
+ })
+
+ // Wait for collection to sync
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ // Grouped query derived from initial query
+ const { result: groupedResult } = renderHook(() => {
+ return useLiveQuery((q) =>
+ q
+ .from({ queryResult: result.current.collection })
+ .groupBy(({ queryResult }) => queryResult.team)
+ .select(({ queryResult }) => ({
+ team: queryResult.team,
+ count: count(queryResult.id),
+ })),
+ )
+ })
+
+ // Wait for grouped query to sync
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ // Verify initial grouped results
+ expect(groupedResult.current.state.size).toBe(1)
+ const teamResult = Array.from(groupedResult.current.state.values())[0]
+ expect(teamResult).toMatchObject({
+ team: `team1`,
+ count: 1,
+ })
+
+ // Insert two new users in different teams
+ act(() => {
+ collection.utils.begin()
+ collection.utils.write({
+ type: `insert`,
+ value: {
+ id: `5`,
+ name: `Sarah Jones`,
+ age: 32,
+ email: `sarah.jones@example.com`,
+ isActive: true,
+ team: `team1`,
+ },
+ })
+ collection.utils.write({
+ type: `insert`,
+ value: {
+ id: `6`,
+ name: `Mike Wilson`,
+ age: 38,
+ email: `mike.wilson@example.com`,
+ isActive: true,
+ team: `team2`,
+ },
+ })
+ collection.utils.commit()
+ })
+
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ // Verify the grouped results include the new team members
+ expect(groupedResult.current.state.size).toBe(2)
+
+ const groupedResults = Array.from(groupedResult.current.state.values())
+ const team1Result = groupedResults.find((r) => r.team === `team1`)
+ const team2Result = groupedResults.find((r) => r.team === `team2`)
+
+ expect(team1Result).toMatchObject({
+ team: `team1`,
+ count: 2, // John Smith + Sarah Jones
+ })
+ expect(team2Result).toMatchObject({
+ team: `team2`,
+ count: 1, // Mike Wilson
+ })
+ })
+
+ it(`optimistic state is dropped after commit`, async () => {
+ // Track renders and states
+ const renderStates: Array<{
+ stateSize: number
+ hasTempKey: boolean
+ hasPermKey: boolean
+ timestamp: number
+ }> = []
+
+ // Create person collection
+ const personCollection = createCollection(
+ mockSyncCollectionOptions({
+ id: `person-collection-test-bug`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ }),
+ )
+
+ // Create issue collection
+ const issueCollection = createCollection(
+ mockSyncCollectionOptions({
+ id: `issue-collection-test-bug`,
+ getKey: (issue: Issue) => issue.id,
+ initialData: initialIssues,
+ }),
+ )
+
+ // Render the hook with a query that joins persons and issues
+ const { result } = renderHook(() => {
+ const queryResult = useLiveQuery((q) =>
+ q
+ .from({ issues: issueCollection })
+ .join({ persons: personCollection }, ({ issues, persons }) =>
+ eq(issues.userId, persons.id),
+ )
+ .select(({ issues, persons }) => ({
+ id: issues.id,
+ title: issues.title,
+ name: persons.name,
+ })),
+ )
+
+ // Track each render state
+ useEffect(() => {
+ renderStates.push({
+ stateSize: queryResult.state.size,
+ hasTempKey: queryResult.state.has(`[temp-key,1]`),
+ hasPermKey: queryResult.state.has(`[4,1]`),
+ timestamp: Date.now(),
+ })
+ }, [queryResult.state])
+
+ return queryResult
+ })
+
+ // Wait for collections to sync and verify initial state
+ await waitFor(() => {
+ expect(result.current.state.size).toBe(3)
+ })
+
+ // Reset render states array for clarity in the remaining test
+ renderStates.length = 0
+
+ // Create an optimistic action for adding issues
+ type AddIssueInput = {
+ title: string
+ description: string
+ userId: string
+ }
+
+ const addIssue = createOptimisticAction({
+ onMutate: (issueInput) => {
+ // Optimistically insert with temporary key
+ issueCollection.insert({
+ id: `temp-key`,
+ title: issueInput.title,
+ description: issueInput.description,
+ userId: issueInput.userId,
+ })
+ },
+ mutationFn: async (issueInput) => {
+ // Simulate server persistence - in a real app, this would be an API call
+ await new Promise((resolve) => setTimeout(resolve, 10)) // Simulate network delay
+
+ // After "server" responds, update the collection with permanent ID using utils
+ // Note: This act() is inside the mutationFn and handles the async server response
+ act(() => {
+ issueCollection.utils.begin()
+ issueCollection.utils.write({
+ type: `delete`,
+ value: {
+ id: `temp-key`,
+ title: issueInput.title,
+ description: issueInput.description,
+ userId: issueInput.userId,
+ },
+ })
+ issueCollection.utils.write({
+ type: `insert`,
+ value: {
+ id: `4`, // Use the permanent ID
+ title: issueInput.title,
+ description: issueInput.description,
+ userId: issueInput.userId,
+ },
+ })
+ issueCollection.utils.commit()
+ })
+
+ return { success: true, id: `4` }
+ },
+ })
+
+ // Perform optimistic insert of a new issue
+ let transaction: any
+ act(() => {
+ transaction = addIssue({
+ title: `New Issue`,
+ description: `New Issue Description`,
+ userId: `1`,
+ })
+ })
+
+ await waitFor(() => {
+ // Verify optimistic state is immediately reflected
+ expect(result.current.state.size).toBe(4)
+ expect(result.current.state.get(`[temp-key,1]`)).toMatchObject({
+ id: `temp-key`,
+ name: `John Doe`,
+ title: `New Issue`,
+ })
+ expect(result.current.state.get(`[4,1]`)).toBeUndefined()
+ })
+
+ // Wait for the transaction to be committed
+ await transaction.isPersisted.promise
+
+ await waitFor(() => {
+ // Wait for the permanent key to appear
+ expect(result.current.state.get(`[4,1]`)).toBeDefined()
+ })
+
+ // Check if we had any render where the temp key was removed but the permanent key wasn't added yet
+ const hadFlicker = renderStates.some(
+ (state) =>
+ !state.hasTempKey && !state.hasPermKey && state.stateSize === 3,
+ )
+
+ expect(hadFlicker).toBe(false)
+
+ // Verify the temporary key is replaced by the permanent one
+ expect(result.current.state.size).toBe(4)
+ expect(result.current.state.get(`[temp-key,1]`)).toBeUndefined()
+ expect(result.current.state.get(`[4,1]`)).toMatchObject({
+ id: `4`,
+ name: `John Doe`,
+ title: `New Issue`,
+ })
+ })
+
+ it(`should accept pre-created live query collection`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `pre-created-collection-test`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ }),
+ )
+
+ // Create a live query collection beforehand
+ const liveQueryCollection = createLiveQueryCollection({
+ query: (q) =>
+ q
+ .from({ persons: collection })
+ .where(({ persons }) => gt(persons.age, 30))
+ .select(({ persons }) => ({
+ id: persons.id,
+ name: persons.name,
+ age: persons.age,
+ })),
+ startSync: true,
+ })
+
+ const { result } = renderHook(() => {
+ return useLiveQuery(liveQueryCollection)
+ })
+
+ // Wait for collection to sync and state to update
+ await waitFor(() => {
+ expect(result.current.state.size).toBe(1) // Only John Smith (age 35)
+ })
+ expect(result.current.data).toHaveLength(1)
+
+ const johnSmith = result.current.data[0]
+ expect(johnSmith).toMatchObject({
+ id: `3`,
+ name: `John Smith`,
+ age: 35,
+ })
+
+ // Verify that the returned collection is the same instance
+ expect(result.current.collection).toBe(liveQueryCollection)
+ })
+
+ it(`should switch to a different pre-created live query collection when changed`, async () => {
+ const collection1 = createCollection(
+ mockSyncCollectionOptions({
+ id: `collection-1`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ }),
+ )
+
+ const collection2 = createCollection(
+ mockSyncCollectionOptions({
+ id: `collection-2`,
+ getKey: (person: Person) => person.id,
+ initialData: [
+ {
+ id: `4`,
+ name: `Alice Cooper`,
+ age: 45,
+ email: `alice.cooper@example.com`,
+ isActive: true,
+ team: `team3`,
+ },
+ {
+ id: `5`,
+ name: `Bob Dylan`,
+ age: 50,
+ email: `bob.dylan@example.com`,
+ isActive: true,
+ team: `team3`,
+ },
+ ],
+ }),
+ )
+
+ // Create two different live query collections
+ const liveQueryCollection1 = createLiveQueryCollection({
+ query: (q) =>
+ q
+ .from({ persons: collection1 })
+ .where(({ persons }) => gt(persons.age, 30))
+ .select(({ persons }) => ({
+ id: persons.id,
+ name: persons.name,
+ })),
+ startSync: true,
+ })
+
+ const liveQueryCollection2 = createLiveQueryCollection({
+ query: (q) =>
+ q
+ .from({ persons: collection2 })
+ .where(({ persons }) => gt(persons.age, 40))
+ .select(({ persons }) => ({
+ id: persons.id,
+ name: persons.name,
+ })),
+ startSync: true,
+ })
+
+ const { result, rerender } = renderHook(
+ ({ collection }: { collection: any }) => {
+ return useLiveQuery(collection)
+ },
+ { initialProps: { collection: liveQueryCollection1 } },
+ )
+
+ // Wait for first collection to sync
+ await waitFor(() => {
+ expect(result.current.state.size).toBe(1) // Only John Smith from collection1
+ })
+ expect(result.current.state.get(`3`)).toMatchObject({
+ id: `3`,
+ name: `John Smith`,
+ })
+ expect(result.current.collection).toBe(liveQueryCollection1)
+
+ // Switch to the second collection
+ act(() => {
+ rerender({ collection: liveQueryCollection2 })
+ })
+
+ // Wait for second collection to sync
+ await waitFor(() => {
+ expect(result.current.state.size).toBe(2) // Alice and Bob from collection2
+ })
+ expect(result.current.state.get(`4`)).toMatchObject({
+ id: `4`,
+ name: `Alice Cooper`,
+ })
+ expect(result.current.state.get(`5`)).toMatchObject({
+ id: `5`,
+ name: `Bob Dylan`,
+ })
+ expect(result.current.collection).toBe(liveQueryCollection2)
+
+ // Verify we no longer have data from the first collection
+ expect(result.current.state.get(`3`)).toBeUndefined()
+ })
+
+ it(`should accept a config object with a pre-built QueryBuilder instance`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `test-persons-config-querybuilder`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ }),
+ )
+
+ // Create a QueryBuilder instance beforehand
+ const queryBuilder = new Query()
+ .from({ persons: collection })
+ .where(({ persons }) => gt(persons.age, 30))
+ .select(({ persons }) => ({
+ id: persons.id,
+ name: persons.name,
+ age: persons.age,
+ }))
+
+ const { result } = renderHook(() => {
+ return useLiveQuery({ query: queryBuilder })
+ })
+
+ // Wait for collection to sync and state to update
+ await waitFor(() => {
+ expect(result.current.state.size).toBe(1) // Only John Smith (age 35)
+ })
+ expect(result.current.data).toHaveLength(1)
+
+ const johnSmith = result.current.data[0]
+ expect(johnSmith).toMatchObject({
+ id: `3`,
+ name: `John Smith`,
+ age: 35,
+ })
+ })
+
+ describe(`isLoaded property`, () => {
+ it(`should be true initially and false after collection is ready`, async () => {
+ let beginFn: (() => void) | undefined
+ let commitFn: (() => void) | undefined
+
+ // Create a collection that doesn't start sync immediately
+ const collection = createCollection({
+ id: `has-loaded-test`,
+ getKey: (person: Person) => person.id,
+ startSync: false, // Don't start sync immediately
+ sync: {
+ sync: ({ begin, commit, markReady }) => {
+ beginFn = begin
+ commitFn = () => {
+ commit()
+ markReady()
+ }
+ // Don't call begin/commit immediately
+ },
+ },
+ onInsert: async () => {},
+ onUpdate: async () => {},
+ onDelete: async () => {},
+ })
+
+ const { result } = renderHook(() => {
+ return useLiveQuery((q) =>
+ q
+ .from({ persons: collection })
+ .where(({ persons }) => gt(persons.age, 30))
+ .select(({ persons }) => ({
+ id: persons.id,
+ name: persons.name,
+ })),
+ )
+ })
+
+ // Initially isLoading should be true
+ expect(result.current.isLoading).toBe(true)
+
+ // Start sync manually
+ act(() => {
+ collection.preload()
+ })
+
+ // Trigger the first commit to make collection ready
+ act(() => {
+ if (beginFn && commitFn) {
+ beginFn()
+ commitFn()
+ }
+ })
+
+ // Insert data
+ act(() => {
+ collection.insert({
+ id: `1`,
+ name: `John Doe`,
+ age: 35,
+ email: `john.doe@example.com`,
+ isActive: true,
+ team: `team1`,
+ })
+ })
+
+ // Wait for collection to become ready
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false)
+ })
+ // Note: Data may not appear immediately due to live query evaluation timing
+ // The main test is that isLoading transitions from true to false
+ })
+
+ it(`should be false for pre-created collections that are already syncing`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `pre-created-has-loaded-test`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ }),
+ )
+
+ // Create a live query collection that's already syncing
+ const liveQueryCollection = createLiveQueryCollection({
+ query: (q) =>
+ q
+ .from({ persons: collection })
+ .where(({ persons }) => gt(persons.age, 30))
+ .select(({ persons }) => ({
+ id: persons.id,
+ name: persons.name,
+ })),
+ startSync: true,
+ })
+
+ // Wait a bit for the collection to start syncing
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ const { result } = renderHook(() => {
+ return useLiveQuery(liveQueryCollection)
+ })
+
+ // For pre-created collections that are already syncing, isLoading should be true
+ expect(result.current.isLoading).toBe(false)
+ expect(result.current.state.size).toBe(1)
+ })
+
+ it(`should update isLoading when collection status changes`, async () => {
+ let beginFn: (() => void) | undefined
+ let commitFn: (() => void) | undefined
+ let markReadyFn: (() => void) | undefined
+
+ const collection = createCollection({
+ id: `status-change-has-loaded-test`,
+ getKey: (person: Person) => person.id,
+ startSync: false,
+ sync: {
+ sync: ({ begin, commit, markReady }) => {
+ beginFn = begin
+ commitFn = commit
+ markReadyFn = markReady
+ // Don't sync immediately
+ },
+ },
+ onInsert: async () => {},
+ onUpdate: async () => {},
+ onDelete: async () => {},
+ })
+
+ const { result } = renderHook(() => {
+ return useLiveQuery((q) =>
+ q
+ .from({ persons: collection })
+ .where(({ persons }) => gt(persons.age, 30))
+ .select(({ persons }) => ({
+ id: persons.id,
+ name: persons.name,
+ })),
+ )
+ })
+
+ // Initially should be true
+ expect(result.current.isLoading).toBe(true)
+
+ // Start sync manually
+ act(() => {
+ collection.preload()
+ })
+
+ // Trigger the first commit to make collection ready
+ act(() => {
+ if (beginFn && commitFn && markReadyFn) {
+ beginFn()
+ commitFn()
+ markReadyFn()
+ }
+ })
+
+ // Insert data
+ act(() => {
+ collection.insert({
+ id: `1`,
+ name: `John Doe`,
+ age: 35,
+ email: `john.doe@example.com`,
+ isActive: true,
+ team: `team1`,
+ })
+ })
+
+ expect(result.current.isLoading).toBe(false)
+ expect(result.current.isReady).toBe(true)
+
+ // Wait for collection to become ready
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false)
+ })
+ expect(result.current.status).toBe(`ready`)
+ })
+
+ it(`should maintain isReady state during live updates`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `live-updates-has-loaded-test`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ }),
+ )
+
+ const { result } = renderHook(() => {
+ return useLiveQuery((q) =>
+ q
+ .from({ persons: collection })
+ .where(({ persons }) => gt(persons.age, 30))
+ .select(({ persons }) => ({
+ id: persons.id,
+ name: persons.name,
+ })),
+ )
+ })
+
+ // Wait for initial load
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false)
+ })
+
+ const initialIsReady = result.current.isReady
+
+ // Perform live updates
+ act(() => {
+ collection.utils.begin()
+ collection.utils.write({
+ type: `insert`,
+ value: {
+ id: `4`,
+ name: `Kyle Doe`,
+ age: 40,
+ email: `kyle.doe@example.com`,
+ isActive: true,
+ team: `team1`,
+ },
+ })
+ collection.utils.commit()
+ })
+
+ // Wait for update to process
+ await waitFor(() => {
+ expect(result.current.state.size).toBe(2)
+ })
+
+ // isReady should remain true during live updates
+ expect(result.current.isReady).toBe(true)
+ expect(result.current.isReady).toBe(initialIsReady)
+ })
+
+ it(`should handle isLoading with complex queries including joins`, async () => {
+ let personBeginFn: (() => void) | undefined
+ let personCommitFn: (() => void) | undefined
+ let personMarkReadyFn: (() => void) | undefined
+ let issueBeginFn: (() => void) | undefined
+ let issueCommitFn: (() => void) | undefined
+ let issueMarkReadyFn: (() => void) | undefined
+
+ const personCollection = createCollection({
+ id: `join-has-loaded-persons`,
+ getKey: (person: Person) => person.id,
+ startSync: false,
+ sync: {
+ sync: ({ begin, commit, markReady }) => {
+ personBeginFn = begin
+ personCommitFn = commit
+ personMarkReadyFn = markReady
+ // Don't sync immediately
+ },
+ },
+ onInsert: async () => {},
+ onUpdate: async () => {},
+ onDelete: async () => {},
+ })
+
+ const issueCollection = createCollection({
+ id: `join-has-loaded-issues`,
+ getKey: (issue: Issue) => issue.id,
+ startSync: false,
+ sync: {
+ sync: ({ begin, commit, markReady }) => {
+ issueBeginFn = begin
+ issueCommitFn = commit
+ issueMarkReadyFn = markReady
+ // Don't sync immediately
+ },
+ },
+ onInsert: async () => {},
+ onUpdate: async () => {},
+ onDelete: async () => {},
+ })
+
+ const { result } = renderHook(() => {
+ return useLiveQuery((q) =>
+ q
+ .from({ issues: issueCollection })
+ .join({ persons: personCollection }, ({ issues, persons }) =>
+ eq(issues.userId, persons.id),
+ )
+ .select(({ issues, persons }) => ({
+ id: issues.id,
+ title: issues.title,
+ name: persons.name,
+ })),
+ )
+ })
+
+ // Initially should be true
+ expect(result.current.isLoading).toBe(true)
+
+ // Start sync for both collections
+ act(() => {
+ personCollection.preload()
+ issueCollection.preload()
+ })
+
+ // Trigger the first commit for both collections to make them ready
+ act(() => {
+ if (personBeginFn && personCommitFn && personMarkReadyFn) {
+ personBeginFn()
+ personCommitFn()
+ personMarkReadyFn()
+ }
+ if (issueBeginFn && issueCommitFn && issueMarkReadyFn) {
+ issueBeginFn()
+ issueCommitFn()
+ issueMarkReadyFn()
+ }
+ })
+
+ // Insert data into both collections
+ act(() => {
+ personCollection.insert({
+ id: `1`,
+ name: `John Doe`,
+ age: 30,
+ email: `john.doe@example.com`,
+ isActive: true,
+ team: `team1`,
+ })
+ issueCollection.insert({
+ id: `1`,
+ title: `Issue 1`,
+ description: `Issue 1 description`,
+ userId: `1`,
+ })
+ })
+
+ // Wait for both collections to sync
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+ // Note: Joined data may not appear immediately due to live query evaluation timing
+ // The main test is that isLoading transitions from false to true
+ })
+
+ it(`should handle isLoading with parameterized queries`, async () => {
+ let beginFn: (() => void) | undefined
+ let commitFn: (() => void) | undefined
+
+ const collection = createCollection({
+ id: `params-has-loaded-test`,
+ getKey: (person: Person) => person.id,
+ startSync: false,
+ sync: {
+ sync: ({ begin, commit, markReady }) => {
+ beginFn = begin
+ commitFn = () => {
+ commit()
+ markReady()
+ }
+ // Don't sync immediately
+ },
+ },
+ onInsert: async () => {},
+ onUpdate: async () => {},
+ onDelete: async () => {},
+ })
+
+ const { result, rerender } = renderHook(
+ ({ minAge }: { minAge: number }) => {
+ return useLiveQuery(
+ (q) =>
+ q
+ .from({ collection })
+ .where(({ collection: c }) => gt(c.age, minAge))
+ .select(({ collection: c }) => ({
+ id: c.id,
+ name: c.name,
+ })),
+ [minAge],
+ )
+ },
+ { initialProps: { minAge: 30 } },
+ )
+
+ // Initially should be false
+ expect(result.current.isLoading).toBe(true)
+
+ // Start sync manually
+ act(() => {
+ collection.preload()
+ })
+
+ // Trigger the first commit to make collection ready
+ act(() => {
+ if (beginFn && commitFn) {
+ beginFn()
+ commitFn()
+ }
+ })
+
+ // Insert data
+ act(() => {
+ collection.insert({
+ id: `1`,
+ name: `John Doe`,
+ age: 35,
+ email: `john.doe@example.com`,
+ isActive: true,
+ team: `team1`,
+ })
+ collection.insert({
+ id: `2`,
+ name: `Jane Doe`,
+ age: 25,
+ email: `jane.doe@example.com`,
+ isActive: true,
+ team: `team2`,
+ })
+ })
+
+ // Wait for initial load
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false)
+ })
+
+ // Change parameters
+ act(() => {
+ rerender({ minAge: 25 })
+ })
+
+ // isReady should remain true even when parameters change
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+ // Note: Data size may not change immediately due to live query evaluation timing
+ // The main test is that isReady remains true when parameters change
+ })
+ })
+
+ describe(`eager execution during sync`, () => {
+ it(`should show state while isLoading is true during sync`, async () => {
+ let syncBegin: (() => void) | undefined
+ let syncWrite: ((op: any) => void) | undefined
+ let syncCommit: (() => void) | undefined
+ let syncMarkReady: (() => void) | undefined
+
+ // Create a collection that doesn't auto-start syncing
+ const collection = createCollection({
+ id: `eager-execution-test`,
+ getKey: (person: Person) => person.id,
+ startSync: false,
+ sync: {
+ sync: ({ begin, write, commit, markReady }) => {
+ syncBegin = begin
+ syncWrite = write
+ syncCommit = commit
+ syncMarkReady = markReady
+ },
+ },
+ onInsert: async () => {},
+ onUpdate: async () => {},
+ onDelete: async () => {},
+ })
+
+ const { result } = renderHook(() => {
+ return useLiveQuery((q) =>
+ q
+ .from({ persons: collection })
+ .where(({ persons }) => gt(persons.age, 30))
+ .select(({ persons }) => ({
+ id: persons.id,
+ name: persons.name,
+ })),
+ )
+ })
+
+ // Initially isLoading should be true
+ expect(result.current.isLoading).toBe(true)
+ expect(result.current.state.size).toBe(0)
+ expect(result.current.data).toEqual([])
+
+ // Start sync manually
+ act(() => {
+ collection.preload()
+ })
+
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ // Still loading
+ expect(result.current.isLoading).toBe(true)
+
+ // Add first batch of data (but don't mark ready yet)
+ act(() => {
+ syncBegin!()
+ syncWrite!({
+ type: `insert`,
+ value: {
+ id: `1`,
+ name: `John Smith`,
+ age: 35,
+ email: `john.smith@example.com`,
+ isActive: true,
+ team: `team1`,
+ },
+ })
+ syncCommit!()
+ })
+
+ // Data should be visible even though still loading
+ await waitFor(() => {
+ expect(result.current.state.size).toBe(1)
+ })
+ expect(result.current.isLoading).toBe(true) // Still loading
+ expect(result.current.data).toHaveLength(1)
+ expect(result.current.data[0]).toMatchObject({
+ id: `1`,
+ name: `John Smith`,
+ })
+
+ // Add second batch of data
+ act(() => {
+ syncBegin!()
+ syncWrite!({
+ type: `insert`,
+ value: {
+ id: `2`,
+ name: `Jane Doe`,
+ age: 32,
+ email: `jane.doe@example.com`,
+ isActive: true,
+ team: `team2`,
+ },
+ })
+ syncCommit!()
+ })
+
+ // More data should be visible
+ await waitFor(() => {
+ expect(result.current.state.size).toBe(2)
+ })
+ expect(result.current.isLoading).toBe(true) // Still loading
+ expect(result.current.data).toHaveLength(2)
+
+ // Now mark as ready
+ act(() => {
+ syncMarkReady!()
+ })
+
+ // Should now be ready
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false)
+ })
+ expect(result.current.isReady).toBe(true)
+ expect(result.current.state.size).toBe(2)
+ expect(result.current.data).toHaveLength(2)
+ })
+
+ it(`should show filtered results during sync with isLoading true`, async () => {
+ let syncBegin: (() => void) | undefined
+ let syncWrite: ((op: any) => void) | undefined
+ let syncCommit: (() => void) | undefined
+ let syncMarkReady: (() => void) | undefined
+
+ const collection = createCollection({
+ id: `eager-filter-test`,
+ getKey: (person: Person) => person.id,
+ startSync: false,
+ sync: {
+ sync: ({ begin, write, commit, markReady }) => {
+ syncBegin = begin
+ syncWrite = write
+ syncCommit = commit
+ syncMarkReady = markReady
+ },
+ },
+ onInsert: async () => {},
+ onUpdate: async () => {},
+ onDelete: async () => {},
+ })
+
+ const { result } = renderHook(() => {
+ return useLiveQuery((q) =>
+ q
+ .from({ persons: collection })
+ .where(({ persons }) => eq(persons.team, `team1`))
+ .select(({ persons }) => ({
+ id: persons.id,
+ name: persons.name,
+ team: persons.team,
+ })),
+ )
+ })
+
+ // Start sync
+ act(() => {
+ collection.preload()
+ })
+
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ expect(result.current.isLoading).toBe(true)
+
+ // Add items from different teams
+ act(() => {
+ syncBegin!()
+ syncWrite!({
+ type: `insert`,
+ value: {
+ id: `1`,
+ name: `Alice`,
+ age: 30,
+ email: `alice@example.com`,
+ isActive: true,
+ team: `team1`,
+ },
+ })
+ syncWrite!({
+ type: `insert`,
+ value: {
+ id: `2`,
+ name: `Bob`,
+ age: 25,
+ email: `bob@example.com`,
+ isActive: true,
+ team: `team2`,
+ },
+ })
+ syncWrite!({
+ type: `insert`,
+ value: {
+ id: `3`,
+ name: `Charlie`,
+ age: 35,
+ email: `charlie@example.com`,
+ isActive: true,
+ team: `team1`,
+ },
+ })
+ syncCommit!()
+ })
+
+ // Should only show team1 members, even while loading
+ await waitFor(() => {
+ expect(result.current.state.size).toBe(2)
+ })
+ expect(result.current.isLoading).toBe(true)
+ expect(result.current.data).toHaveLength(2)
+ expect(result.current.data.every((p) => p.team === `team1`)).toBe(true)
+
+ // Mark ready
+ act(() => {
+ syncMarkReady!()
+ })
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+ expect(result.current.isLoading).toBe(false)
+ expect(result.current.state.size).toBe(2)
+ })
+
+ it(`should show join results during sync with isLoading true`, async () => {
+ let userSyncBegin: (() => void) | undefined
+ let userSyncWrite: ((op: any) => void) | undefined
+ let userSyncCommit: (() => void) | undefined
+ let userSyncMarkReady: (() => void) | undefined
+
+ let issueSyncBegin: (() => void) | undefined
+ let issueSyncWrite: ((op: any) => void) | undefined
+ let issueSyncCommit: (() => void) | undefined
+ let issueSyncMarkReady: (() => void) | undefined
+
+ const personCollection = createCollection({
+ id: `eager-join-persons`,
+ getKey: (person: Person) => person.id,
+ startSync: false,
+ sync: {
+ sync: ({ begin, write, commit, markReady }) => {
+ userSyncBegin = begin
+ userSyncWrite = write
+ userSyncCommit = commit
+ userSyncMarkReady = markReady
+ },
+ },
+ onInsert: async () => {},
+ onUpdate: async () => {},
+ onDelete: async () => {},
+ })
+
+ const issueCollection = createCollection({
+ id: `eager-join-issues`,
+ getKey: (issue: Issue) => issue.id,
+ startSync: false,
+ sync: {
+ sync: ({ begin, write, commit, markReady }) => {
+ issueSyncBegin = begin
+ issueSyncWrite = write
+ issueSyncCommit = commit
+ issueSyncMarkReady = markReady
+ },
+ },
+ onInsert: async () => {},
+ onUpdate: async () => {},
+ onDelete: async () => {},
+ })
+
+ const { result } = renderHook(() => {
+ return useLiveQuery((q) =>
+ q
+ .from({ issues: issueCollection })
+ .join({ persons: personCollection }, ({ issues, persons }) =>
+ eq(issues.userId, persons.id),
+ )
+ .select(({ issues, persons }) => ({
+ id: issues.id,
+ title: issues.title,
+ userName: persons.name,
+ })),
+ )
+ })
+
+ // Start sync for both
+ act(() => {
+ personCollection.preload()
+ issueCollection.preload()
+ })
+
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ expect(result.current.isLoading).toBe(true)
+
+ // Add a person first
+ act(() => {
+ userSyncBegin!()
+ userSyncWrite!({
+ type: `insert`,
+ value: {
+ id: `1`,
+ name: `John Doe`,
+ age: 30,
+ email: `john@example.com`,
+ isActive: true,
+ team: `team1`,
+ },
+ })
+ userSyncCommit!()
+ })
+
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ expect(result.current.isLoading).toBe(true)
+ expect(result.current.state.size).toBe(0) // No joins yet
+
+ // Add an issue for that person
+ act(() => {
+ issueSyncBegin!()
+ issueSyncWrite!({
+ type: `insert`,
+ value: {
+ id: `1`,
+ title: `First Issue`,
+ description: `Description`,
+ userId: `1`,
+ },
+ })
+ issueSyncCommit!()
+ })
+
+ // Should see join result even while loading
+ await waitFor(() => {
+ expect(result.current.state.size).toBe(1)
+ })
+ expect(result.current.isLoading).toBe(true)
+ expect(result.current.data).toHaveLength(1)
+ expect(result.current.data[0]).toMatchObject({
+ id: `1`,
+ title: `First Issue`,
+ userName: `John Doe`,
+ })
+
+ // Mark both as ready
+ act(() => {
+ userSyncMarkReady!()
+ issueSyncMarkReady!()
+ })
+
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+ expect(result.current.isLoading).toBe(false)
+ expect(result.current.state.size).toBe(1)
+ })
+
+ it(`should update isReady when source collection is marked ready with no data`, async () => {
+ let syncMarkReady: (() => void) | undefined
+
+ const collection = createCollection({
+ id: `ready-no-data-test`,
+ getKey: (person: Person) => person.id,
+ startSync: false,
+ sync: {
+ sync: ({ markReady }) => {
+ syncMarkReady = markReady
+ // Don't call begin/commit - just provide markReady
+ },
+ },
+ onInsert: async () => {},
+ onUpdate: async () => {},
+ onDelete: async () => {},
+ })
+
+ const { result } = renderHook(() => {
+ return useLiveQuery((q) =>
+ q
+ .from({ persons: collection })
+ .where(({ persons }) => gt(persons.age, 30))
+ .select(({ persons }) => ({
+ id: persons.id,
+ name: persons.name,
+ })),
+ )
+ })
+
+ // Initially isLoading should be true
+ expect(result.current.isLoading).toBe(true)
+ expect(result.current.isReady).toBe(false)
+ expect(result.current.state.size).toBe(0)
+ expect(result.current.data).toEqual([])
+
+ // Start sync manually
+ act(() => {
+ collection.preload()
+ })
+
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ // Still loading
+ expect(result.current.isLoading).toBe(true)
+ expect(result.current.isReady).toBe(false)
+
+ // Mark ready without any data commits
+ act(() => {
+ syncMarkReady!()
+ })
+
+ // Should now be ready, even with no data
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true)
+ })
+ expect(result.current.isLoading).toBe(false)
+ expect(result.current.state.size).toBe(0) // Still no data
+ expect(result.current.data).toEqual([]) // Empty array
+ expect(result.current.status).toBe(`ready`)
+ })
+ })
+
+ describe(`callback variants with conditional returns`, () => {
+ it(`should handle callback returning undefined with proper state`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `undefined-callback-test`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ }),
+ )
+
+ const { result, rerender } = renderHook(
+ ({ enabled }: { enabled: boolean }) => {
+ return useLiveQuery(
+ (q) => {
+ if (!enabled) return undefined
+ return q
+ .from({ persons: collection })
+ .where(({ persons }) => gt(persons.age, 30))
+ .select(({ persons }) => ({
+ id: persons.id,
+ name: persons.name,
+ age: persons.age,
+ }))
+ },
+ [enabled],
+ )
+ },
+ { initialProps: { enabled: false } },
+ )
+
+ // When callback returns undefined, should return the specified state
+ expect(result.current.state).toBeUndefined()
+ expect(result.current.data).toBeUndefined()
+ expect(result.current.collection).toBeUndefined()
+ expect(result.current.status).toBe(`disabled`)
+ expect(result.current.isLoading).toBe(false)
+ expect(result.current.isReady).toBe(true)
+ expect(result.current.isIdle).toBe(false)
+ expect(result.current.isError).toBe(false)
+ expect(result.current.isCleanedUp).toBe(false)
+
+ // Enable the query
+ act(() => {
+ rerender({ enabled: true })
+ })
+
+ // Wait for collection to sync and state to update
+ await waitFor(() => {
+ expect(result.current.state?.size).toBe(1) // Only John Smith (age 35)
+ })
+ expect(result.current.data).toHaveLength(1)
+ expect(result.current.collection).toBeDefined()
+ expect(result.current.status).toBeDefined()
+ expect(result.current.isLoading).toBe(false)
+ expect(result.current.isReady).toBe(true)
+ expect(result.current.isIdle).toBe(false)
+
+ const johnSmith = result.current.data![0]
+ expect(johnSmith).toMatchObject({
+ id: `3`,
+ name: `John Smith`,
+ age: 35,
+ })
+
+ // Disable the query again
+ act(() => {
+ rerender({ enabled: false })
+ })
+
+ // Should return to undefined state
+ expect(result.current.state).toBeUndefined()
+ expect(result.current.data).toBeUndefined()
+ expect(result.current.collection).toBeUndefined()
+ expect(result.current.status).toBe(`disabled`)
+ expect(result.current.isLoading).toBe(false)
+ expect(result.current.isReady).toBe(true)
+ expect(result.current.isIdle).toBe(false)
+ expect(result.current.isError).toBe(false)
+ expect(result.current.isCleanedUp).toBe(false)
+ })
+
+ it(`should handle callback returning null with proper state`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `null-callback-test`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ }),
+ )
+
+ const { result, rerender } = renderHook(
+ ({ enabled }: { enabled: boolean }) => {
+ return useLiveQuery(
+ (q) => {
+ if (!enabled) return null
+ return q
+ .from({ persons: collection })
+ .where(({ persons }) => gt(persons.age, 30))
+ .select(({ persons }) => ({
+ id: persons.id,
+ name: persons.name,
+ age: persons.age,
+ }))
+ },
+ [enabled],
+ )
+ },
+ { initialProps: { enabled: false } },
+ )
+
+ // When callback returns null, should return the specified state
+ expect(result.current.state).toBeUndefined()
+ expect(result.current.data).toBeUndefined()
+ expect(result.current.collection).toBeUndefined()
+ expect(result.current.status).toBe(`disabled`)
+ expect(result.current.isLoading).toBe(false)
+ expect(result.current.isReady).toBe(true)
+ expect(result.current.isIdle).toBe(false)
+ expect(result.current.isError).toBe(false)
+ expect(result.current.isCleanedUp).toBe(false)
+
+ // Enable the query
+ act(() => {
+ rerender({ enabled: true })
+ })
+
+ // Wait for collection to sync and state to update
+ await waitFor(() => {
+ expect(result.current.state?.size).toBe(1) // Only John Smith (age 35)
+ })
+ expect(result.current.data).toHaveLength(1)
+ expect(result.current.collection).toBeDefined()
+ expect(result.current.status).toBeDefined()
+ expect(result.current.isLoading).toBe(false)
+ expect(result.current.isReady).toBe(true)
+ expect(result.current.isIdle).toBe(false)
+ })
+
+ it(`should handle callback returning LiveQueryCollectionConfig`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `config-callback-test`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ }),
+ )
+
+ const { result, rerender } = renderHook(
+ ({ useConfig }: { useConfig: boolean }) => {
+ return useLiveQuery(
+ (q) => {
+ if (useConfig) {
+ return {
+ query: q
+ .from({ persons: collection })
+ .where(({ persons }) => gt(persons.age, 30))
+ .select(({ persons }) => ({
+ id: persons.id,
+ name: persons.name,
+ age: persons.age,
+ })),
+ startSync: true,
+ gcTime: 0,
+ }
+ }
+ return q
+ .from({ persons: collection })
+ .where(({ persons }) => lte(persons.age, 30))
+ .select(({ persons }) => ({
+ id: persons.id,
+ name: persons.name,
+ age: persons.age,
+ }))
+ .orderBy(({ persons }) => persons.age)
+ },
+ [useConfig],
+ )
+ },
+ { initialProps: { useConfig: false } },
+ )
+
+ // Wait for collection to sync and state to update
+ await waitFor(() => {
+ expect(result.current.state?.size).toBe(2) // John Smith (age 35) and Jane Doe (age 25)
+ })
+ expect(result.current.data).toHaveLength(2)
+ expect(result.current.collection).toBeDefined()
+ expect(result.current.status).toBeDefined()
+
+ expect(result.current.data).toMatchObject([
+ {
+ id: `2`,
+ name: `Jane Doe`,
+ age: 25,
+ },
+ {
+ id: `1`,
+ name: `John Doe`,
+ age: 30,
+ },
+ ])
+
+ // Switch to using config
+ act(() => {
+ rerender({ useConfig: true })
+ })
+
+ // Should still work with config
+ await waitFor(() => {
+ expect(result.current.state?.size).toBe(1)
+ })
+ expect(result.current.data).toHaveLength(1)
+ expect(result.current.collection).toBeDefined()
+ expect(result.current.status).toBeDefined()
+
+ expect(result.current.data).toMatchObject([
+ {
+ id: `3`,
+ name: `John Smith`,
+ age: 35,
+ },
+ ])
+ })
+
+ it(`should handle callback returning Collection`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `collection-callback-test`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ }),
+ )
+
+ // Create a live query collection beforehand
+ const liveQueryCollection = createLiveQueryCollection({
+ query: (q) =>
+ q
+ .from({ persons: collection })
+ .where(({ persons }) => gt(persons.age, 30))
+ .select(({ persons }) => ({
+ id: persons.id,
+ name: persons.name,
+ age: persons.age,
+ })),
+ startSync: true,
+ })
+
+ const { result, rerender } = renderHook(
+ ({ useCollection }: { useCollection: boolean }) => {
+ return useLiveQuery(
+ (q) => {
+ if (useCollection) {
+ return liveQueryCollection
+ }
+ return q
+ .from({ persons: collection })
+ .where(({ persons }) => lte(persons.age, 30))
+ .select(({ persons }) => ({
+ id: persons.id,
+ name: persons.name,
+ age: persons.age,
+ }))
+ },
+ [useCollection],
+ )
+ },
+ { initialProps: { useCollection: false } },
+ )
+
+ // Wait for collection to sync and state to update
+ await waitFor(() => {
+ expect(result.current.state?.size).toBe(2) // Only John Smith (age 35)
+ })
+ expect(result.current.data).toHaveLength(2)
+ expect(result.current.collection).toBeDefined()
+ expect(result.current.status).toBeDefined()
+
+ // Results are in deterministic key order (id: 1 before id: 2)
+ expect(result.current.data).toMatchObject([
+ {
+ id: `1`,
+ name: `John Doe`,
+ age: 30,
+ },
+ {
+ id: `2`,
+ name: `Jane Doe`,
+ age: 25,
+ },
+ ])
+
+ // Switch to using pre-created collection
+ act(() => {
+ rerender({ useCollection: true })
+ })
+
+ // Should still work with pre-created collection
+ await waitFor(() => {
+ expect(result.current.state?.size).toBe(1) // Only John Smith (age 35)
+ })
+ expect(result.current.data).toHaveLength(1)
+ expect(result.current.collection).toBeDefined()
+ expect(result.current.status).toBeDefined()
+ expect(result.current.collection).toBe(liveQueryCollection)
+
+ expect(result.current.data).toMatchObject([
+ {
+ id: `3`,
+ name: `John Smith`,
+ age: 35,
+ },
+ ])
+ })
+
+ it(`should handle conditional returns with dependencies`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `conditional-deps-test`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ }),
+ )
+
+ const { result, rerender } = renderHook(
+ ({ minAge, enabled }: { minAge: number; enabled: boolean }) => {
+ return useLiveQuery(
+ (q) => {
+ if (!enabled) return undefined
+ return q
+ .from({ persons: collection })
+ .where(({ persons }) => gt(persons.age, minAge))
+ .select(({ persons }) => ({
+ id: persons.id,
+ name: persons.name,
+ age: persons.age,
+ }))
+ },
+ [minAge, enabled],
+ )
+ },
+ { initialProps: { minAge: 30, enabled: false } },
+ )
+
+ // Initially disabled
+ expect(result.current.state).toBeUndefined()
+ expect(result.current.data).toBeUndefined()
+ expect(result.current.status).toBe(`disabled`)
+ expect(result.current.isEnabled).toBe(false)
+
+ // Enable with minAge 30
+ act(() => {
+ rerender({ minAge: 30, enabled: true })
+ })
+
+ await waitFor(() => {
+ expect(result.current.state?.size).toBe(1) // Only John Smith (age 35)
+ })
+ expect(result.current.data).toHaveLength(1)
+ expect(result.current.isIdle).toBe(false)
+
+ // Change minAge to 25 (should include more people)
+ act(() => {
+ rerender({ minAge: 25, enabled: true })
+ })
+
+ await waitFor(() => {
+ expect(result.current.state?.size).toBe(2) // People with age > 25 (ages 30, 35)
+ })
+ expect(result.current.data).toHaveLength(2)
+
+ // Disable again
+ act(() => {
+ rerender({ minAge: 25, enabled: false })
+ })
+
+ expect(result.current.state).toBeUndefined()
+ expect(result.current.data).toBeUndefined()
+ expect(result.current.status).toBe(`disabled`)
+ expect(result.current.isEnabled).toBe(false)
+ })
+ })
+
+ describe(`aggregates nested inside expressions`, () => {
+ it(`coalesce(count(...), 0) in groupBy select returns count per group`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `nested-agg-test`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ }),
+ )
+
+ const { result } = renderHook(() => {
+ return useLiveQuery((q) =>
+ q
+ .from({ persons: collection })
+ .groupBy(({ persons }) => persons.team)
+ .select(({ persons }) => ({
+ team: persons.team,
+ memberCount: coalesce(count(persons.id), 0),
+ })),
+ )
+ })
+
+ await waitFor(() => {
+ expect(result.current.state.size).toBe(2) // team1 and team2
+ })
+
+ const results = result.current.data
+ expect(
+ stripVirtualProps(results.find((r) => r.team === `team1`)),
+ ).toEqual({
+ team: `team1`,
+ memberCount: 2, // John Doe + John Smith
+ })
+ expect(
+ stripVirtualProps(results.find((r) => r.team === `team2`)),
+ ).toEqual({
+ team: `team2`,
+ memberCount: 1, // Jane Doe
+ })
+ })
+
+ it(`subquery with coalesce(count(...)) can be left-joined as a source`, async () => {
+ const personCollection = createCollection(
+ mockSyncCollectionOptions({
+ id: `nested-agg-join-persons`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ }),
+ )
+
+ const issueCollection = createCollection(
+ mockSyncCollectionOptions({
+ id: `nested-agg-join-issues`,
+ getKey: (issue: Issue) => issue.id,
+ initialData: initialIssues,
+ }),
+ )
+
+ const { result } = renderHook(() => {
+ return useLiveQuery((q) => {
+ const issueCountSubquery = q
+ .from({ issues: issueCollection })
+ .groupBy(({ issues }) => issues.userId)
+ .select(({ issues }) => ({
+ userId: issues.userId,
+ issueCount: coalesce(count(issues.id), 0),
+ }))
+
+ return q
+ .from({ persons: personCollection })
+ .leftJoin({ ic: issueCountSubquery }, ({ persons, ic }) =>
+ eq(persons.id, ic.userId),
+ )
+ .select(({ persons, ic }) => ({
+ name: persons.name,
+ issueCount: ic.issueCount,
+ }))
+ }, [])
+ })
+
+ await waitFor(() => {
+ expect(result.current.state.size).toBeGreaterThan(0)
+ })
+
+ const results = result.current.data
+ expect(
+ stripVirtualProps(results.find((r) => r.name === `John Doe`)),
+ ).toEqual({
+ name: `John Doe`,
+ issueCount: 2, // Issues 1 and 3
+ })
+ expect(
+ stripVirtualProps(results.find((r) => r.name === `Jane Doe`)),
+ ).toEqual({
+ name: `Jane Doe`,
+ issueCount: 1, // Issue 2
+ })
+ })
+
+ it(`coalesce(sum(...), 0) in groupBy select returns sum per group`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ id: `nested-agg-sum-test`,
+ getKey: (person: Person) => person.id,
+ initialData: initialPersons,
+ }),
+ )
+
+ const { result } = renderHook(() => {
+ return useLiveQuery((q) =>
+ q
+ .from({ persons: collection })
+ .groupBy(({ persons }) => persons.team)
+ .select(({ persons }) => ({
+ team: persons.team,
+ totalAge: coalesce(sum(persons.age), 0),
+ })),
+ )
+ })
+
+ await waitFor(() => {
+ expect(result.current.state.size).toBe(2)
+ })
+
+ const results = result.current.data
+ expect(
+ stripVirtualProps(results.find((r) => r.team === `team1`)),
+ ).toEqual({
+ team: `team1`,
+ totalAge: 65, // 30 + 35
+ })
+ expect(
+ stripVirtualProps(results.find((r) => r.team === `team2`)),
+ ).toEqual({
+ team: `team2`,
+ totalAge: 25,
+ })
+ })
+ })
+
+ describe(`includes subqueries`, () => {
+ type Project = {
+ id: string
+ name: string
+ }
+
+ type ProjectIssue = {
+ id: string
+ title: string
+ projectId: string
+ }
+
+ const sampleProjects: Array = [
+ { id: `p1`, name: `Alpha` },
+ { id: `p2`, name: `Beta` },
+ ]
+
+ const sampleProjectIssues: Array = [
+ { id: `i1`, title: `Bug in Alpha`, projectId: `p1` },
+ { id: `i2`, title: `Feature for Alpha`, projectId: `p1` },
+ { id: `i3`, title: `Bug in Beta`, projectId: `p2` },
+ ]
+
+ it(`should render includes results and reactively update child collections`, async () => {
+ const projectsCollection = createCollection(
+ mockSyncCollectionOptions({
+ id: `includes-react-projects`,
+ getKey: (p) => p.id,
+ initialData: sampleProjects,
+ }),
+ )
+
+ const issuesCollection = createCollection(
+ mockSyncCollectionOptions({
+ id: `includes-react-issues`,
+ getKey: (i) => i.id,
+ initialData: sampleProjectIssues,
+ }),
+ )
+
+ // Parent hook: runs includes query that produces child Collections
+ const { result: parentResult } = renderHook(() =>
+ useLiveQuery((q) =>
+ q.from({ p: projectsCollection }).select(({ p }) => ({
+ id: p.id,
+ name: p.name,
+ issues: q
+ .from({ i: issuesCollection })
+ .where(({ i }) => eq(i.projectId, p.id))
+ .select(({ i }) => ({
+ id: i.id,
+ title: i.title,
+ })),
+ })),
+ ),
+ )
+
+ // Wait for parent to be ready
+ await waitFor(() => {
+ expect(parentResult.current.data).toHaveLength(2)
+ })
+
+ const alphaProject = parentResult.current.data.find(
+ (p: any) => p.id === `p1`,
+ )!
+ expect(alphaProject.name).toBe(`Alpha`)
+
+ // Child hook: subscribes to the child Collection from the parent row,
+ // simulating a subcomponent using useLiveQuery(project.issues)
+ const { result: childResult } = renderHook(() =>
+ useLiveQuery((alphaProject as any).issues),
+ )
+
+ await waitFor(() => {
+ expect(childResult.current.data).toHaveLength(2)
+ })
+
+ expect(childResult.current.data).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ id: `i1`, title: `Bug in Alpha` }),
+ expect.objectContaining({ id: `i2`, title: `Feature for Alpha` }),
+ ]),
+ )
+
+ // Add a new issue to Alpha — the child hook should reactively update
+ act(() => {
+ issuesCollection.utils.begin()
+ issuesCollection.utils.write({
+ type: `insert`,
+ value: { id: `i4`, title: `New Alpha issue`, projectId: `p1` },
+ })
+ issuesCollection.utils.commit()
+ })
+
+ await waitFor(() => {
+ expect(childResult.current.data).toHaveLength(3)
+ })
+
+ expect(childResult.current.data).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ id: `i1`, title: `Bug in Alpha` }),
+ expect.objectContaining({ id: `i2`, title: `Feature for Alpha` }),
+ expect.objectContaining({ id: `i4`, title: `New Alpha issue` }),
+ ]),
+ )
+ })
+ })
+})
diff --git a/packages/octane-db/tests/useLiveQueryEffect.test.tsx b/packages/octane-db/tests/useLiveQueryEffect.test.tsx
new file mode 100644
index 0000000000..04df48b1e6
--- /dev/null
+++ b/packages/octane-db/tests/useLiveQueryEffect.test.tsx
@@ -0,0 +1,209 @@
+import { describe, expect, it } from 'vitest'
+import { act, renderHook } from '@octanejs/testing-library'
+import { createCollection, eq } from '@tanstack/db'
+import { useLiveQueryEffect } from '../src/useLiveQueryEffect'
+import { mockSyncCollectionOptions } from '../../db/tests/utils'
+import type { DeltaEvent } from '@tanstack/db'
+
+type User = {
+ id: number
+ name: string
+ active: boolean
+}
+
+const initialUsers: Array = [
+ { id: 1, name: `Alice`, active: true },
+ { id: 2, name: `Bob`, active: true },
+]
+
+const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0))
+
+function createUsersCollection(initialData = initialUsers) {
+ return createCollection(
+ mockSyncCollectionOptions({
+ id: `test-users-hook`,
+ getKey: (user) => user.id,
+ initialData,
+ }),
+ )
+}
+
+describe(`useLiveQueryEffect`, () => {
+ it(`should create effect on mount and dispose on unmount`, async () => {
+ const users = createUsersCollection()
+ const events: Array> = []
+
+ const { unmount } = renderHook(() => {
+ useLiveQueryEffect(
+ {
+ query: (q) => q.from({ user: users }),
+ onEnter: (event) => {
+ events.push(event)
+ },
+ },
+ [],
+ )
+ })
+
+ await act(async () => {
+ await flushPromises()
+ })
+
+ // Should have received enter events for initial data
+ expect(events.length).toBe(2)
+
+ const countBefore = events.length
+
+ // Unmount — should dispose the effect
+ unmount()
+
+ // Insert after unmount
+ users.utils.begin()
+ users.utils.write({
+ type: `insert`,
+ value: { id: 3, name: `Charlie`, active: true },
+ })
+ users.utils.commit()
+
+ await act(async () => {
+ await flushPromises()
+ })
+
+ // Should not have received new events after unmount
+ expect(events.length).toBe(countBefore)
+ })
+
+ it(`should recreate effect when deps change`, async () => {
+ const users = createUsersCollection()
+ const effectIds: Array = []
+
+ const { rerender } = renderHook(
+ ({ dep }: { dep: number }) => {
+ useLiveQueryEffect(
+ {
+ query: (q) => q.from({ user: users }),
+ onEnter: (_event, ctx) => {
+ if (!effectIds.includes(ctx.effectId)) {
+ effectIds.push(ctx.effectId)
+ }
+ },
+ },
+ [dep],
+ )
+ },
+ { initialProps: { dep: 1 } },
+ )
+
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(effectIds.length).toBe(1)
+ const firstId = effectIds[0]
+
+ // Change deps — should dispose old effect and create new one
+ rerender({ dep: 2 })
+
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(effectIds.length).toBe(2)
+ expect(effectIds[1]).not.toBe(firstId)
+ })
+
+ it(`should receive events from source collection changes`, async () => {
+ const users = createUsersCollection()
+ const events: Array> = []
+
+ renderHook(() => {
+ useLiveQueryEffect(
+ {
+ query: (q) =>
+ q.from({ user: users }).where(({ user }) => eq(user.active, true)),
+ onBatch: (batch) => {
+ events.push(...batch)
+ },
+ skipInitial: true,
+ },
+ [],
+ )
+ })
+
+ await act(async () => {
+ await flushPromises()
+ })
+
+ // skipInitial — no initial events
+ expect(events.length).toBe(0)
+
+ // Insert a new active user
+ await act(async () => {
+ users.utils.begin()
+ users.utils.write({
+ type: `insert`,
+ value: { id: 3, name: `Charlie`, active: true },
+ })
+ users.utils.commit()
+ await flushPromises()
+ })
+
+ expect(events.length).toBe(1)
+ expect(events[0]!.type).toBe(`enter`)
+ expect(events[0]!.value.name).toBe(`Charlie`)
+
+ // Delete a user
+ await act(async () => {
+ users.utils.begin()
+ users.utils.write({
+ type: `delete`,
+ value: { id: 1, name: `Alice`, active: true },
+ })
+ users.utils.commit()
+ await flushPromises()
+ })
+
+ expect(events.length).toBe(2)
+ expect(events[1]!.type).toBe(`exit`)
+ expect(events[1]!.value.name).toBe(`Alice`)
+ })
+
+ it(`should use the latest callback without recreating the effect`, async () => {
+ const users = createUsersCollection()
+ const labels: Array = []
+
+ const { rerender } = renderHook(
+ ({ label }: { label: string }) => {
+ useLiveQueryEffect(
+ {
+ query: (q) => q.from({ user: users }),
+ skipInitial: true,
+ onEnter: (event) => {
+ labels.push(`${label}:${event.value.name}`)
+ },
+ },
+ [],
+ )
+ },
+ { initialProps: { label: `first` } },
+ )
+
+ await act(async () => {
+ await flushPromises()
+ })
+
+ rerender({ label: `second` })
+
+ await act(async () => {
+ users.utils.begin()
+ users.utils.write({
+ type: `insert`,
+ value: { id: 3, name: `Charlie`, active: true },
+ })
+ users.utils.commit()
+ await flushPromises()
+ })
+
+ expect(labels).toEqual([`second:Charlie`])
+ })
+})
diff --git a/packages/octane-db/tests/useLiveSuspenseQuery.test.tsx b/packages/octane-db/tests/useLiveSuspenseQuery.test.tsx
new file mode 100644
index 0000000000..5ddc1f7957
--- /dev/null
+++ b/packages/octane-db/tests/useLiveSuspenseQuery.test.tsx
@@ -0,0 +1,685 @@
+import { describe, expect, it } from 'vitest'
+import { renderHook, waitFor } from '@octanejs/testing-library'
+import {
+ createCollection,
+ createLiveQueryCollection,
+ eq,
+ gt,
+} from '@tanstack/db'
+import { Suspense } from 'octane'
+import { useLiveSuspenseQuery } from '../src/useLiveSuspenseQuery'
+import { mockSyncCollectionOptions } from '../../db/tests/utils'
+
+type ChildrenProps = { children: unknown }
+
+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 }: ChildrenProps) {
+ 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