diff --git a/.changeset/smooth-goats-ring.md b/.changeset/smooth-goats-ring.md
new file mode 100644
index 000000000..6fc227d34
--- /dev/null
+++ b/.changeset/smooth-goats-ring.md
@@ -0,0 +1,58 @@
+---
+"@tanstack/react-db": patch
+---
+
+Add `useLiveInfiniteQuery` hook for infinite scrolling with live updates.
+
+The new `useLiveInfiniteQuery` hook provides an infinite query pattern similar to TanStack Query's `useInfiniteQuery`, but with live updates from your local collection. It uses `liveQueryCollection.utils.setWindow()` internally to efficiently paginate through ordered data without recreating the query on each page fetch.
+
+**Key features:**
+
+- Automatic live updates as data changes in the collection
+- Efficient pagination using dynamic window adjustment
+- Peek-ahead mechanism to detect when more pages are available
+- Compatible with TanStack Query's infinite query API patterns
+
+**Example usage:**
+
+```tsx
+import { useLiveInfiniteQuery } from "@tanstack/react-db"
+
+function PostList() {
+ const { data, pages, fetchNextPage, hasNextPage, isLoading } =
+ useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: postsCollection })
+ .orderBy(({ posts }) => posts.createdAt, "desc"),
+ {
+ pageSize: 20,
+ getNextPageParam: (lastPage, allPages) =>
+ lastPage.length === 20 ? allPages.length : undefined,
+ }
+ )
+
+ if (isLoading) return
Loading...
+
+ return (
+
+ {pages.map((page, i) => (
+
+ {page.map((post) => (
+
+ ))}
+
+ ))}
+ {hasNextPage && (
+
+ )}
+
+ )
+}
+```
+
+**Requirements:**
+
+- Query must include `.orderBy()` for the window mechanism to work
+- Returns flattened `data` array and `pages` array for flexible rendering
+- Automatically detects new pages when data is synced to the collection
diff --git a/packages/db/src/query/index.ts b/packages/db/src/query/index.ts
index c5e5873cc..17f4dd8e7 100644
--- a/packages/db/src/query/index.ts
+++ b/packages/db/src/query/index.ts
@@ -56,3 +56,4 @@ export {
} from "./live-query-collection.js"
export { type LiveQueryCollectionConfig } from "./live/types.js"
+export { type LiveQueryCollectionUtils } from "./live/collection-config-builder.js"
diff --git a/packages/react-db/src/index.ts b/packages/react-db/src/index.ts
index bd98349f0..bb3cd3ad5 100644
--- a/packages/react-db/src/index.ts
+++ b/packages/react-db/src/index.ts
@@ -1,5 +1,6 @@
// Re-export all public APIs
export * from "./useLiveQuery"
+export * from "./useLiveInfiniteQuery"
// Re-export everything from @tanstack/db
export * from "@tanstack/db"
diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts
new file mode 100644
index 000000000..1e44fd16e
--- /dev/null
+++ b/packages/react-db/src/useLiveInfiniteQuery.ts
@@ -0,0 +1,185 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import { useLiveQuery } from "./useLiveQuery"
+import type {
+ Context,
+ InferResultType,
+ InitialQueryBuilder,
+ LiveQueryCollectionUtils,
+ QueryBuilder,
+} from "@tanstack/db"
+
+/**
+ * Type guard to check if utils object has setWindow method (LiveQueryCollectionUtils)
+ */
+function isLiveQueryCollectionUtils(
+ utils: unknown
+): utils is LiveQueryCollectionUtils {
+ return typeof (utils as any).setWindow === `function`
+}
+
+export type UseLiveInfiniteQueryConfig = {
+ pageSize?: number
+ initialPageParam?: number
+ getNextPageParam: (
+ lastPage: Array[number]>,
+ allPages: Array[number]>>,
+ lastPageParam: number,
+ allPageParams: Array
+ ) => number | undefined
+}
+
+export type UseLiveInfiniteQueryReturn = Omit<
+ ReturnType>,
+ `data`
+> & {
+ data: InferResultType
+ pages: Array[number]>>
+ pageParams: Array
+ fetchNextPage: () => void
+ hasNextPage: boolean
+ isFetchingNextPage: boolean
+}
+
+/**
+ * Create an infinite query using a query function with live updates
+ *
+ * Uses `utils.setWindow()` to dynamically adjust the limit/offset window
+ * without recreating the live query collection on each page change.
+ *
+ * @param queryFn - Query function that defines what data to fetch. Must include `.orderBy()` for setWindow to work.
+ * @param config - Configuration including pageSize and getNextPageParam
+ * @param deps - Array of dependencies that trigger query re-execution when changed
+ * @returns Object with pages, data, and pagination controls
+ *
+ * @example
+ * // Basic infinite query
+ * const { data, pages, fetchNextPage, hasNextPage } = useLiveInfiniteQuery(
+ * (q) => q
+ * .from({ posts: postsCollection })
+ * .orderBy(({ posts }) => posts.createdAt, 'desc')
+ * .select(({ posts }) => ({
+ * id: posts.id,
+ * title: posts.title
+ * })),
+ * {
+ * pageSize: 20,
+ * getNextPageParam: (lastPage, allPages) =>
+ * lastPage.length === 20 ? allPages.length : undefined
+ * }
+ * )
+ *
+ * @example
+ * // With dependencies
+ * const { pages, fetchNextPage } = useLiveInfiniteQuery(
+ * (q) => q
+ * .from({ posts: postsCollection })
+ * .where(({ posts }) => eq(posts.category, category))
+ * .orderBy(({ posts }) => posts.createdAt, 'desc'),
+ * {
+ * pageSize: 10,
+ * getNextPageParam: (lastPage) =>
+ * lastPage.length === 10 ? lastPage.length : undefined
+ * },
+ * [category]
+ * )
+ */
+export function useLiveInfiniteQuery(
+ queryFn: (q: InitialQueryBuilder) => QueryBuilder,
+ config: UseLiveInfiniteQueryConfig,
+ deps: Array = []
+): UseLiveInfiniteQueryReturn {
+ const pageSize = config.pageSize || 20
+ const initialPageParam = config.initialPageParam ?? 0
+
+ // Track how many pages have been loaded
+ const [loadedPageCount, setLoadedPageCount] = useState(1)
+ const [isFetchingNextPage, setIsFetchingNextPage] = useState(false)
+
+ // Stringify deps for comparison
+ const depsKey = JSON.stringify(deps)
+ const prevDepsKeyRef = useRef(depsKey)
+
+ // Reset page count when dependencies change
+ useEffect(() => {
+ if (prevDepsKeyRef.current !== depsKey) {
+ setLoadedPageCount(1)
+ prevDepsKeyRef.current = depsKey
+ }
+ }, [depsKey])
+
+ // Create a live query with initial limit and offset
+ // The query function is wrapped to add limit/offset to the query
+ const queryResult = useLiveQuery(
+ (q) => queryFn(q).limit(pageSize).offset(0),
+ deps
+ )
+
+ // Update the window when loadedPageCount changes
+ // We fetch one extra item to peek if there's a next page
+ useEffect(() => {
+ const newLimit = loadedPageCount * pageSize + 1 // +1 to peek ahead
+ const utils = queryResult.collection.utils
+ // setWindow is available on live query collections with orderBy
+ if (isLiveQueryCollectionUtils(utils)) {
+ const result = utils.setWindow({ offset: 0, limit: newLimit })
+ // setWindow returns true if data is immediately available, or Promise if loading
+ if (result !== true) {
+ setIsFetchingNextPage(true)
+ result.then(() => {
+ setIsFetchingNextPage(false)
+ })
+ } else {
+ setIsFetchingNextPage(false)
+ }
+ }
+ }, [loadedPageCount, pageSize, queryResult.collection])
+
+ // Split the data array into pages and determine if there's a next page
+ const { pages, pageParams, hasNextPage, flatData } = useMemo(() => {
+ const dataArray = queryResult.data as InferResultType
+ const totalItemsRequested = loadedPageCount * pageSize
+
+ // Check if we have more data than requested (the peek ahead item)
+ const hasMore = dataArray.length > totalItemsRequested
+
+ // Build pages array (without the peek ahead item)
+ const pagesResult: Array[number]>> = []
+ const pageParamsResult: Array = []
+
+ for (let i = 0; i < loadedPageCount; i++) {
+ const pageData = dataArray.slice(i * pageSize, (i + 1) * pageSize)
+ pagesResult.push(pageData)
+ pageParamsResult.push(initialPageParam + i)
+ }
+
+ // Flatten the pages for the data return (without peek ahead item)
+ const flatDataResult = dataArray.slice(
+ 0,
+ totalItemsRequested
+ ) as InferResultType
+
+ return {
+ pages: pagesResult,
+ pageParams: pageParamsResult,
+ hasNextPage: hasMore,
+ flatData: flatDataResult,
+ }
+ }, [queryResult.data, loadedPageCount, pageSize, initialPageParam])
+
+ // Fetch next page
+ const fetchNextPage = useCallback(() => {
+ if (!hasNextPage || isFetchingNextPage) return
+
+ setLoadedPageCount((prev) => prev + 1)
+ }, [hasNextPage, isFetchingNextPage])
+
+ return {
+ ...queryResult,
+ data: flatData,
+ pages,
+ pageParams,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ }
+}
diff --git a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx
new file mode 100644
index 000000000..0a8c3eeca
--- /dev/null
+++ b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx
@@ -0,0 +1,967 @@
+import { describe, expect, it } from "vitest"
+import { act, renderHook, waitFor } from "@testing-library/react"
+import { createCollection, eq } from "@tanstack/db"
+import { useLiveInfiniteQuery } from "../src/useLiveInfiniteQuery"
+import { mockSyncCollectionOptions } from "../../db/tests/utils"
+
+type Post = {
+ id: string
+ title: string
+ content: string
+ createdAt: number
+ category: string
+}
+
+const 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
+}
+
+describe(`useLiveInfiniteQuery`, () => {
+ it(`should fetch initial page of data`, async () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ 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({
+ 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({
+ 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({
+ 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({
+ 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({
+ 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 work with where clauses`, async () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ 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({
+ 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({
+ 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({
+ 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({
+ 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({
+ 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({
+ 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({
+ 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({
+ 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 track isFetchingNextPage when async loading is triggered`, async () => {
+ let loadSubsetCallCount = 0
+
+ const collection = createCollection({
+ id: `async-loading-test`,
+ getKey: (post: Post) => post.id,
+ syncMode: `on-demand`,
+ startSync: true,
+ sync: {
+ sync: ({ markReady, begin, write, commit }) => {
+ // Provide initial data
+ begin()
+ for (let i = 1; i <= 15; i++) {
+ write({
+ type: `insert`,
+ value: {
+ id: `${i}`,
+ title: `Post ${i}`,
+ content: `Content ${i}`,
+ createdAt: 1000000 - i * 1000,
+ category: i % 2 === 0 ? `tech` : `life`,
+ },
+ })
+ }
+ commit()
+ markReady()
+
+ return {
+ loadSubset: () => {
+ loadSubsetCallCount++
+
+ // First few calls return true (initial load + window setup)
+ if (loadSubsetCallCount <= 2) {
+ return true
+ }
+
+ // Subsequent calls simulate async loading with a real timeout
+ const loadPromise = new Promise((resolve) => {
+ setTimeout(() => {
+ begin()
+ // Load more data
+ for (let i = 16; i <= 30; i++) {
+ write({
+ type: `insert`,
+ value: {
+ id: `${i}`,
+ title: `Post ${i}`,
+ content: `Content ${i}`,
+ createdAt: 1000000 - i * 1000,
+ category: i % 2 === 0 ? `tech` : `life`,
+ },
+ })
+ }
+ 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)
+})