diff --git a/.changeset/live-query-window-controller.md b/.changeset/live-query-window-controller.md new file mode 100644 index 000000000..bdf4f7a5a --- /dev/null +++ b/.changeset/live-query-window-controller.md @@ -0,0 +1,14 @@ +--- +'@tanstack/db': patch +'@tanstack/react-db': patch +--- + +feat(db): shared live-query window controller for infinite queries + +Adds `createLiveQueryWindowController` to `@tanstack/db` — the framework-agnostic +forward-pagination state machine (loaded-page count, peek-ahead window via +`setWindow`, page slicing, `hasNextPage`/`isFetchingNextPage`) that composes the +live-query observer. `react-db`'s `useLiveInfiniteQuery` is reimplemented as a +thin binding over it with no public API change, so other framework adapters can +build infinite queries on the same shared semantics instead of re-porting the +React hook. diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index bf4e16a81..9958d56be 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -12,6 +12,7 @@ export * from './query/index.js' export * from './optimistic-action' export * from './live-query-adapter' export * from './live-query-observer' +export * from './live-query-window-controller' export * from './local-only' export * from './local-storage' export * from './errors' diff --git a/packages/db/src/live-query-window-controller.ts b/packages/db/src/live-query-window-controller.ts new file mode 100644 index 000000000..8c40ed585 --- /dev/null +++ b/packages/db/src/live-query-window-controller.ts @@ -0,0 +1,307 @@ +import { createLiveQueryObserver } from './live-query-observer.js' +import type { + CreateLiveQueryObserverOptions, + LiveQueryObserver, +} from './live-query-observer.js' +import type { Collection } from './collection/index.js' +import type { CollectionStatus } from './types.js' + +const DEFAULT_PAGE_SIZE = 20 + +/** + * A page-windowed view of a live query at a point in time. Extends the live + * query's status/data contract with forward pagination derived from a + * peek-ahead window (`limit = loadedPages * pageSize + 1`): the extra row tells + * us whether another page exists and is then dropped from `data`/`pages`. + * + * `getSnapshot()` returns a stable identity that only changes when the query, + * the page count, or the fetching state changes, so `useSyncExternalStore`-style + * consumers can compare by reference. + */ +export interface LiveQueryWindowSnapshot< + T extends object, + TKey extends string | number, +> { + /** Rows across all loaded pages, peek-ahead row removed. */ + data: ReadonlyArray + /** Rows grouped into pages of `pageSize`. */ + pages: ReadonlyArray> + /** `initialPageParam + i` for each loaded page. */ + pageParams: ReadonlyArray + hasNextPage: boolean + isFetchingNextPage: boolean + /** Keyed results for the whole window (incl. peek row), or `undefined` when disabled. */ + state: ReadonlyMap | undefined + collection: Collection | undefined + status: CollectionStatus | `disabled` + isLoading: boolean + isReady: boolean + isIdle: boolean + isError: boolean + isCleanedUp: boolean + isEnabled: boolean +} + +export interface CreateLiveQueryWindowControllerOptions extends CreateLiveQueryObserverOptions { + /** Rows per page (default 20). A falsy value falls back to the default. */ + pageSize?: number + /** Value of the first page's `pageParam` (default 0). */ + initialPageParam?: number + /** + * Defer applying the first window until the collection is ready. Set for + * query-function inputs whose collection is created lazily and already carries + * the first page's window in its query; leave off for a pre-created collection + * whose window must be established up front. + */ + waitForReady?: boolean +} + +/** + * Owns the forward-pagination state machine for an ordered live query: the + * loaded-page count, the peek-ahead window (via `collection.utils.setWindow`), + * page slicing, and `hasNextPage`/`isFetchingNextPage`. Composes a + * {@link LiveQueryObserver} for the data + lifecycle channel. Framework adapters + * resolve the input to a collection and materialize the snapshot natively. + */ +export interface LiveQueryWindowController< + T extends object, + TKey extends string | number, +> { + getSnapshot: () => LiveQueryWindowSnapshot + subscribe: (listener: () => void) => () => void + /** Load one more page (no-op when already fetching or no next page exists). */ + fetchNextPage: () => void + /** Reset back to the first page — call when the input identity/deps change. */ + reset: () => void + preload: () => Promise + dispose: () => void +} + +interface CachedFrom { + observerSnapshot: unknown + loadedPageCount: number + isFetchingNextPage: boolean +} + +class LiveQueryWindowControllerImpl< + T extends object, + TKey extends string | number, +> implements LiveQueryWindowController { + private readonly observer: LiveQueryObserver + private readonly collection: Collection | null + private readonly pageSize: number + private readonly initialPageParam: number + private readonly waitForReady: boolean + + private loadedPageCount = 1 + private isFetchingNextPage = false + // The limit last handed to `setWindow`, so we don't re-apply an unchanged + // window on every observer notification. + private appliedLimit: number | undefined + // Bumped on each window application so a superseded load promise doesn't clear + // the fetching flag for a window that no longer applies. + private windowGeneration = 0 + + private readonly listeners = new Set<() => void>() + private observerUnsub: (() => void) | null = null + private cachedSnapshot: LiveQueryWindowSnapshot | null = null + private cachedFrom: CachedFrom | null = null + private disposed = false + + constructor( + collection: Collection | null, + options: CreateLiveQueryWindowControllerOptions, + ) { + this.collection = collection + this.pageSize = options.pageSize || DEFAULT_PAGE_SIZE + this.initialPageParam = options.initialPageParam ?? 0 + this.waitForReady = options.waitForReady ?? false + this.observer = createLiveQueryObserver(collection, { + deferInitialNotify: options.deferInitialNotify, + }) + } + + getSnapshot(): LiveQueryWindowSnapshot { + const observerSnapshot = this.observer.getSnapshot() + const cached = this.cachedSnapshot + if ( + cached && + this.cachedFrom && + this.cachedFrom.observerSnapshot === observerSnapshot && + this.cachedFrom.loadedPageCount === this.loadedPageCount && + this.cachedFrom.isFetchingNextPage === this.isFetchingNextPage + ) { + return cached + } + + const enabled = observerSnapshot.isEnabled + const rows = + enabled && Array.isArray(observerSnapshot.data) + ? (observerSnapshot.data as ReadonlyArray) + : [] + const totalRequested = this.loadedPageCount * this.pageSize + // The window peeks one row past what was requested; its presence means + // there is another page. It is not part of the visible result. + const hasNextPage = enabled && rows.length > totalRequested + + // A disabled query has no pages; an enabled query always has `loadedPageCount` + // pages (the last may be empty when there is no data yet). + const pageCount = enabled ? this.loadedPageCount : 0 + const pages: Array> = [] + const pageParams: Array = [] + for (let i = 0; i < pageCount; i++) { + pages.push(rows.slice(i * this.pageSize, (i + 1) * this.pageSize)) + pageParams.push(this.initialPageParam + i) + } + + this.cachedSnapshot = { + data: rows.slice(0, totalRequested), + pages, + pageParams, + hasNextPage, + isFetchingNextPage: this.isFetchingNextPage, + state: observerSnapshot.state, + collection: observerSnapshot.collection, + status: observerSnapshot.status, + isLoading: observerSnapshot.isLoading, + isReady: observerSnapshot.isReady, + isIdle: observerSnapshot.isIdle, + isError: observerSnapshot.isError, + isCleanedUp: observerSnapshot.isCleanedUp, + isEnabled: observerSnapshot.isEnabled, + } + this.cachedFrom = { + observerSnapshot, + loadedPageCount: this.loadedPageCount, + isFetchingNextPage: this.isFetchingNextPage, + } + return this.cachedSnapshot + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + if (this.listeners.size === 1) { + this.observerUnsub = this.observer.subscribe(() => + this.onObserverNotify(), + ) + // Establish the current window now that the query is active. + this.applyWindow() + } + + let active = true + return () => { + if (!active) return + active = false + this.listeners.delete(listener) + if (this.listeners.size === 0) { + this.observerUnsub?.() + this.observerUnsub = null + } + } + } + + fetchNextPage(): void { + if (this.disposed || this.isFetchingNextPage) return + if (!this.getSnapshot().hasNextPage) return + this.loadedPageCount++ + this.applyWindow() + this.notify() + } + + reset(): void { + if (this.disposed) return + if (this.loadedPageCount === 1 && this.appliedLimit !== undefined) { + // Already on the first page; nothing to reset. + return + } + this.loadedPageCount = 1 + this.appliedLimit = undefined + this.applyWindow() + this.notify() + } + + preload(): Promise { + return this.observer.preload() + } + + dispose(): void { + if (this.disposed) return + this.disposed = true + this.observerUnsub?.() + this.observerUnsub = null + this.observer.dispose() + this.listeners.clear() + } + + private onObserverNotify(): void { + // Re-apply the window in case readiness just changed (a deferred first + // apply) — idempotent when the window is unchanged — then republish. + this.applyWindow() + this.notify() + } + + private applyWindow(): void { + const collection = this.collection + if (!collection || this.disposed) return + if (this.waitForReady && !this.observer.getSnapshot().isReady) return + + const limit = this.loadedPageCount * this.pageSize + 1 + if (limit === this.appliedLimit) return + this.appliedLimit = limit + + const utils = collection.utils as + | { + setWindow?: (o: { + offset: number + limit: number + }) => true | Promise + } + | undefined + if (typeof utils?.setWindow !== `function`) return + + const generation = ++this.windowGeneration + const result = utils.setWindow({ offset: 0, limit }) + if (result === true) { + this.setFetching(false) + return + } + + this.setFetching(true) + result + .catch(() => { + // Swallow — the load error surfaces through the collection's status. + }) + .finally(() => { + // Only clear for the window this call requested; a newer apply owns the + // flag otherwise. + if (!this.disposed && generation === this.windowGeneration) { + this.setFetching(false) + } + }) + } + + private setFetching(value: boolean): void { + if (this.isFetchingNextPage === value) return + this.isFetchingNextPage = value + this.notify() + } + + private notify(): void { + this.listeners.forEach((listener) => listener()) + } +} + +/** + * Create a {@link LiveQueryWindowController} for a resolved, ordered live-query + * collection (which must have an `orderBy`), or a disabled controller when + * `collection` is `null`/`undefined`. + */ +export function createLiveQueryWindowController< + T extends object, + TKey extends string | number, +>( + collection: Collection | null | undefined, + options: CreateLiveQueryWindowControllerOptions = {}, +): LiveQueryWindowController { + return new LiveQueryWindowControllerImpl(collection ?? null, options) +} diff --git a/packages/db/tests/live-query-window-controller.test.ts b/packages/db/tests/live-query-window-controller.test.ts new file mode 100644 index 000000000..59ab46a41 --- /dev/null +++ b/packages/db/tests/live-query-window-controller.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest' +import { createCollection } from '../src/collection/index.js' +import { createLiveQueryCollection } from '../src/query/live-query-collection.js' +import { createLiveQueryWindowController } from '../src/live-query-window-controller.js' +import { mockSyncCollectionOptions } from './utils.js' + +interface Row { + id: string + n: number +} + +const ROWS: Array = [1, 2, 3, 4, 5].map((n) => ({ id: String(n), n })) + +let seq = 0 +function makeSource() { + return createCollection( + mockSyncCollectionOptions({ + id: `window-ctrl-${seq++}`, + getKey: (r) => r.id, + initialData: ROWS, + }), + ) +} + +/** Ordered live query with page 1's peek-ahead window baked in, as the React adapter builds it. */ +function makeOrderedLiveQuery( + source: ReturnType, + pageSize: number, +) { + return createLiveQueryCollection({ + query: (q) => + q + .from({ r: source }) + .orderBy(({ r }) => r.n, `asc`) + .limit(pageSize + 1) + .offset(0) + .select(({ r }) => ({ id: r.id, n: r.n })), + startSync: true, + gcTime: 1, + }) +} + +const flush = () => new Promise((r) => setTimeout(r, 0)) + +const ids = (snap: { data: ReadonlyArray }) => snap.data.map((r) => r.id) + +describe(`createLiveQueryWindowController`, () => { + it(`exposes the first page with a peek-ahead hasNextPage`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + controller.subscribe(() => {}) + await lq.preload() + await flush() + + const snap = controller.getSnapshot() + expect(ids(snap)).toEqual([`1`, `2`]) + expect(snap.pages.map((p) => p.map((r) => r.id))).toEqual([[`1`, `2`]]) + expect(snap.pageParams).toEqual([0]) + expect(snap.hasNextPage).toBe(true) + expect(snap.isFetchingNextPage).toBe(false) + controller.dispose() + }) + + it(`loads further pages via fetchNextPage until the source is exhausted`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + controller.subscribe(() => {}) + await lq.preload() + await flush() + + controller.fetchNextPage() + await flush() + let snap = controller.getSnapshot() + expect(ids(snap)).toEqual([`1`, `2`, `3`, `4`]) + expect(snap.pages.map((p) => p.map((r) => r.id))).toEqual([ + [`1`, `2`], + [`3`, `4`], + ]) + expect(snap.pageParams).toEqual([0, 1]) + expect(snap.hasNextPage).toBe(true) + + controller.fetchNextPage() + await flush() + snap = controller.getSnapshot() + // 5 rows total; the 3rd page is a partial page and there is no peek row. + expect(ids(snap)).toEqual([`1`, `2`, `3`, `4`, `5`]) + expect(snap.pages.map((p) => p.map((r) => r.id))).toEqual([ + [`1`, `2`], + [`3`, `4`], + [`5`], + ]) + expect(snap.hasNextPage).toBe(false) + controller.dispose() + }) + + it(`fetchNextPage is a no-op when there is no next page`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 10) // pageSize > row count + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 10, + }) + controller.subscribe(() => {}) + await lq.preload() + await flush() + expect(controller.getSnapshot().hasNextPage).toBe(false) + + controller.fetchNextPage() + await flush() + expect(ids(controller.getSnapshot())).toEqual([`1`, `2`, `3`, `4`, `5`]) + expect(controller.getSnapshot().pages).toHaveLength(1) + controller.dispose() + }) + + it(`reset returns to the first page`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + controller.subscribe(() => {}) + await lq.preload() + await flush() + + controller.fetchNextPage() + await flush() + expect(controller.getSnapshot().pages).toHaveLength(2) + + controller.reset() + await flush() + const snap = controller.getSnapshot() + expect(ids(snap)).toEqual([`1`, `2`]) + expect(snap.pages).toHaveLength(1) + controller.dispose() + }) + + it(`notifies subscribers on data changes and page changes`, async () => { + const source = makeSource() + const lq = makeOrderedLiveQuery(source, 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + let notifications = 0 + controller.subscribe(() => notifications++) + await lq.preload() + await flush() + + notifications = 0 + controller.fetchNextPage() + await flush() + expect(notifications).toBeGreaterThan(0) + controller.dispose() + }) + + it(`returns a stable snapshot identity when nothing changed`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + controller.subscribe(() => {}) + await lq.preload() + await flush() + expect(controller.getSnapshot()).toBe(controller.getSnapshot()) + controller.dispose() + }) + + it(`represents a disabled controller (null collection)`, () => { + const controller = createLiveQueryWindowController(null) + const snap = controller.getSnapshot() + expect(snap.isEnabled).toBe(false) + expect(snap.data).toEqual([]) + expect(snap.hasNextPage).toBe(false) + expect(snap.pages).toEqual([]) + controller.dispose() + }) +}) diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index 99c77c739..00a524652 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -1,23 +1,27 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { CollectionImpl } from '@tanstack/db' -import { useLiveQuery } from './useLiveQuery' +import { useCallback, useRef, useSyncExternalStore } from 'react' +import { + CollectionImpl, + createLiveQueryCollection, + createLiveQueryWindowController, +} from '@tanstack/db' +// Type-only: used in `ReturnType` in UseLiveInfiniteQueryReturn. +import type { useLiveQuery } from './useLiveQuery' import type { Collection, Context, InferResultType, InitialQueryBuilder, - LiveQueryCollectionUtils, + LiveQueryWindowController, NonSingleResult, 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` +// Live queries created here are cleaned up immediately (0 disables GC). +const DEFAULT_GC_TIME_MS = 1 + +/** Type guard: does this collection expose `setWindow` (i.e. has an orderBy)? */ +function hasSetWindow(collection: Collection): boolean { + return typeof collection.utils?.setWindow === `function` } export type UseLiveInfiniteQueryConfig = { @@ -151,14 +155,6 @@ export function useLiveInfiniteQuery( ) } - // Track how many pages have been loaded - const [loadedPageCount, setLoadedPageCount] = useState(1) - const [isFetchingNextPage, setIsFetchingNextPage] = useState(false) - - // 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) let depsKey: string try { @@ -169,162 +165,116 @@ export function useLiveInfiniteQuery( `Ensure all dependency values are JSON-serializable.`, ) } - const prevDepsKeyRef = useRef(depsKey) - - // Reset pagination when inputs change - useEffect(() => { - let shouldReset = false + const collectionRef = useRef | null>(null) + const controllerRef = useRef | null>(null) + const configRef = useRef(null) + const depsRef = useRef(null) + const pageSizeRef = useRef(pageSize) + const initialPageParamRef = useRef(initialPageParam) + const validatedCollectionRef = useRef(null) + + // Recreate the underlying collection + controller when the input identity + // (pre-created collection), the deps (query function), or the page shape + // (`pageSize`/`initialPageParam`) change. A fresh controller starts back at + // page 1, which is the desired reset behaviour. + const needsNew = + !controllerRef.current || + pageSizeRef.current !== pageSize || + initialPageParamRef.current !== initialPageParam || + (isCollection && configRef.current !== queryFnOrCollection) || + (!isCollection && depsRef.current !== depsKey) + + if (needsNew) { + pageSizeRef.current = pageSize + initialPageParamRef.current = initialPageParam 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) - } - }, [isCollection, queryFnOrCollection, depsKey]) - - // Create a live query with initial limit and offset - // Either pass collection directly or wrap query function - // Use pageSize + 1 for peek-ahead detection (to know if there are more pages) - const queryResult = isCollection - ? useLiveQuery(queryFnOrCollection) - : useLiveQuery( - (q) => - queryFnOrCollection(q) - .limit(pageSize + 1) - .offset(0), - deps, - ) - - // Adjust window when pagination changes - useEffect(() => { - const utils = queryResult.collection.utils - 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) { + const collection = queryFnOrCollection as Collection + if (!hasSetWindow(collection)) { 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.`, - ) + // Warn once per collection instance if its current window doesn't match + // the first page the hook is about to enforce. + if (validatedCollectionRef.current !== collection) { + validatedCollectionRef.current = collection + const currentWindow = collection.utils.getWindow?.() + if ( + currentWindow && + (currentWindow.offset !== 0 || currentWindow.limit !== pageSize + 1) + ) { + console.warn( + `useLiveInfiniteQuery: Pre-created collection has window {offset: ${currentWindow.offset}, limit: ${currentWindow.limit}} ` + + `but the hook expects {offset: 0, limit: ${pageSize + 1}}. Adjusting window now.`, + ) + } } - hasValidatedCollectionRef.current = true - } - - // For query functions, wait until collection is ready - if (!isCollection && !queryResult.isReady) return - - // Adjust the window - let cancelled = false - const result = utils.setWindow({ - offset: expectedOffset, - limit: expectedLimit, - }) - - if (result !== true) { - setIsFetchingNextPage(true) - result - .catch((error: unknown) => { - if (!cancelled) - console.error(`useLiveInfiniteQuery: setWindow failed:`, error) - }) - .finally(() => { - if (!cancelled) setIsFetchingNextPage(false) - }) + collection.startSyncImmediate() + collectionRef.current = collection + configRef.current = queryFnOrCollection } else { - setIsFetchingNextPage(false) - } - - return () => { - cancelled = true - } - }, [ - 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 = ( - Array.isArray(queryResult.data) ? 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) + // Wrap the query with the first page's peek-ahead window; the controller + // grows the limit from here via setWindow. + collectionRef.current = createLiveQueryCollection({ + query: (q: InitialQueryBuilder) => + queryFnOrCollection(q) + .limit(pageSize + 1) + .offset(0), + startSync: true, + gcTime: DEFAULT_GC_TIME_MS, + }) + depsRef.current = depsKey } + controllerRef.current = createLiveQueryWindowController( + collectionRef.current, + { + pageSize, + initialPageParam, + // useSyncExternalStore must not be notified synchronously on subscribe. + deferInitialNotify: true, + // A query-function collection already carries page 1's window in its + // query, so defer the (redundant) first apply until it is ready; a + // pre-created collection needs its window established up front. + waitForReady: !isCollection, + }, + ) + } + const controller = controllerRef.current! + + // Stable subscribe bound to the current controller. + const subscribeRef = useRef< + ((onStoreChange: () => void) => () => void) | null + >(null) + if (!subscribeRef.current || needsNew) { + subscribeRef.current = (onStoreChange) => + controller.subscribe(onStoreChange) + } - // 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]) + const snapshot = useSyncExternalStore(subscribeRef.current, () => + controller.getSnapshot(), + ) - // Fetch next page const fetchNextPage = useCallback(() => { - if (!hasNextPage || isFetchingNextPage) return - - setLoadedPageCount((prev) => prev + 1) - }, [hasNextPage, isFetchingNextPage]) + controllerRef.current?.fetchNextPage() + }, []) return { - ...queryResult, - data: flatData, - pages, - pageParams, + data: snapshot.data as InferResultType, + state: snapshot.state, + status: snapshot.status, + isLoading: snapshot.isLoading, + isReady: snapshot.isReady, + isIdle: snapshot.isIdle, + isError: snapshot.isError, + isCleanedUp: snapshot.isCleanedUp, + collection: snapshot.collection, + isEnabled: snapshot.isEnabled, + pages: snapshot.pages as Array[number]>>, + pageParams: snapshot.pageParams as Array, fetchNextPage, - hasNextPage, - isFetchingNextPage, + hasNextPage: snapshot.hasNextPage, + isFetchingNextPage: snapshot.isFetchingNextPage, } as UseLiveInfiniteQueryReturn } diff --git a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx index 9aa63244e..e961167de 100644 --- a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx +++ b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx @@ -1,7 +1,11 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { act, renderHook, waitFor } from '@testing-library/react' -import { createCollection, createLiveQueryCollection, eq } from '@tanstack/db' -import { BTreeIndex } from '@tanstack/db' +import { + BTreeIndex, + createCollection, + createLiveQueryCollection, + eq, +} from '@tanstack/db' import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' import { mockSyncCollectionOptions } from '../../db/tests/utils' import { createFilterFunctionFromExpression } from '../../db/src/collection/change-events' @@ -695,6 +699,44 @@ describe(`useLiveInfiniteQuery`, () => { }) }) + it(`re-windows and re-slices when pageSize changes at runtime`, async () => { + const posts = createMockPosts(50) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `pagesize-change-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + const { result, rerender } = renderHook( + ({ pageSize }: { pageSize: number }) => + useLiveInfiniteQuery( + (q) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { pageSize }, + ), + { initialProps: { pageSize: 5 } }, + ) + + await waitFor(() => expect(result.current.isReady).toBe(true)) + expect(result.current.data).toHaveLength(5) + expect(result.current.pages[0]).toHaveLength(5) + + // Grow the page size at runtime (no deps change) — the window and the + // page slicing must both pick it up. + act(() => { + rerender({ pageSize: 10 }) + }) + + await waitFor(() => expect(result.current.data).toHaveLength(10)) + expect(result.current.pages).toHaveLength(1) + expect(result.current.pages[0]).toHaveLength(10) + }) + it(`should track pageParams correctly`, async () => { const posts = createMockPosts(30) const collection = createCollection( @@ -1738,6 +1780,42 @@ describe(`useLiveInfiniteQuery`, () => { expect(result.current.hasNextPage).toBe(true) }) + it(`warns when a pre-created collection's window differs from the first page`, async () => { + const posts = createMockPosts(50) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `mismatched-window-warn-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) + .offset(0), + }) + await liveQueryCollection.preload() + // Give the collection a concrete window that differs from the hook's + // expected first page (offset 0, limit pageSize + 1). + liveQueryCollection.utils.setWindow({ offset: 0, limit: 5 }) + + const warn = vi.spyOn(console, `warn`).mockImplementation(() => {}) + try { + renderHook(() => + useLiveInfiniteQuery(liveQueryCollection, { pageSize: 10 }), + ) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining(`Pre-created collection has window`), + ) + } finally { + warn.mockRestore() + } + }) + it(`should handle live updates with pre-created collection`, async () => { const posts = createMockPosts(30) const collection = createCollection(