From 34f88017657c332e50f841aa75e04253bd7746a4 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Thu, 16 Oct 2025 11:39:27 +0100 Subject: [PATCH] enable passing a preloaded live query to useLiveInfiniteQuery --- .changeset/brown-otters-grab.md | 6 + .../query/live/collection-config-builder.ts | 20 + packages/react-db/src/useLiveInfiniteQuery.ts | 174 ++++++-- .../tests/useLiveInfiniteQuery.test.tsx | 409 +++++++++++++++++- 4 files changed, 580 insertions(+), 29 deletions(-) create mode 100644 .changeset/brown-otters-grab.md diff --git a/.changeset/brown-otters-grab.md b/.changeset/brown-otters-grab.md new file mode 100644 index 000000000..35a0e5a37 --- /dev/null +++ b/.changeset/brown-otters-grab.md @@ -0,0 +1,6 @@ +--- +"@tanstack/react-db": patch +"@tanstack/db": patch +--- + +Add support for pre-created live query collections in useLiveInfiniteQuery, enabling router loader patterns where live queries can be created, preloaded, and passed to components. diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 08302fc39..eef3241dc 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -43,6 +43,12 @@ export type LiveQueryCollectionUtils = UtilsRecord & { * @returns `true` if no subset loading was triggered, or `Promise` that resolves when the subset has been loaded */ setWindow: (options: WindowOptions) => true | Promise + /** + * Gets the current window (offset and limit) for an ordered query. + * + * @returns The current window settings, or `undefined` if the query is not windowed + */ + getWindow: () => { offset: number; limit: number } | undefined } type PendingGraphRun = { @@ -93,6 +99,7 @@ export class CollectionConfigBuilder< public liveQueryCollection?: Collection private windowFn: ((options: WindowOptions) => void) | undefined + private currentWindow: WindowOptions | undefined private maybeRunGraphFn: (() => void) | undefined @@ -187,6 +194,7 @@ export class CollectionConfigBuilder< getRunCount: this.getRunCount.bind(this), getBuilder: () => this, setWindow: this.setWindow.bind(this), + getWindow: this.getWindow.bind(this), }, } } @@ -196,6 +204,7 @@ export class CollectionConfigBuilder< throw new SetWindowRequiresOrderByError() } + this.currentWindow = options this.windowFn(options) this.maybeRunGraphFn?.() @@ -219,6 +228,17 @@ export class CollectionConfigBuilder< return true } + getWindow(): { offset: number; limit: number } | undefined { + // Only return window if this is a windowed query (has orderBy and windowFn) + if (!this.windowFn || !this.currentWindow) { + return undefined + } + return { + offset: this.currentWindow.offset ?? 0, + limit: this.currentWindow.limit ?? 0, + } + } + /** * Resolves a collection alias to its collection ID. * diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index 1e44fd16e..650bbe22c 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -1,10 +1,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { CollectionImpl } from "@tanstack/db" import { useLiveQuery } from "./useLiveQuery" import type { + Collection, Context, InferResultType, InitialQueryBuilder, LiveQueryCollectionUtils, + NonSingleResult, QueryBuilder, } from "@tanstack/db" @@ -82,61 +85,176 @@ export type UseLiveInfiniteQueryReturn = Omit< * }, * [category] * ) + * + * @example + * // Router loader pattern with pre-created collection + * // In loader: + * const postsQuery = createLiveQueryCollection({ + * query: (q) => q + * .from({ posts: postsCollection }) + * .orderBy(({ posts }) => posts.createdAt, 'desc') + * .limit(20) + * }) + * await postsQuery.preload() + * return { postsQuery } + * + * // In component: + * const { postsQuery } = useLoaderData() + * const { data, fetchNextPage, hasNextPage } = useLiveInfiniteQuery( + * postsQuery, + * { + * pageSize: 20, + * getNextPageParam: (lastPage) => lastPage.length === 20 ? lastPage.length : undefined + * } + * ) */ + +// Overload for pre-created collection (non-single result) +export function useLiveInfiniteQuery< + TResult extends object, + TKey extends string | number, + TUtils extends Record, +>( + liveQueryCollection: Collection & NonSingleResult, + config: UseLiveInfiniteQueryConfig +): UseLiveInfiniteQueryReturn + +// Overload for query function export function useLiveInfiniteQuery( queryFn: (q: InitialQueryBuilder) => QueryBuilder, config: UseLiveInfiniteQueryConfig, + deps?: Array +): UseLiveInfiniteQueryReturn + +// Implementation +export function useLiveInfiniteQuery( + queryFnOrCollection: any, + config: UseLiveInfiniteQueryConfig, deps: Array = [] ): UseLiveInfiniteQueryReturn { const pageSize = config.pageSize || 20 const initialPageParam = config.initialPageParam ?? 0 + // Detect if input is a collection or query function + const isCollection = queryFnOrCollection instanceof CollectionImpl + + // Validate input type + if (!isCollection && typeof queryFnOrCollection !== `function`) { + throw new Error( + `useLiveInfiniteQuery: First argument must be either a pre-created live query collection (CollectionImpl) ` + + `or a query function. Received: ${typeof queryFnOrCollection}` + ) + } + // Track how many pages have been loaded const [loadedPageCount, setLoadedPageCount] = useState(1) const [isFetchingNextPage, setIsFetchingNextPage] = useState(false) - // Stringify deps for comparison + // Track collection instance and whether we've validated it (only for pre-created collections) + const collectionRef = useRef(isCollection ? queryFnOrCollection : null) + const hasValidatedCollectionRef = useRef(false) + + // Track deps for query functions (stringify for comparison) const depsKey = JSON.stringify(deps) const prevDepsKeyRef = useRef(depsKey) - // Reset page count when dependencies change + // Reset pagination when inputs change useEffect(() => { - if (prevDepsKeyRef.current !== depsKey) { + let shouldReset = false + + if (isCollection) { + // Reset if collection instance changed + if (collectionRef.current !== queryFnOrCollection) { + collectionRef.current = queryFnOrCollection + hasValidatedCollectionRef.current = false + shouldReset = true + } + } else { + // Reset if deps changed (for query functions) + if (prevDepsKeyRef.current !== depsKey) { + prevDepsKeyRef.current = depsKey + shouldReset = true + } + } + + if (shouldReset) { setLoadedPageCount(1) - prevDepsKeyRef.current = depsKey } - }, [depsKey]) + }, [isCollection, queryFnOrCollection, 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 + // Either pass collection directly or wrap query function + const queryResult = isCollection + ? useLiveQuery(queryFnOrCollection) + : useLiveQuery( + (q) => queryFnOrCollection(q).limit(pageSize).offset(0), + deps + ) + + // Adjust window when pagination changes 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) + const expectedOffset = 0 + const expectedLimit = loadedPageCount * pageSize + 1 // +1 for peek ahead + + // Check if collection has orderBy (required for setWindow) + if (!isLiveQueryCollectionUtils(utils)) { + // For pre-created collections, throw an error if no orderBy + if (isCollection) { + throw new Error( + `useLiveInfiniteQuery: Pre-created live query collection must have an orderBy clause for infinite pagination to work. ` + + `Please add .orderBy() to your createLiveQueryCollection query.` + ) + } + return + } + + // For pre-created collections, validate window on first check + if (isCollection && !hasValidatedCollectionRef.current) { + const currentWindow = utils.getWindow() + if ( + currentWindow && + (currentWindow.offset !== expectedOffset || + currentWindow.limit !== expectedLimit) + ) { + console.warn( + `useLiveInfiniteQuery: Pre-created collection has window {offset: ${currentWindow.offset}, limit: ${currentWindow.limit}} ` + + `but hook expects {offset: ${expectedOffset}, limit: ${expectedLimit}}. Adjusting window now.` + ) } + hasValidatedCollectionRef.current = true } - }, [loadedPageCount, pageSize, queryResult.collection]) + + // For query functions, wait until collection is ready + if (!isCollection && !queryResult.isReady) return + + // Adjust the window + const result = utils.setWindow({ + offset: expectedOffset, + limit: expectedLimit, + }) + + if (result !== true) { + setIsFetchingNextPage(true) + result.then(() => { + setIsFetchingNextPage(false) + }) + } else { + setIsFetchingNextPage(false) + } + }, [ + isCollection, + queryResult.collection, + queryResult.isReady, + loadedPageCount, + pageSize, + ]) // 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 dataArray = ( + Array.isArray(queryResult.data) ? queryResult.data : [] + ) as InferResultType const totalItemsRequested = loadedPageCount * pageSize // Check if we have more data than requested (the peek ahead item) @@ -181,5 +299,5 @@ export function useLiveInfiniteQuery( fetchNextPage, hasNextPage, isFetchingNextPage, - } + } as UseLiveInfiniteQueryReturn } diff --git a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx index 0a8c3eeca..1496a27f1 100644 --- a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx +++ b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest" import { act, renderHook, waitFor } from "@testing-library/react" -import { createCollection, eq } from "@tanstack/db" +import { createCollection, createLiveQueryCollection, eq } from "@tanstack/db" import { useLiveInfiniteQuery } from "../src/useLiveInfiniteQuery" import { mockSyncCollectionOptions } from "../../db/tests/utils" @@ -964,4 +964,411 @@ describe(`useLiveInfiniteQuery`, () => { 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({ + 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({ + 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({ + 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({ + 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(`should throw error if collection lacks orderBy`, async () => { + const posts = createMockPosts(50) + const collection = createCollection( + mockSyncCollectionOptions({ + 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 error when trying to use it with useLiveInfiniteQuery + expect(() => { + renderHook(() => { + return useLiveInfiniteQuery(liveQueryCollection, { + pageSize: 10, + getNextPageParam: (lastPage) => + lastPage.length === 10 ? lastPage.length : undefined, + }) + }) + }).toThrow(/ORDER BY/) + }) + + 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({ + 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({ + 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({ + 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) + }) + }) })