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) +})