From 877bc234371029d1b6d3f7c555749d8120a757ba Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 15 Jul 2026 13:48:27 +0200 Subject: [PATCH 1/5] feat(db): shared live-query window controller (RFC #1623 phase 5) Extracts the forward-pagination state machine out of react-db's useLiveInfiniteQuery into a framework-agnostic createLiveQueryWindowController in @tanstack/db, composing the shared live-query observer. The controller owns loadedPageCount, the peek-ahead window (via collection.utils.setWindow), page slicing, and hasNextPage/isFetchingNextPage, and exposes a reactivity-free getSnapshot/subscribe/fetchNextPage/reset/dispose surface mirroring the observer. react-db's useLiveInfiniteQuery is now a thin binding over it with no public API change; its existing suite stays green. Vue/Svelte/Solid/Angular can build infinite queries on the same controller instead of re-porting React's logic. Co-Authored-By: Claude Opus 4.8 --- .changeset/live-query-window-controller.md | 14 + packages/db/src/index.ts | 1 + .../db/src/live-query-window-controller.ts | 302 ++++++++++++++++++ .../live-query-window-controller.test.ts | 174 ++++++++++ packages/react-db/src/useLiveInfiniteQuery.ts | 244 +++++--------- 5 files changed, 575 insertions(+), 160 deletions(-) create mode 100644 .changeset/live-query-window-controller.md create mode 100644 packages/db/src/live-query-window-controller.ts create mode 100644 packages/db/tests/live-query-window-controller.test.ts diff --git a/.changeset/live-query-window-controller.md b/.changeset/live-query-window-controller.md new file mode 100644 index 0000000000..bdf4f7a5a2 --- /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 bf4e16a817..9958d56be2 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 0000000000..41ebd3e761 --- /dev/null +++ b/packages/db/src/live-query-window-controller.ts @@ -0,0 +1,302 @@ +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 0000000000..11dea911bf --- /dev/null +++ b/packages/db/tests/live-query-window-controller.test.ts @@ -0,0 +1,174 @@ +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 99c77c7397..1f66823e25 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -1,23 +1,25 @@ -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' 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 +153,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 +163,92 @@ export function useLiveInfiniteQuery( `Ensure all dependency values are JSON-serializable.`, ) } - const prevDepsKeyRef = useRef(depsKey) - - // Reset pagination when inputs change - useEffect(() => { - 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) - } - }, [isCollection, queryFnOrCollection, depsKey]) + const collectionRef = useRef | null>(null) + const controllerRef = useRef | null>(null) + const configRef = useRef(null) + const depsRef = useRef(null) - // 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, - ) + // Recreate the underlying collection + controller when the input identity + // (pre-created collection) or the deps (query function) change. A fresh + // controller starts back at page 1, which is the desired reset behaviour. + const needsNew = + !controllerRef.current || + (isCollection && configRef.current !== queryFnOrCollection) || + (!isCollection && depsRef.current !== depsKey) - // 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) { + if (needsNew) { + 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.`, - ) - } - 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 } From fa26a67362d3b6a7e50e01b78b5804fd7a891e4b Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:50:16 +0000 Subject: [PATCH 2/5] ci: apply automated fixes --- packages/db/src/live-query-window-controller.ts | 17 +++++++++++------ .../tests/live-query-window-controller.test.ts | 5 ++++- packages/react-db/src/useLiveInfiniteQuery.ts | 11 ++++++----- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/db/src/live-query-window-controller.ts b/packages/db/src/live-query-window-controller.ts index 41ebd3e761..8c40ed5854 100644 --- a/packages/db/src/live-query-window-controller.ts +++ b/packages/db/src/live-query-window-controller.ts @@ -42,8 +42,7 @@ export interface LiveQueryWindowSnapshot< isEnabled: boolean } -export interface CreateLiveQueryWindowControllerOptions - extends CreateLiveQueryObserverOptions { +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). */ @@ -87,8 +86,7 @@ interface CachedFrom { class LiveQueryWindowControllerImpl< T extends object, TKey extends string | number, -> implements LiveQueryWindowController -{ +> implements LiveQueryWindowController { private readonly observer: LiveQueryObserver private readonly collection: Collection | null private readonly pageSize: number @@ -183,7 +181,9 @@ class LiveQueryWindowControllerImpl< subscribe(listener: () => void): () => void { this.listeners.add(listener) if (this.listeners.size === 1) { - this.observerUnsub = this.observer.subscribe(() => this.onObserverNotify()) + this.observerUnsub = this.observer.subscribe(() => + this.onObserverNotify(), + ) // Establish the current window now that the query is active. this.applyWindow() } @@ -250,7 +250,12 @@ class LiveQueryWindowControllerImpl< this.appliedLimit = limit const utils = collection.utils as - | { setWindow?: (o: { offset: number; limit: number }) => true | Promise } + | { + setWindow?: (o: { + offset: number + limit: number + }) => true | Promise + } | undefined if (typeof utils?.setWindow !== `function`) return diff --git a/packages/db/tests/live-query-window-controller.test.ts b/packages/db/tests/live-query-window-controller.test.ts index 11dea911bf..59ab46a415 100644 --- a/packages/db/tests/live-query-window-controller.test.ts +++ b/packages/db/tests/live-query-window-controller.test.ts @@ -23,7 +23,10 @@ function makeSource() { } /** Ordered live query with page 1's peek-ahead window baked in, as the React adapter builds it. */ -function makeOrderedLiveQuery(source: ReturnType, pageSize: number) { +function makeOrderedLiveQuery( + source: ReturnType, + pageSize: number, +) { return createLiveQueryCollection({ query: (q) => q diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index 1f66823e25..6136d134cc 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -19,7 +19,7 @@ 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` + return typeof collection.utils?.setWindow === `function` } export type UseLiveInfiniteQueryConfig = { @@ -219,11 +219,12 @@ export function useLiveInfiniteQuery( const controller = controllerRef.current! // Stable subscribe bound to the current controller. - const subscribeRef = useRef<((onStoreChange: () => void) => () => void) | null>( - null, - ) + const subscribeRef = useRef< + ((onStoreChange: () => void) => () => void) | null + >(null) if (!subscribeRef.current || needsNew) { - subscribeRef.current = (onStoreChange) => controller.subscribe(onStoreChange) + subscribeRef.current = (onStoreChange) => + controller.subscribe(onStoreChange) } const snapshot = useSyncExternalStore(subscribeRef.current, () => From ab9b37e43a23717be92fd7fb4265e2e18d78dd58 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 15 Jul 2026 14:47:55 +0200 Subject: [PATCH 3/5] fix(react-db): restore useLiveQuery type-only import for return type UseLiveInfiniteQueryReturn references ReturnType, but the import was dropped in the controller rewrite. vitest's typecheck missed it; the package build (strict tsc) caught it (TS2304). Re-add as a type-only import. Co-Authored-By: Claude Opus 4.8 --- packages/react-db/src/useLiveInfiniteQuery.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index 6136d134cc..5f5e714062 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -4,6 +4,8 @@ import { createLiveQueryCollection, createLiveQueryWindowController, } from '@tanstack/db' +// Type-only: used in `ReturnType` in UseLiveInfiniteQueryReturn. +import type { useLiveQuery } from './useLiveQuery' import type { Collection, Context, From 8d82827aa0739bde4b9a441b06d98dd47a40725a Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 15 Jul 2026 15:19:20 +0200 Subject: [PATCH 4/5] fix(react-db): react to runtime pageSize changes + restore window-mismatch warn Addresses review of the window-controller extraction: - pageSize/initialPageParam are now part of the controller-recreation check, so changing them at runtime re-windows and re-slices (the old hook had them in its effect/memo deps; the first controller draft baked them in at creation). - Restore the one-time console.warn when a pre-created collection's existing window differs from the first page the hook enforces (dropped in the rewrite). Co-Authored-By: Claude Opus 4.8 --- packages/react-db/src/useLiveInfiniteQuery.ts | 27 ++++++- .../tests/useLiveInfiniteQuery.test.tsx | 79 ++++++++++++++++++- 2 files changed, 101 insertions(+), 5 deletions(-) diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index 5f5e714062..00a524652e 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -170,16 +170,24 @@ export function useLiveInfiniteQuery( 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) or the deps (query function) change. A fresh - // controller starts back at page 1, which is the desired reset behaviour. + // (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) { const collection = queryFnOrCollection as Collection if (!hasSetWindow(collection)) { @@ -188,6 +196,21 @@ export function useLiveInfiniteQuery( `Please add .orderBy() to your createLiveQueryCollection query.`, ) } + // 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.`, + ) + } + } collection.startSyncImmediate() collectionRef.current = collection configRef.current = queryFnOrCollection diff --git a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx index 9aa63244e7..542350df9b 100644 --- a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx +++ b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx @@ -1,7 +1,6 @@ -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 +694,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 +1775,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( From 5d50a0c058da621f33a9d836b84ef900e37b7087 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:20:36 +0000 Subject: [PATCH 5/5] ci: apply automated fixes --- packages/react-db/tests/useLiveInfiniteQuery.test.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx index 542350df9b..e961167dee 100644 --- a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx +++ b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import { act, renderHook, waitFor } from '@testing-library/react' -import { BTreeIndex, createCollection, createLiveQueryCollection, eq } 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'