diff --git a/packages/react-db/src/index.ts b/packages/react-db/src/index.ts index bd98349f..bb3cd3ad 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 00000000..71be7f45 --- /dev/null +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -0,0 +1,195 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import type { + Context, + InferResultType, + InitialQueryBuilder, + QueryBuilder, +} from "@tanstack/db" +import { useLiveQuery } from "./useLiveQuery" + +export type UseLiveInfiniteQueryConfig = { + pageSize?: number + initialPageParam?: number + getNextPageParam: ( + lastPage: Array[number]>, + allPages: Array[number]>>, + lastPageParam: number, + allPageParams: Array + ) => number | undefined +} + +export type UseLiveInfiniteQueryReturn = { + data: InferResultType + pages: Array[number]>> + pageParams: Array + fetchNextPage: () => void + hasNextPage: boolean + isFetchingNextPage: boolean + // From useLiveQuery + state: ReturnType>["state"] + collection: ReturnType>["collection"] + status: ReturnType>["status"] + isLoading: ReturnType>["isLoading"] + isReady: ReturnType>["isReady"] + isIdle: ReturnType>["isIdle"] + isError: ReturnType>["isError"] + isCleanedUp: ReturnType>["isCleanedUp"] + isEnabled: ReturnType>["isEnabled"] +} + +/** + * Create an infinite query using a query function with live updates + * + * Phase 1 implementation: Operates within the collection's current dataset. + * Fetching "next page" loads more data from the collection, not from a backend. + * + * @param queryFn - Query function that defines what data to fetch + * @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 isFetchingRef = useRef(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 without limit - fetch all matching data + // Phase 1: Client-side slicing is acceptable + // Phase 2: Will add limit optimization with dynamic adjustment + const queryResult = useLiveQuery((q) => queryFn(q), deps) + + // Split the flat data array into pages + const pages = useMemo(() => { + const result: Array[number]>> = [] + const dataArray = queryResult.data as InferResultType + + for (let i = 0; i < loadedPageCount; i++) { + const pageData = dataArray.slice(i * pageSize, (i + 1) * pageSize) + result.push(pageData) + } + + return result + }, [queryResult.data, loadedPageCount, pageSize]) + + // Track page params used (for TanStack Query API compatibility) + const pageParams = useMemo(() => { + const params: Array = [] + for (let i = 0; i < pages.length; i++) { + params.push(initialPageParam + i) + } + return params + }, [pages.length, initialPageParam]) + + // Determine if there are more pages available + const hasNextPage = useMemo(() => { + if (pages.length === 0) return false + + const lastPage = pages[pages.length - 1] + const lastPageParam = pageParams[pageParams.length - 1] + + // Ensure lastPage and lastPageParam are defined before calling getNextPageParam + if (!lastPage || lastPageParam === undefined) return false + + // Call user's getNextPageParam to determine if there's more + const nextParam = config.getNextPageParam( + lastPage, + pages, + lastPageParam, + pageParams + ) + + return nextParam !== undefined + }, [pages, pageParams, config]) + + // Fetch next page + const fetchNextPage = useCallback(() => { + if (!hasNextPage || isFetchingRef.current) return + + isFetchingRef.current = true + setLoadedPageCount((prev) => prev + 1) + + // Reset fetching state synchronously + Promise.resolve().then(() => { + isFetchingRef.current = false + }) + }, [hasNextPage]) + + // Calculate flattened data from pages + const flatData = useMemo(() => { + const result: Array[number]> = [] + for (const page of pages) { + result.push(...page) + } + return result as InferResultType + }, [pages]) + + return { + data: flatData, + pages, + pageParams, + fetchNextPage, + hasNextPage, + isFetchingNextPage: isFetchingRef.current, + // Pass through useLiveQuery properties + state: queryResult.state, + collection: queryResult.collection, + status: queryResult.status, + isLoading: queryResult.isLoading, + isReady: queryResult.isReady, + isIdle: queryResult.isIdle, + isError: queryResult.isError, + isCleanedUp: queryResult.isCleanedUp, + isEnabled: queryResult.isEnabled, + } +} diff --git a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx new file mode 100644 index 00000000..ed543ed4 --- /dev/null +++ b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx @@ -0,0 +1,723 @@ +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) + // Should still have next page since lastPage is full + // In Phase 1, we can't know if there's more data without trying to fetch it + // This is a limitation that will be resolved in Phase 3 with backend integration + expect(result.current.hasNextPage).toBe(true) + + // Try to fetch page 3 - should get empty page + act(() => { + result.current.fetchNextPage() + }) + + await waitFor(() => { + expect(result.current.pages).toHaveLength(3) + }) + + // Page 3 should be empty + expect(result.current.pages[2]).toHaveLength(0) + + // Now hasNextPage should be false because page 3 is empty + expect(result.current.hasNextPage).toBe(false) + }) + + 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) + }) + + // Try to fetch multiple times rapidly + act(() => { + result.current.fetchNextPage() + result.current.fetchNextPage() + result.current.fetchNextPage() + }) + + await waitFor(() => { + expect(result.current.pages).toHaveLength(2) + }) + + // Should only have fetched one additional page + expect(result.current.pages).toHaveLength(2) + }) + + 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]) + }) + }) +})