diff --git a/.changeset/curly-ravens-listen.md b/.changeset/curly-ravens-listen.md new file mode 100644 index 0000000000..f4ea4cbada --- /dev/null +++ b/.changeset/curly-ravens-listen.md @@ -0,0 +1,8 @@ +--- +'@tanstack/vue-db': minor +'@tanstack/react-db': minor +'@tanstack/svelte-db': patch +'@tanstack/db': patch +--- + +Add `useLiveInfiniteQuery` as a Vue binding over the shared live-query window controller. Align infinite-query behavior across React, Vue, and Svelte, including awaitable page fetches, safe page sizes, reactive page-depth preservation, ordered collection validation, shared input resolution, and shared-window cleanup. diff --git a/docs/framework/react/overview.md b/docs/framework/react/overview.md index 1c10d644c6..f68d013d2f 100644 --- a/docs/framework/react/overview.md +++ b/docs/framework/react/overview.md @@ -130,6 +130,8 @@ const { data, pages, fetchNextPage, hasNextPage } = useLiveInfiniteQuery( ) ``` +`fetchNextPage()` returns a promise that resolves after the page request settles. Failures are exposed through the returned `error` value and do not reject the promise. + **Note:** The dependency array is only available when using the query function variant, not when passing a pre-created collection. ### useLiveSuspenseQuery diff --git a/docs/framework/svelte/overview.md b/docs/framework/svelte/overview.md index f7497d0e7e..e0515b7d9d 100644 --- a/docs/framework/svelte/overview.md +++ b/docs/framework/svelte/overview.md @@ -46,6 +46,47 @@ The `useLiveQuery` utility creates a live query that automatically updates your **Note:** With Svelte 5, `useLiveQuery` returns reactive values through getters. Access `query.data` and `query.isLoading` directly (no `$` prefix needed). +### useLiveInfiniteQuery + +For ordered, paginated data with live updates, use `useLiveInfiniteQuery`: + +```svelte + + +{#each query.data as post (post.id)} +
{post.title}
+{/each} + +{#if query.hasNextPage} + +{/if} +``` + +`fetchNextPage()` returns a promise that resolves after the page request settles. Failures are exposed through `query.error` and do not reject the promise. + +The query must include `orderBy`. The dependency array is available only with +the query-function form. You can also pass an ordered, pre-created live query +collection directly. + ### Dependency Arrays The `useLiveQuery` utility accepts an optional dependency array as its last parameter. When any value in the array changes, the query is recreated and re-executed. diff --git a/docs/framework/vue/overview.md b/docs/framework/vue/overview.md index eb78d370f4..db8cddcbb9 100644 --- a/docs/framework/vue/overview.md +++ b/docs/framework/vue/overview.md @@ -43,6 +43,49 @@ const { data, isLoading } = useLiveQuery((q) => **Note:** All return values (`data`, `isLoading`, `status`, etc.) are computed refs, so access them with `.value` in ` + + +``` + +`fetchNextPage()` returns a promise that resolves after the page request settles. Failures are exposed through the returned `error` ref and do not reject the promise. + +The query must include `orderBy`. The dependency array is available only with +the query-function form. You can also pass an ordered, pre-created live query +collection directly. + ### Dependency Arrays The `useLiveQuery` composable accepts an optional dependency array as its last parameter. When any reactive value in the array changes, the query is recreated and re-executed. diff --git a/packages/db/src/live-query-window-controller.ts b/packages/db/src/live-query-window-controller.ts index e22ca6170e..0acd53a260 100644 --- a/packages/db/src/live-query-window-controller.ts +++ b/packages/db/src/live-query-window-controller.ts @@ -2,19 +2,117 @@ import { LiveQueryWindowControllerDisposedError, SetWindowRequiresOrderByError, } from './errors.js' -import { getLiveQueryStatusFlags } from './live-query-adapter.js' +import { + getLiveQueryStatusFlags, + isCollection, + isSingleResultCollection, +} from './live-query-adapter.js' import { createLiveQueryObserver } from './live-query-observer.js' +import { BaseQueryBuilder } from './query/builder/index.js' +import { deepEquals } from './utils.js' import type { LiveQueryObserver, LiveQuerySnapshot, } from './live-query-observer.js' import type { Collection } from './collection/index.js' import type { CollectionStatus } from './types.js' +import type { + Context, + InitialQueryBuilder, + QueryBuilder, +} from './query/builder/index.js' const DEFAULT_PAGE_SIZE = 20 +export type LiveQueryWindowInputKind = `collection` | `query` + +/** @internal The supported, enabled input forms for infinite-query adapters. */ +export type ResolvedLiveQueryWindowInput = + | { kind: `collection`; collection: Collection } + | { kind: `query`; query: QueryBuilder } + +/** + * Classify an infinite-query input without invoking its query callback. + * Frameworks use this during lifecycle comparison so unchanged React renders + * do not execute the callback again. + * + * @internal This contract is unstable while RFC #1623 is being implemented. + */ +export function getLiveQueryWindowInputKind( + input: unknown, +): LiveQueryWindowInputKind { + if (isCollection(input)) return `collection` + if (typeof input === `function`) return `query` + throw new Error( + `useLiveInfiniteQuery: First argument must be either a pre-created live query collection or a query function. ` + + `Received: ${typeof input}`, + ) +} + +/** + * Resolve a supported infinite-query input and invoke a query callback once. + * A function may resolve to a collection for framework getter compatibility. + * Nullable/disabled and config-object inputs are intentionally not supported. + * + * @internal This contract is unstable while RFC #1623 is being implemented. + */ +export function resolveLiveQueryWindowInput( + input: unknown, +): ResolvedLiveQueryWindowInput { + if (getLiveQueryWindowInputKind(input) === `collection`) { + return { + kind: `collection`, + collection: input as Collection, + } + } + + const value = ( + input as (q: InitialQueryBuilder) => QueryBuilder | unknown + )(new BaseQueryBuilder() as InitialQueryBuilder) + if (isCollection(value)) { + return { kind: `collection`, collection: value } + } + if ( + typeof value !== `object` || + value === null || + typeof (value as { limit?: unknown }).limit !== `function` || + typeof (value as { offset?: unknown }).offset !== `function` + ) { + throw new Error( + `useLiveInfiniteQuery: Query function must return a query builder. ` + + `Disabled null or undefined queries are not supported.`, + ) + } + return { kind: `query`, query: value as QueryBuilder } +} + +/** @internal This contract is unstable while RFC #1623 is being implemented. */ +export function normalizeLiveQueryWindowPageSize( + pageSize: number | undefined, +): number { + if ( + pageSize === undefined || + !Number.isSafeInteger(pageSize) || + pageSize <= 0 || + pageSize >= Number.MAX_SAFE_INTEGER + ) { + return DEFAULT_PAGE_SIZE + } + return pageSize +} + type WindowResult = true | Promise +type LiveQueryWindow = { offset: number; limit: number } + +/** @internal Shared adapter view of a collection with an ordered window. */ +export type LiveQueryWindowCollection = Collection & { + utils: { + setWindow: (options: LiveQueryWindow) => WindowResult + getWindow: () => LiveQueryWindow | undefined + } +} + type WindowTarget = object & { utils?: { setWindow?: (options: { offset: number; limit: number }) => WindowResult @@ -31,17 +129,29 @@ type PendingWindow = { class WindowCoordinator { private readonly leases = new Map() private readonly leaseVersions = new Map() - private readonly initialWindow: { offset: number; limit: number } | undefined + private baselineWindow: { offset: number; limit: number } | undefined + private retainedWindow: { offset: number; limit: number } | undefined + private shouldCaptureBaseline = true private appliedLimit: number | undefined private pending: PendingWindow | undefined private generation = 0 private leaseVersion = 0 - constructor(private readonly target: WindowTarget) { - this.initialWindow = target.utils?.getWindow?.() - } + constructor(private readonly target: WindowTarget) {} request(lease: symbol, limit: number): WindowResult { + if (this.leases.size === 0) { + const currentWindow = this.target.utils?.getWindow?.() + const retainedWindowChanged = + this.retainedWindow !== undefined && + (currentWindow?.offset !== this.retainedWindow.offset || + currentWindow.limit !== this.retainedWindow.limit) + if (this.shouldCaptureBaseline || retainedWindowChanged) { + this.baselineWindow = currentWindow + this.shouldCaptureBaseline = false + } + this.retainedWindow = undefined + } const previousLimit = this.leases.get(lease) const previousVersion = this.leaseVersions.get(lease) const version = ++this.leaseVersion @@ -54,6 +164,7 @@ class WindowCoordinator { } catch (error) { this.rollbackLease(lease, version, previousLimit, previousVersion) this.appliedLimit = undefined + if (this.leases.size === 0) this.shouldCaptureBaseline = true throw error } @@ -89,7 +200,11 @@ class WindowCoordinator { ) } - release(lease: symbol): void { + hasLeases(): boolean { + return this.leases.size > 0 + } + + release(lease: symbol, restoreWhenEmpty: boolean): void { if (!this.leases.delete(lease)) return this.leaseVersions.delete(lease) @@ -100,6 +215,11 @@ class WindowCoordinator { this.appliedLimit = undefined if (this.leases.size === 0) { + if (restoreWhenEmpty) { + this.restoreInitialWindow() + } else { + this.retainedWindow = this.target.utils?.getWindow?.() + } return } @@ -149,12 +269,30 @@ class WindowCoordinator { private restoreInitialWindow(): void { const setWindow = this.target.utils?.setWindow - if (!this.initialWindow || typeof setWindow !== `function`) return + const baselineWindow = this.baselineWindow + this.retainedWindow = undefined + this.shouldCaptureBaseline = false + if (!baselineWindow || typeof setWindow !== `function`) { + this.shouldCaptureBaseline = true + return + } + const generation = this.generation + const markRestored = () => { + if (generation === this.generation && this.leases.size === 0) { + this.shouldCaptureBaseline = true + } + } try { - const result = setWindow.call(this.target.utils, this.initialWindow) - if (result !== true) void result.catch(() => {}) + const result = setWindow.call(this.target.utils, baselineWindow) + if (result === true) { + markRestored() + } else { + void result.then(markRestored, () => { + // Keep the original baseline so a later release can retry it. + }) + } } catch { - // Release has no error channel. A future lease will retry its own window. + // Release has no error channel. Keep the baseline for a later retry. } } @@ -228,6 +366,106 @@ function getWindowCoordinator(target: WindowTarget): WindowCoordinator { return coordinator } +/** @internal Whether an infinite-query controller currently owns this window. */ +export function hasLiveQueryWindowLeases(target: object): boolean { + return windowCoordinators.get(target)?.hasLeases() ?? false +} + +/** @internal Shared validation for infinite-query adapters. */ +export function assertLiveQueryWindowManyResult( + collection: Collection, +): void { + if (isSingleResultCollection(collection)) { + throw new Error( + `useLiveInfiniteQuery: Infinite queries do not support single-result queries. Remove .findOne().`, + ) + } +} + +/** @internal Whether a collection exposes an active ordered window. */ +export function isLiveQueryWindowCollection( + collection: Collection, +): collection is LiveQueryWindowCollection { + return ( + typeof collection.utils?.setWindow === `function` && + collection.utils.getWindow?.() !== undefined + ) +} + +/** + * Validate a pre-created infinite-query collection and describe any window + * adjustment the adapter should warn about. + * + * @internal Shared validation for infinite-query adapters. + */ +export function getLiveQueryWindowCollectionWarning( + collection: Collection, + expectedLimit: number, +): string | undefined { + assertLiveQueryWindowManyResult(collection) + if (!isLiveQueryWindowCollection(collection)) { + throw new Error( + `useLiveInfiniteQuery: Pre-created live query collection must have an ORDER BY (orderBy) clause for infinite pagination to work. ` + + `Please add .orderBy() to your createLiveQueryCollection query.`, + ) + } + + const currentWindow = collection.utils.getWindow() + if ( + !currentWindow || + hasLiveQueryWindowLeases(collection) || + (currentWindow.offset === 0 && currentWindow.limit === expectedLimit) + ) { + return undefined + } + + return ( + `useLiveInfiniteQuery: Pre-created collection has window {offset: ${currentWindow.offset}, limit: ${currentWindow.limit}} ` + + `but the hook expects {offset: 0, limit: ${expectedLimit}}. Adjusting window now.` + ) +} + +/** @internal Compare adapter dependencies by identity and structure. */ +export function compareLiveQueryWindowDependencies( + previous: ReadonlyArray | null | undefined, + current: ReadonlyArray, +): { changed: boolean; structurallyEqual: boolean } { + const changed = + previous === null || + previous === undefined || + previous.length !== current.length || + previous.some((dependency, index) => dependency !== current[index]) + return { + changed, + structurallyEqual: + previous !== null && + previous !== undefined && + deepEquals(previous, current), + } +} + +/** @internal Shared page-depth preservation policy for framework adapters. */ +export function shouldPreserveLiveQueryWindowPageCount(options: { + hasPreviousController: boolean + previousInputKind: `collection` | `query` | undefined + inputKind: `collection` | `query` + sameCollection: boolean + dependenciesChanged: boolean + dependenciesStructurallyEqual: boolean + pageShapeChanged: boolean +}): boolean { + if ( + !options.hasPreviousController || + options.previousInputKind !== options.inputKind + ) { + return false + } + if (options.inputKind === `collection`) return options.sameCollection + return options.dependenciesChanged + ? options.dependenciesStructurallyEqual + : options.pageShapeChanged +} + /** * A page-windowed view of a live query at a point in time. * @@ -261,7 +499,7 @@ export interface LiveQueryWindowSnapshot< /** @internal This contract is unstable while RFC #1623 is being implemented. */ export interface CreateLiveQueryWindowControllerOptions { - /** Rows per page (default 20). Non-positive values use the default. */ + /** Rows per page (default 20). Invalid values use the default. */ pageSize?: number /** Value of the first page's `pageParam` (default 0). */ initialPageParam?: number @@ -284,6 +522,22 @@ export interface LiveQueryWindowController< dispose: () => void } +/** + * Run an adapter-facing page fetch. The controller records failures in its + * snapshot; consuming the rejection here keeps event handlers safe while the + * returned promise still settles with the request. + * + * @internal This contract is unstable while RFC #1623 is being implemented. + */ +export function fetchNextLiveQueryWindowPage( + controller: Pick< + LiveQueryWindowController, + `fetchNextPage` + >, +): Promise { + return controller.fetchNextPage().catch(() => {}) +} + interface CachedFrom { observerSnapshot: unknown committedPageCount: number @@ -318,11 +572,13 @@ class LiveQueryWindowControllerImpl< private hasPaginationError = false private paginationError: unknown private failedHasNextPage = false + private activeFetchPromise: Promise | null = null private windowGeneration = 0 private pendingWindowGeneration: number | undefined private leaseActive = false private leaseGeneration = 0 private inFlightLeaseHolders = 0 + private restoreInitialWindowOnRelease = false private readonly subscriptions = new Set() private readonly publicationQueue: Array = [] @@ -343,10 +599,7 @@ class LiveQueryWindowControllerImpl< this.coordinator = collection ? getWindowCoordinator(collection as unknown as WindowTarget) : null - this.pageSize = - options.pageSize !== undefined && options.pageSize > 0 - ? options.pageSize - : DEFAULT_PAGE_SIZE + this.pageSize = normalizeLiveQueryWindowPageSize(options.pageSize) this.initialPageParam = options.initialPageParam ?? 0 const initialPageCount = Math.floor(options.initialPageCount ?? 1) this.committedPageCount = Number.isFinite(initialPageCount) @@ -432,6 +685,7 @@ class LiveQueryWindowControllerImpl< const record: SubscriptionRecord = { listener, active: true } this.subscriptions.add(record) if (this.subscriptions.size === 1) { + this.restoreInitialWindowOnRelease = false this.blockDelivery = true let observerUnsub: (() => void) | null = null try { @@ -447,7 +701,7 @@ class LiveQueryWindowControllerImpl< } catch (error) { observerUnsub?.() this.observerUnsub = null - this.deactivateLease() + this.deactivateLease(true) record.active = false this.subscriptions.delete(record) throw error @@ -461,17 +715,52 @@ class LiveQueryWindowControllerImpl< record.active = false this.subscriptions.delete(record) if (this.subscriptions.size === 0) { + this.restoreInitialWindowOnRelease = true this.observerUnsub?.() this.observerUnsub = null - if (this.inFlightLeaseHolders === 0) this.deactivateLease() + if (this.inFlightLeaseHolders === 0) this.deactivateLease(true) } } } fetchNextPage(): Promise { - if (this.disposed || this.isFetchingNextPage) return Promise.resolve() + if (this.disposed) return Promise.resolve() + if (this.isFetchingNextPage && this.activeFetchPromise) { + return this.activeFetchPromise + } if (!this.getSnapshot().hasNextPage) return Promise.resolve() - return this.requestPageCount(this.committedPageCount + 1, true) + + let resolveFetch!: () => void + let rejectFetch!: (error: unknown) => void + const activeFetchPromise = new Promise((resolve, reject) => { + resolveFetch = resolve + rejectFetch = reject + }) + this.activeFetchPromise = activeFetchPromise + + let request: Promise + try { + request = this.requestPageCount(this.committedPageCount + 1, true) + } catch (error) { + this.activeFetchPromise = null + rejectFetch(error) + return activeFetchPromise + } + void request.then( + () => { + if (this.activeFetchPromise === activeFetchPromise) { + this.activeFetchPromise = null + } + resolveFetch() + }, + (error: unknown) => { + if (this.activeFetchPromise === activeFetchPromise) { + this.activeFetchPromise = null + } + rejectFetch(error) + }, + ) + return activeFetchPromise } reset(): Promise { @@ -518,7 +807,7 @@ class LiveQueryWindowControllerImpl< this.pendingWindowGeneration = undefined this.observerUnsub?.() this.observerUnsub = null - this.deactivateLease() + this.deactivateLease(true) this.observer.dispose() for (const record of this.subscriptions) record.active = false this.subscriptions.clear() @@ -607,7 +896,7 @@ class LiveQueryWindowControllerImpl< private releaseInFlightLease(): void { this.inFlightLeaseHolders-- if (this.inFlightLeaseHolders === 0 && this.subscriptions.size === 0) { - this.deactivateLease() + this.deactivateLease(this.restoreInitialWindowOnRelease) } } @@ -629,11 +918,12 @@ class LiveQueryWindowControllerImpl< return this.activateLease(pageCount) } - private deactivateLease(): void { + private deactivateLease(restoreWhenEmpty = false): void { if (!this.leaseActive || !this.coordinator) return this.leaseGeneration++ this.leaseActive = false - this.coordinator.release(this.lease) + this.restoreInitialWindowOnRelease = false + this.coordinator.release(this.lease, restoreWhenEmpty) } private trackAttachmentFailure( diff --git a/packages/db/tests/conformance/infinite-contract.ts b/packages/db/tests/conformance/infinite-contract.ts new file mode 100644 index 0000000000..09fd67b37e --- /dev/null +++ b/packages/db/tests/conformance/infinite-contract.ts @@ -0,0 +1,101 @@ +/** + * Cross-adapter contract for `useLiveInfiniteQuery`. + * + * Drivers keep framework scheduling and package-realm details out of the shared + * scenarios. Unlike the ordinary live-query contract, controllable handles can + * mutate inputs without settling so the suite can exercise imperative calls in + * the invalidation-to-subscription interval. + */ +import type { Collection } from '@tanstack/db' +import type { QueryBuild, SourceHandle } from './contract' + +export interface InfiniteQueryConfig { + pageSize?: number + initialPageParam?: number +} + +export interface InfiniteQueryResult { + data: Array + pages: Array> + pageParams: Array + hasNextPage: boolean + isFetchingNextPage: boolean + error: unknown + status: string + collection: Collection +} + +export interface InfiniteQueryHandle { + current: () => InfiniteQueryResult + /** Invoke a page fetch and wait until its observable request settles. */ + fetchNextPage: () => Promise + flush: () => Promise + apply: (fn: () => void) => Promise + unmount: () => void +} + +export interface InfiniteQueryControllableHandle< + P, +> extends InfiniteQueryHandle { + /** Change a query dependency without waiting for the framework to settle. */ + setParamSync: (param: P) => void +} + +export interface InfiniteQueryCollectionHandle extends InfiniteQueryHandle { + /** Replace the input collection without waiting for the framework to settle. */ + replaceCollectionSync: (collection: Collection) => void +} + +export interface InfiniteQueryConfigHandle extends InfiniteQueryHandle { + /** Replace reactive page-shape options without waiting for the framework. */ + setConfigSync: (config: InfiniteQueryConfig) => void +} + +export interface InfiniteQueryInputHandle extends InfiniteQueryHandle { + setInputKindSync: (kind: `collection` | `query`) => void +} + +export interface InfiniteQueryDriver { + name: string + gt: (a: any, b: any) => any + makeSource: ( + initialData: ReadonlyArray, + ) => SourceHandle + makeOnDemandSource: ( + data: ReadonlyArray, + asyncDelay?: number, + ) => { + collection: Collection + calls: Array<{ limit?: number }> + } + makePrecreated: (build: QueryBuild) => { + collection: Collection + } + mount: ( + build: QueryBuild, + config?: InfiniteQueryConfig, + ) => InfiniteQueryHandle + mountControllable:

( + build: (q: any, param: P) => any, + initial: P, + config?: InfiniteQueryConfig, + ) => InfiniteQueryControllableHandle

+ mountCollection: ( + collection: Collection, + config?: InfiniteQueryConfig, + ) => InfiniteQueryHandle + mountCollectionControllable: ( + collection: Collection, + config?: InfiniteQueryConfig, + ) => InfiniteQueryCollectionHandle + mountConfigControllable: ( + build: QueryBuild, + initial: InfiniteQueryConfig, + ) => InfiniteQueryConfigHandle + mountInputControllable: ( + collection: Collection, + build: QueryBuild, + config?: InfiniteQueryConfig, + ) => InfiniteQueryInputHandle + knownGaps?: ReadonlyArray +} diff --git a/packages/db/tests/conformance/infinite-on-demand.ts b/packages/db/tests/conformance/infinite-on-demand.ts new file mode 100644 index 0000000000..0b265b1260 --- /dev/null +++ b/packages/db/tests/conformance/infinite-on-demand.ts @@ -0,0 +1,62 @@ +import { createFilterFunctionFromExpression } from '../../src/collection/change-events.js' +import type { Collection, LoadSubsetOptions } from '../../src/index.js' + +interface Runtime { + BTreeIndex: unknown + createCollection: ( + options: any, + ) => Collection +} + +let sequence = 0 + +export function makeInfiniteOnDemandSource< + T extends { id: string; rank: number }, +>(runtime: Runtime, data: ReadonlyArray, asyncDelay?: number) { + const calls: Array = [] + const collection = runtime.createCollection({ + id: `infinite-conformance-on-demand-${sequence++}`, + getKey: (row: T) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: runtime.BTreeIndex, + sync: { + sync: ({ markReady, begin, write, commit }: any) => { + markReady() + return { + loadSubset: (options: LoadSubsetOptions) => { + calls.push({ ...options }) + let requested = [...data].sort((a, b) => b.rank - a.rank) + if (options.cursor) { + const filter = createFilterFunctionFromExpression( + options.cursor.whereFrom, + ) + requested = requested.filter(filter) + } + if (options.limit !== undefined) { + requested = requested.slice(0, options.limit) + } + + const load = () => { + begin() + for (const row of requested) write({ type: `insert`, value: row }) + commit() + } + if (asyncDelay === undefined) { + load() + return true + } + return new Promise((resolve) => { + setTimeout(() => { + load() + resolve() + }, asyncDelay) + }) + }, + } + }, + }, + }) + return { collection, calls } +} diff --git a/packages/db/tests/conformance/infinite-suite.ts b/packages/db/tests/conformance/infinite-suite.ts new file mode 100644 index 0000000000..829fa6bf44 --- /dev/null +++ b/packages/db/tests/conformance/infinite-suite.ts @@ -0,0 +1,1050 @@ +/** Shared behavioral suite for every `useLiveInfiniteQuery` adapter. */ +import { describe, expect, it, vi } from 'vitest' +import type { + InfiniteQueryDriver, + InfiniteQueryHandle, +} from './infinite-contract' + +interface InfiniteRow { + id: string + label: string + rank: number +} + +function rows(count: number, prefix = ``): Array { + return Array.from({ length: count }, (_, index) => ({ + id: `${prefix}${index + 1}`, + label: `${prefix || `row`}-${index + 1}`, + rank: count - index, + })) +} + +async function captureError(fn: () => InfiniteQueryHandle): Promise { + try { + const handle = fn() + await handle.flush() + handle.unmount() + return undefined + } catch (error) { + return error + } +} + +async function waitFor(check: () => boolean): Promise { + for (let attempt = 0; attempt < 20; attempt++) { + if (check()) return + await Promise.resolve() + } + throw new Error(`Condition did not become true`) +} + +async function waitForAsync(check: () => boolean): Promise { + for (let attempt = 0; attempt < 50; attempt++) { + if (check()) return + await new Promise((resolve) => setTimeout(resolve, 5)) + } + throw new Error(`Condition did not become true`) +} + +export function runInfiniteQuerySuite(rawDriver: InfiniteQueryDriver): void { + const gaps = new Set(rawDriver.knownGaps ?? []) + const registeredKeys = new Set() + let mounted: Array | null = null + + const track = (handle: H): H => { + mounted?.push(handle) + return handle + } + const driver: InfiniteQueryDriver = { + ...rawDriver, + mount: (build, config) => track(rawDriver.mount(build, config)), + mountControllable: (build, initial, config) => + track(rawDriver.mountControllable(build, initial, config)), + mountCollection: (collection, config) => + track(rawDriver.mountCollection(collection, config)), + mountCollectionControllable: (collection, config) => + track(rawDriver.mountCollectionControllable(collection, config)), + mountConfigControllable: (build, config) => + track(rawDriver.mountConfigControllable(build, config)), + mountInputControllable: (collection, build, config) => + track(rawDriver.mountInputControllable(collection, build, config)), + } + + const scenario = ( + key: string, + name: string, + fn: () => Promise | void, + ) => { + registeredKeys.add(key) + const expectFail = gaps.has(key) + const label = `[${key}] ${name}${expectFail ? ` (expected-fail)` : ``}` + const run = async () => { + const handles: Array = [] + mounted = handles + try { + await fn() + } finally { + mounted = null + for (const handle of handles) { + try { + handle.unmount() + } catch { + // Teardown is best-effort and idempotent. + } + } + } + } + if (expectFail) it.fails(label, run) + else it(label, run) + } + + describe(`infinite-query conformance :: ${driver.name}`, () => { + scenario( + `page-expansion`, + `loads the initial page and expands through the final partial page`, + async () => { + const source = driver.makeSource(rows(8)) + const handle = driver.mount( + (q) => + q + .from({ items: source.collection }) + .orderBy(({ items }: any) => items.rank, `desc`), + { pageSize: 3, initialPageParam: 4 }, + ) + await handle.flush() + + expect(handle.current().data.map((row) => row.id)).toEqual([ + `1`, + `2`, + `3`, + ]) + expect(handle.current().pages.map((page) => page.length)).toEqual([3]) + expect(handle.current().pageParams).toEqual([4]) + expect(handle.current().hasNextPage).toBe(true) + + await handle.fetchNextPage() + await handle.flush() + expect(handle.current().data.map((row) => row.id)).toEqual([ + `1`, + `2`, + `3`, + `4`, + `5`, + `6`, + ]) + expect(handle.current().pageParams).toEqual([4, 5]) + + await handle.fetchNextPage() + await handle.flush() + expect(handle.current().pages.map((page) => page.length)).toEqual([ + 3, 3, 2, + ]) + expect(handle.current().pageParams).toEqual([4, 5, 6]) + expect(handle.current().hasNextPage).toBe(false) + }, + ) + + scenario( + `boundary-noop`, + `does not add a page after the end of the result`, + async () => { + const source = driver.makeSource(rows(2)) + const handle = driver.mount( + (q) => + q + .from({ items: source.collection }) + .orderBy(({ items }: any) => items.rank, `desc`), + { pageSize: 3 }, + ) + await handle.flush() + + await handle.fetchNextPage() + await handle.flush() + expect(handle.current().pages.map((page) => page.length)).toEqual([2]) + expect(handle.current().hasNextPage).toBe(false) + }, + ) + + scenario( + `empty-result`, + `represents an empty result as one empty page`, + async () => { + const source = driver.makeSource(rows(0)) + const handle = driver.mount( + (q) => + q + .from({ items: source.collection }) + .orderBy(({ items }: any) => items.rank, `desc`), + { pageSize: 3 }, + ) + await handle.flush() + + expect(handle.current().data).toEqual([]) + expect(handle.current().pages).toEqual([[]]) + expect(handle.current().hasNextPage).toBe(false) + }, + ) + + scenario( + `exact-boundary`, + `detects the end when the result fills the final page exactly`, + async () => { + const source = driver.makeSource(rows(6)) + const handle = driver.mount( + (q) => + q + .from({ items: source.collection }) + .orderBy(({ items }: any) => items.rank, `desc`), + { pageSize: 3 }, + ) + await handle.flush() + await handle.fetchNextPage() + await handle.flush() + + expect(handle.current().pages.map((page) => page.length)).toEqual([ + 3, 3, + ]) + expect(handle.current().hasNextPage).toBe(false) + }, + ) + + scenario( + `live-window`, + `keeps all committed pages live when a row enters the window`, + async () => { + const source = driver.makeSource(rows(8)) + const handle = driver.mount( + (q) => + q + .from({ items: source.collection }) + .orderBy(({ items }: any) => items.rank, `desc`), + { pageSize: 3 }, + ) + await handle.flush() + await handle.fetchNextPage() + await handle.flush() + + await handle.apply(() => { + source.insert({ id: `new`, label: `new`, rank: 100 }) + }) + expect(handle.current().data.map((row) => row.id)).toEqual([ + `new`, + `1`, + `2`, + `3`, + `4`, + `5`, + ]) + expect(handle.current().pages.map((page) => page.length)).toEqual([ + 3, 3, + ]) + }, + ) + + scenario( + `live-deletion`, + `backfills committed pages when rows are deleted`, + async () => { + const data = rows(8) + const source = driver.makeSource(data) + const handle = driver.mount( + (q) => + q + .from({ items: source.collection }) + .orderBy(({ items }: any) => items.rank, `desc`), + { pageSize: 3 }, + ) + await handle.flush() + await handle.fetchNextPage() + await handle.flush() + + await handle.apply(() => source.remove(data[1]!)) + expect(handle.current().data.map((row) => row.id)).toEqual([ + `1`, + `3`, + `4`, + `5`, + `6`, + `7`, + ]) + expect(handle.current().pages.map((page) => page.length)).toEqual([ + 3, 3, + ]) + }, + ) + + scenario( + `partial-page-deletion`, + `removes rows from a partial page in either order direction`, + async () => { + for (const direction of [`desc`, `asc`] as const) { + const data = rows(5, direction) + const source = driver.makeSource(data) + const handle = driver.mount( + (q) => + q + .from({ items: source.collection }) + .orderBy(({ items }: any) => items.rank, direction), + { pageSize: 20 }, + ) + await handle.flush() + + const removed = direction === `desc` ? data[0]! : data[4]! + await handle.apply(() => source.remove(removed)) + expect(handle.current().data.map((row) => row.id)).not.toContain( + removed.id, + ) + expect(handle.current().pages.map((page) => page.length)).toEqual([4]) + expect(handle.current().hasNextPage).toBe(false) + handle.unmount() + } + }, + ) + + scenario( + `live-has-next-page`, + `updates hasNextPage when a row is inserted beyond the visible page`, + async () => { + const source = driver.makeSource(rows(3)) + const handle = driver.mount( + (q) => + q + .from({ items: source.collection }) + .orderBy(({ items }: any) => items.rank, `desc`), + { pageSize: 3 }, + ) + await handle.flush() + expect(handle.current().hasNextPage).toBe(false) + + await handle.apply(() => { + source.insert({ id: `last`, label: `last`, rank: 0 }) + }) + expect(handle.current().data.map((row) => row.id)).toEqual([ + `1`, + `2`, + `3`, + ]) + expect(handle.current().hasNextPage).toBe(true) + }, + ) + + scenario( + `concurrent-fetch`, + `coalesces concurrent next-page requests`, + async () => { + const source = driver.makeSource(rows(8)) + const handle = driver.mount( + (q) => + q + .from({ items: source.collection }) + .orderBy(({ items }: any) => items.rank, `desc`), + { pageSize: 3 }, + ) + await handle.flush() + + const utils = handle.current().collection.utils as { + setWindow: (window: { + offset: number + limit: number + }) => true | Promise + } + const originalSetWindow = utils.setWindow.bind(utils) + let calls = 0 + let resolveWindow: (() => void) | undefined + utils.setWindow = (window) => { + calls++ + originalSetWindow(window) + return new Promise((resolve) => { + resolveWindow = resolve + }) + } + + try { + const first = handle.fetchNextPage() + const second = handle.fetchNextPage() + let secondSettled = false + void second.then( + () => { + secondSettled = true + }, + () => { + secondSettled = true + }, + ) + await waitFor(() => resolveWindow !== undefined) + + expect(calls).toBe(1) + expect(handle.current().isFetchingNextPage).toBe(true) + expect(secondSettled).toBe(false) + resolveWindow?.() + await Promise.all([first, second]) + await handle.flush() + expect(handle.current().pages.map((page) => page.length)).toEqual([ + 3, 3, + ]) + } finally { + utils.setWindow = originalSetWindow + } + }, + ) + + scenario( + `fetch-settlement`, + `settles the driver operation with the window request`, + async () => { + const source = driver.makeSource(rows(8)) + const handle = driver.mount( + (q) => + q + .from({ items: source.collection }) + .orderBy(({ items }: any) => items.rank, `desc`), + { pageSize: 3 }, + ) + await handle.flush() + + const utils = handle.current().collection.utils as { + setWindow: (window: { + offset: number + limit: number + }) => true | Promise + } + const originalSetWindow = utils.setWindow.bind(utils) + let resolveWindow: (() => void) | undefined + utils.setWindow = (window) => { + originalSetWindow(window) + return new Promise((resolve) => { + resolveWindow = resolve + }) + } + + try { + let settled = false + const fetch = handle.fetchNextPage().then(() => { + settled = true + }) + await waitFor(() => resolveWindow !== undefined) + await Promise.resolve() + expect(settled).toBe(false) + resolveWindow?.() + await fetch + expect(settled).toBe(true) + } finally { + utils.setWindow = originalSetWindow + } + }, + ) + + scenario( + `on-demand-paging`, + `uses peek-ahead windows while paging an on-demand source`, + async () => { + const source = driver.makeOnDemandSource(rows(8)) + const handle = driver.mount( + (q) => + q + .from({ items: source.collection }) + .orderBy(({ items }: any) => items.rank, `desc`), + { pageSize: 3 }, + ) + await handle.flush() + + expect(source.calls.some((call) => call.limit === 4)).toBe(true) + expect(handle.current().data.map((row) => row.id)).toEqual([ + `1`, + `2`, + `3`, + ]) + expect(handle.current().hasNextPage).toBe(true) + + await handle.fetchNextPage() + await handle.fetchNextPage() + await handle.flush() + expect(handle.current().pages.map((page) => page.length)).toEqual([ + 3, 3, 2, + ]) + expect(handle.current().hasNextPage).toBe(false) + }, + ) + + scenario( + `on-demand-async`, + `tracks an asynchronous on-demand page load`, + async () => { + const source = driver.makeOnDemandSource(rows(8), 5) + const handle = driver.mount( + (q) => + q + .from({ items: source.collection }) + .orderBy(({ items }: any) => items.rank, `desc`), + { pageSize: 3 }, + ) + await waitForAsync(() => handle.current().data.length === 3) + + const fetch = handle.fetchNextPage() + await waitForAsync(() => handle.current().isFetchingNextPage) + await fetch + await handle.flush() + expect(handle.current().pages.map((page) => page.length)).toEqual([ + 3, 3, + ]) + expect(handle.current().isFetchingNextPage).toBe(false) + }, + ) + + scenario( + `window-failure`, + `surfaces a failed window request in state without rejecting`, + async () => { + const source = driver.makeSource(rows(8)) + const handle = driver.mount( + (q) => + q + .from({ items: source.collection }) + .orderBy(({ items }: any) => items.rank, `desc`), + { pageSize: 3 }, + ) + await handle.flush() + + const failure = new Error(`window failed`) + const utils = handle.current().collection.utils as { + setWindow: (window: { + offset: number + limit: number + }) => true | Promise + } + const originalSetWindow = utils.setWindow.bind(utils) + utils.setWindow = (window) => { + originalSetWindow(window) + return Promise.reject(failure) + } + + try { + await expect(handle.fetchNextPage()).resolves.toBeUndefined() + await handle.flush() + expect(handle.current().pages.map((page) => page.length)).toEqual([3]) + expect(handle.current().status).toBe(`error`) + expect(handle.current().error).toBe(failure) + + utils.setWindow = originalSetWindow + await handle.fetchNextPage() + await handle.flush() + expect(handle.current().pages.map((page) => page.length)).toEqual([ + 3, 3, + ]) + expect(handle.current().error).toBeUndefined() + } finally { + utils.setWindow = originalSetWindow + } + }, + ) + + scenario( + `dependency-immediate-fetch`, + `fetches from the replacement query before the framework settles`, + async () => { + const source = driver.makeSource(rows(10)) + const handle = driver.mountControllable( + (q, minimum: number) => + q + .from({ items: source.collection }) + .where(({ items }: any) => driver.gt(items.rank, minimum)) + .orderBy(({ items }: any) => items.rank, `desc`), + 0, + { pageSize: 3 }, + ) + await handle.flush() + await handle.fetchNextPage() + await handle.flush() + + handle.setParamSync(5) + await handle.fetchNextPage() + await handle.flush() + + expect(handle.current().data.map((row) => row.rank)).toEqual([ + 10, 9, 8, 7, 6, + ]) + expect(handle.current().pages.map((page) => page.length)).toEqual([ + 3, 2, + ]) + }, + ) + + scenario( + `equal-dependency-depth`, + `preserves loaded pages for a structurally equal dependency`, + async () => { + const source = driver.makeSource(rows(10)) + const handle = driver.mountControllable( + (q, filter: { minimum: number }) => + q + .from({ items: source.collection }) + .where(({ items }: any) => driver.gt(items.rank, filter.minimum)) + .orderBy(({ items }: any) => items.rank, `desc`), + { minimum: 0 }, + { pageSize: 3 }, + ) + await handle.flush() + await handle.fetchNextPage() + await handle.flush() + + handle.setParamSync({ minimum: 0 }) + await handle.flush() + expect(handle.current().pages.map((page) => page.length)).toEqual([ + 3, 3, + ]) + }, + ) + + scenario( + `circular-dependency`, + `preserves page depth for a structurally equal circular dependency`, + async () => { + const source = driver.makeSource(rows(8)) + const dependency: { self?: unknown } = {} + dependency.self = dependency + const handle = driver.mountControllable( + (q, _dependency: unknown) => + q + .from({ items: source.collection }) + .orderBy(({ items }: any) => items.rank, `desc`), + dependency, + { pageSize: 3 }, + ) + await handle.flush() + await handle.fetchNextPage() + await handle.flush() + + const replacement: { self?: unknown } = {} + replacement.self = replacement + handle.setParamSync(replacement) + await handle.flush() + + expect(handle.current().pages.map((page) => page.length)).toEqual([ + 3, 3, + ]) + }, + ) + + scenario( + `page-shape-change`, + `preserves committed page depth when reactive page options change`, + async () => { + const source = driver.makeSource(rows(20)) + const handle = driver.mountConfigControllable( + (q) => + q + .from({ items: source.collection }) + .orderBy(({ items }: any) => items.rank, `desc`), + { pageSize: 3, initialPageParam: 4 }, + ) + await handle.flush() + await handle.fetchNextPage() + await handle.fetchNextPage() + await handle.flush() + + handle.setConfigSync({ pageSize: 4, initialPageParam: 8 }) + await handle.flush() + expect(handle.current().pages.map((page) => page.length)).toEqual([ + 4, 4, 4, + ]) + expect(handle.current().pageParams).toEqual([8, 9, 10]) + }, + ) + + scenario( + `invalid-page-size`, + `normalizes invalid and unsafe page sizes to the default`, + async () => { + const source = driver.makeSource(rows(21)) + for (const pageSize of [ + 0, + -1, + 2.5, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER, + ]) { + const handle = driver.mount( + (q) => + q + .from({ items: source.collection }) + .orderBy(({ items }: any) => items.rank, `desc`), + { pageSize }, + ) + await handle.flush() + expect(handle.current().pages[0]).toHaveLength(20) + expect(handle.current().hasNextPage).toBe(true) + handle.unmount() + } + }, + ) + + scenario( + `collection-immediate-fetch`, + `fetches from a replacement collection before the framework settles`, + async () => { + const first = driver.makeSource(rows(8, `a`)) + const second = driver.makeSource(rows(8, `b`)) + const firstQuery = driver.makePrecreated((q) => + q + .from({ items: first.collection }) + .orderBy(({ items }: any) => items.rank, `desc`) + .limit(4), + ).collection + const secondQuery = driver.makePrecreated((q) => + q + .from({ items: second.collection }) + .orderBy(({ items }: any) => items.rank, `desc`) + .limit(4), + ).collection + const handle = driver.mountCollectionControllable(firstQuery, { + pageSize: 3, + }) + await handle.flush() + await handle.fetchNextPage() + await handle.flush() + + handle.replaceCollectionSync(secondQuery) + await handle.fetchNextPage() + await handle.flush() + + expect(handle.current().collection).toBe(secondQuery) + expect(handle.current().data.map((row) => row.id)).toEqual([ + `b1`, + `b2`, + `b3`, + `b4`, + `b5`, + `b6`, + ]) + expect(handle.current().pages.map((page) => page.length)).toEqual([ + 3, 3, + ]) + }, + ) + + scenario( + `input-kind-switch`, + `switches between a supplied collection and a query callback`, + async () => { + const collectionSource = driver.makeSource(rows(6, `a`)) + const querySource = driver.makeSource(rows(6, `b`)) + const collection = driver.makePrecreated((q) => + q + .from({ items: collectionSource.collection }) + .orderBy(({ items }: any) => items.rank, `desc`) + .limit(4), + ).collection + const handle = driver.mountInputControllable( + collection, + (q) => + q + .from({ items: querySource.collection }) + .orderBy(({ items }: any) => items.rank, `desc`), + { pageSize: 3 }, + ) + await handle.flush() + expect(handle.current().data.map((row) => row.id)).toEqual([ + `a1`, + `a2`, + `a3`, + ]) + + handle.setInputKindSync(`query`) + await handle.flush() + expect(handle.current().data.map((row) => row.id)).toEqual([ + `b1`, + `b2`, + `b3`, + ]) + + handle.setInputKindSync(`collection`) + await handle.flush() + expect(handle.current().data.map((row) => row.id)).toEqual([ + `a1`, + `a2`, + `a3`, + ]) + }, + ) + + scenario( + `stale-window`, + `ignores a window promise from a replaced query`, + async () => { + const source = driver.makeSource(rows(10)) + const handle = driver.mountControllable( + (q, minimum: number) => + q + .from({ items: source.collection }) + .where(({ items }: any) => driver.gt(items.rank, minimum)) + .orderBy(({ items }: any) => items.rank, `desc`), + 0, + { pageSize: 3 }, + ) + await handle.flush() + + const oldUtils = handle.current().collection.utils as { + setWindow: (window: { + offset: number + limit: number + }) => true | Promise + } + const originalSetWindow = oldUtils.setWindow.bind(oldUtils) + let resolveWindow: (() => void) | undefined + oldUtils.setWindow = (window) => { + const result = originalSetWindow(window) + if (resolveWindow !== undefined) return result + return new Promise((resolve) => { + resolveWindow = resolve + }) + } + + try { + const staleFetch = handle.fetchNextPage() + await waitFor(() => resolveWindow !== undefined) + handle.setParamSync(8) + await handle.flush() + resolveWindow?.() + await staleFetch.catch(() => {}) + await handle.flush() + + expect(handle.current().data.map((row) => row.rank)).toEqual([10, 9]) + expect(handle.current().pages.map((page) => page.length)).toEqual([2]) + } finally { + oldUtils.setWindow = originalSetWindow + } + }, + ) + + scenario( + `callback-once`, + `invokes a zero-arity-compatible query callback once`, + async () => { + const source = driver.makeSource(rows(4)) + let calls = 0 + const callback = (...args: Array) => { + calls++ + return args[0] + .from({ items: source.collection }) + .orderBy(({ items }: any) => items.rank, `desc`) + } + const handle = driver.mount(callback, { pageSize: 3 }) + await handle.flush() + + expect(calls).toBe(1) + expect(handle.current().data).toHaveLength(3) + }, + ) + + scenario( + `callback-error`, + `surfaces an error thrown while constructing the query`, + async () => { + const failure = new Error(`query construction failed`) + const error = await captureError(() => + driver.mount( + ((..._args: Array) => { + throw failure + }) as any, + { pageSize: 3 }, + ), + ) + expect(error).toBe(failure) + }, + ) + + scenario( + `disabled-callback`, + `rejects a nullable query callback through the shared input policy`, + async () => { + const error = await captureError(() => + driver.mount((() => null) as any, { pageSize: 3 }), + ) + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toContain( + `Disabled null or undefined queries are not supported`, + ) + }, + ) + + scenario( + `findone-runtime`, + `rejects a single-result query at runtime`, + async () => { + const source = driver.makeSource(rows(4)) + const error = await captureError(() => + driver.mount( + ((q: any) => + q + .from({ items: source.collection }) + .orderBy(({ items }: any) => items.rank, `desc`) + .findOne()) as any, + { pageSize: 3 }, + ), + ) + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toContain(`Remove .findOne()`) + }, + ) + + scenario( + `unordered-collection`, + `rejects a pre-created collection without orderBy`, + async () => { + const source = driver.makeSource(rows(4)) + const unordered = driver.makePrecreated((q) => + q.from({ items: source.collection }), + ).collection + const error = await captureError(() => + driver.mountCollection(unordered, { pageSize: 3 }), + ) + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toMatch(/orderBy|ORDER BY/) + }, + ) + + scenario( + `unordered-query`, + `rejects a query callback without orderBy`, + async () => { + const source = driver.makeSource(rows(4)) + const error = await captureError(() => + driver.mount((q) => q.from({ items: source.collection }), { + pageSize: 3, + }), + ) + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toMatch(/orderBy|ORDER BY/) + }, + ) + + scenario( + `findone-collection`, + `rejects a pre-created single-result collection`, + async () => { + const source = driver.makeSource(rows(4)) + const single = driver.makePrecreated(((q: any) => + q + .from({ items: source.collection }) + .orderBy(({ items }: any) => items.rank, `desc`) + .findOne()) as any).collection + const error = await captureError(() => + driver.mountCollection(single, { pageSize: 3 }), + ) + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toContain(`Remove .findOne()`) + }, + ) + + scenario( + `collection-window-normalization`, + `normalizes a pre-created collection to the first peek-ahead window`, + async () => { + const source = driver.makeSource(rows(8)) + const collection = driver.makePrecreated((q) => + q + .from({ items: source.collection }) + .orderBy(({ items }: any) => items.rank, `desc`) + .offset(1) + .limit(2), + ).collection + const warn = vi.spyOn(console, `warn`).mockImplementation(() => {}) + const handle = driver.mountCollection(collection, { pageSize: 3 }) + await handle.flush() + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining(`Pre-created collection has window`), + ) + expect( + (collection.utils as { getWindow: () => unknown }).getWindow(), + ).toEqual({ offset: 0, limit: 4 }) + expect(handle.current().data.map((row) => row.id)).toEqual([ + `1`, + `2`, + `3`, + ]) + warn.mockRestore() + }, + ) + + scenario( + `collection-live-update`, + `keeps a supplied pre-created collection live`, + async () => { + const source = driver.makeSource(rows(6)) + const collection = driver.makePrecreated((q) => + q + .from({ items: source.collection }) + .orderBy(({ items }: any) => items.rank, `desc`) + .limit(4), + ).collection + const handle = driver.mountCollection(collection, { pageSize: 3 }) + await handle.flush() + + await handle.apply(() => { + source.insert({ id: `new`, label: `new`, rank: 100 }) + }) + expect(handle.current().data.map((row) => row.id)).toEqual([ + `new`, + `1`, + `2`, + ]) + }, + ) + + scenario( + `invalid-input`, + `rejects a first argument that is neither a query nor a collection`, + async () => { + const error = await captureError(() => + driver.mount(null as unknown as Parameters[0], { + pageSize: 3, + }), + ) + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toContain(`First argument`) + }, + ) + + scenario( + `shared-window-release`, + `releases shared window leases and restores the initial window`, + async () => { + const source = driver.makeSource(rows(12)) + const collection = driver.makePrecreated((q) => + q + .from({ items: source.collection }) + .orderBy(({ items }: any) => items.rank, `desc`) + .limit(4), + ).collection + const warn = vi.spyOn(console, `warn`).mockImplementation(() => {}) + try { + const larger = driver.mountCollection(collection, { pageSize: 3 }) + const smaller = driver.mountCollection(collection, { pageSize: 1 }) + await larger.flush() + await smaller.flush() + await larger.fetchNextPage() + await smaller.fetchNextPage() + await larger.flush() + + const getWindow = () => + (collection.utils as { getWindow: () => unknown }).getWindow() + expect(getWindow()).toEqual({ offset: 0, limit: 7 }) + larger.unmount() + await smaller.flush() + expect(getWindow()).toEqual({ offset: 0, limit: 3 }) + smaller.unmount() + expect(getWindow()).toEqual({ offset: 0, limit: 4 }) + expect(warn).not.toHaveBeenCalled() + } finally { + warn.mockRestore() + } + }, + ) + + it(`has no stale known-gap keys`, () => { + expect([...gaps].filter((key) => !registeredKeys.has(key))).toEqual([]) + }) + }) +} diff --git a/packages/db/tests/live-query-window-controller.test.ts b/packages/db/tests/live-query-window-controller.test.ts index c67e5d4629..00062df30f 100644 --- a/packages/db/tests/live-query-window-controller.test.ts +++ b/packages/db/tests/live-query-window-controller.test.ts @@ -4,7 +4,10 @@ import { createCollection } from '../src/collection/index.js' import { createLiveQueryCollection } from '../src/query/live-query-collection.js' import { LIVE_QUERY_INTERNAL } from '../src/query/live/internal.js' import { LiveQueryWindowControllerDisposedError } from '../src/errors.js' -import { createLiveQueryWindowController } from '../src/live-query-window-controller.js' +import { + createLiveQueryWindowController, + normalizeLiveQueryWindowPageSize, +} from '../src/live-query-window-controller.js' import { mockSyncCollectionOptions } from './utils.js' interface Row { @@ -48,6 +51,25 @@ const flush = () => new Promise((r) => setTimeout(r, 0)) const ids = (snap: { data: ReadonlyArray }) => snap.data.map((r) => r.id) describe(`createLiveQueryWindowController`, () => { + it.each([ + { pageSize: undefined, normalized: 20 }, + { pageSize: 0, normalized: 20 }, + { pageSize: -1, normalized: 20 }, + { pageSize: 1.5, normalized: 20 }, + { pageSize: Number.POSITIVE_INFINITY, normalized: 20 }, + { pageSize: Number.MAX_SAFE_INTEGER, normalized: 20 }, + { pageSize: 1, normalized: 1 }, + { + pageSize: Number.MAX_SAFE_INTEGER - 1, + normalized: Number.MAX_SAFE_INTEGER - 1, + }, + ])( + `normalizes pageSize $pageSize to $normalized`, + ({ pageSize, normalized }) => { + expect(normalizeLiveQueryWindowPageSize(pageSize)).toBe(normalized) + }, + ) + it(`exposes the first page with a peek-ahead hasNextPage`, async () => { const lq = makeOrderedLiveQuery(makeSource(), 2) const controller = createLiveQueryWindowController(lq as any, { @@ -117,6 +139,53 @@ describe(`createLiveQueryWindowController`, () => { controller.dispose() }) + it(`returns the active fetch promise to concurrent callers`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + controller.subscribe(() => {}) + await lq.preload() + + const failure = new Error(`window failed`) + const originalSetWindow = lq.utils.setWindow.bind(lq.utils) + let rejectWindow!: (error: Error) => void + vi.spyOn(lq.utils, `setWindow`).mockImplementationOnce((options) => { + originalSetWindow(options) + return new Promise((_resolve, reject) => { + rejectWindow = reject + }) + }) + + const first = controller.fetchNextPage() + const second = controller.fetchNextPage() + expect(second).toBe(first) + let secondSettled = false + const firstOutcome = first.then( + () => undefined, + (error: unknown) => error, + ) + const secondOutcome = second.then( + () => { + secondSettled = true + return undefined + }, + (error: unknown) => { + secondSettled = true + return error + }, + ) + + await Promise.resolve() + const secondWasPending = !secondSettled + rejectWindow(failure) + + expect(secondWasPending).toBe(true) + expect(await firstOutcome).toBe(failure) + expect(await secondOutcome).toBe(failure) + controller.dispose() + }) + it(`represents an empty enabled query as one empty page`, async () => { const lq = makeOrderedLiveQuery(makeSource([]), 2) const controller = createLiveQueryWindowController(lq as any, { @@ -654,12 +723,88 @@ describe(`createLiveQueryWindowController`, () => { expect(lq.utils.getWindow()).toEqual({ offset: 0, limit: 5 }) unsubscribe() + + expect(lq.utils.getWindow()).toEqual({ offset: 0, limit: 3 }) + expect(lq.toArray).toHaveLength(3) controller.dispose() await lq.cleanup() + }) + + it.each([`throws`, `rejects`] as const)( + `retains the original baseline when its first restoration %s`, + async (failureMode) => { + const lq = makeOrderedLiveQuery(makeSource(), 3) + const first = createLiveQueryWindowController(lq as any, { + pageSize: 3, + }) + const unsubscribeFirst = first.subscribe(() => {}) + await lq.preload() + await first.fetchNextPage() + expect(lq.utils.getWindow()).toEqual({ offset: 0, limit: 7 }) + + const setWindow = vi.spyOn(lq.utils, `setWindow`) + if (failureMode === `throws`) { + setWindow.mockImplementationOnce(() => { + throw new Error(`restore failed`) + }) + } else { + setWindow.mockRejectedValueOnce(new Error(`restore failed`)) + } + unsubscribeFirst() + await Promise.resolve() + expect(lq.utils.getWindow()).toEqual({ offset: 0, limit: 7 }) + + const second = createLiveQueryWindowController(lq as any, { + pageSize: 3, + }) + const unsubscribeSecond = second.subscribe(() => {}) + unsubscribeSecond() + + expect(lq.utils.getWindow()).toEqual({ offset: 0, limit: 4 }) + first.dispose() + second.dispose() + await lq.cleanup() + }, + ) + + it(`recaptures an externally changed window before a new lease cycle`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const first = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + const unsubscribeFirst = first.subscribe(() => {}) await lq.preload() + unsubscribeFirst() - expect(lq.utils.getWindow()).toEqual({ offset: 0, limit: 3 }) - expect(lq.toArray).toHaveLength(3) + await lq.utils.setWindow({ offset: 0, limit: 4 }) + const second = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + const unsubscribeSecond = second.subscribe(() => {}) + unsubscribeSecond() + + expect(lq.utils.getWindow()).toEqual({ offset: 0, limit: 4 }) + first.dispose() + second.dispose() + await lq.cleanup() + }) + + it(`recaptures an external window change after standalone preload`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 3, + }) + + await controller.preload() + expect(lq.utils.getWindow()).toEqual({ offset: 0, limit: 4 }) + + await lq.utils.setWindow({ offset: 0, limit: 6 }) + const unsubscribe = controller.subscribe(() => {}) + unsubscribe() + + expect(lq.utils.getWindow()).toEqual({ offset: 0, limit: 6 }) + controller.dispose() + await lq.cleanup() }) it(`ignores a failed attachment superseded by a new lease`, async () => { diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index c3d4c629c6..9d22153ee8 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -1,9 +1,15 @@ import { useCallback, useRef, useSyncExternalStore } from 'react' import { - CollectionImpl, + assertLiveQueryWindowManyResult, + compareLiveQueryWindowDependencies, createLiveQueryCollection, createLiveQueryWindowController, - deepEquals, + fetchNextLiveQueryWindowPage, + getLiveQueryWindowCollectionWarning, + getLiveQueryWindowInputKind, + normalizeLiveQueryWindowPageSize, + resolveLiveQueryWindowInput, + shouldPreserveLiveQueryWindowPageCount, } from '@tanstack/db' // Type-only: used in `ReturnType` in UseLiveInfiniteQueryReturn. import type { useLiveQuery } from './useLiveQuery' @@ -20,22 +26,6 @@ import type { // Live queries created here are cleaned up immediately (0 disables GC). const DEFAULT_GC_TIME_MS = 1 -type WindowedCollection = Collection & { - utils: { - setWindow: (options: { - offset: number - limit: number - }) => true | Promise - } -} - -/** Type guard: does this collection expose `setWindow` (i.e. has an orderBy)? */ -function hasSetWindow( - collection: Collection, -): collection is WindowedCollection { - return typeof collection.utils?.setWindow === `function` -} - export type UseLiveInfiniteQueryConfig = { pageSize?: number initialPageParam?: number @@ -59,7 +49,7 @@ export type UseLiveInfiniteQueryReturn = Omit< data: InferResultType pages: Array[number]>> pageParams: Array - fetchNextPage: () => void + fetchNextPage: () => Promise hasNextPage: boolean isFetchingNextPage: boolean error: unknown @@ -69,6 +59,18 @@ type EnabledLiveQueryReturn = ReturnType< typeof useLiveQuery > +type InfiniteQueryRenderState = { + inputKind: `collection` | `query` + inputCollection: Collection | null + dependencies: Array | null + pageSize: number + initialPageParam: number + collection: Collection + controller: LiveQueryWindowController + warning: string | null + warned: boolean +} + /** * Create an infinite query using a query function with live updates * @@ -158,130 +160,120 @@ export function useLiveInfiniteQuery( config: UseLiveInfiniteQueryConfig, deps: Array = [], ): UseLiveInfiniteQueryReturn { - const pageSize = config.pageSize || 20 + const pageSize = normalizeLiveQueryWindowPageSize(config.pageSize) const initialPageParam = config.initialPageParam ?? 0 - // Detect if input is a collection or query function - const isCollection = queryFnOrCollection instanceof CollectionImpl + const inputIsCollection = + getLiveQueryWindowInputKind(queryFnOrCollection) === `collection` - // 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}`, - ) - } - - const collectionRef = useRef | null>(null) - const controllerRef = useRef | null>(null) - const configRef = useRef(null) - const depsRef = useRef | null>(null) - const pageSizeRef = useRef(pageSize) - const initialPageParamRef = useRef(initialPageParam) - const validatedCollectionRef = useRef(null) - const inputKind = isCollection ? `collection` : `query` - const inputKindRef = useRef(null) - const previousInputKind = inputKindRef.current + const committedRef = useRef(null) + const committed = committedRef.current + const inputKind = inputIsCollection ? `collection` : `query` - const dependenciesChanged = - !isCollection && - (depsRef.current === null || - depsRef.current.length !== deps.length || - depsRef.current.some((dep, index) => dep !== deps[index])) + const dependencyComparison = compareLiveQueryWindowDependencies( + committed?.dependencies, + deps, + ) + const dependenciesChanged = !inputIsCollection && dependencyComparison.changed const dependenciesStructurallyEqual = - !isCollection && - depsRef.current !== null && - deepEquals(depsRef.current, deps) + !inputIsCollection && dependencyComparison.structurallyEqual const needsNewCollection = - !collectionRef.current || - inputKindRef.current !== inputKind || - (isCollection && configRef.current !== queryFnOrCollection) || + committed === null || + committed.inputKind !== inputKind || + (inputIsCollection && committed.inputCollection !== queryFnOrCollection) || dependenciesChanged const pageShapeChanged = - pageSizeRef.current !== pageSize || - initialPageParamRef.current !== initialPageParam + committed === null || + committed.pageSize !== pageSize || + committed.initialPageParam !== initialPageParam const needsNewController = - !controllerRef.current || needsNewCollection || pageShapeChanged + committed === null || needsNewCollection || pageShapeChanged - if (needsNewCollection) { - inputKindRef.current = inputKind - 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.`, - ) - } - // 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.`, - ) - } + let renderState = committed + if (needsNewController) { + let collection = committed?.collection + let warning: string | null = null + + if (needsNewCollection) { + const input = resolveLiveQueryWindowInput(queryFnOrCollection) + if (input.kind === `collection`) { + collection = input.collection + } else { + // Wrap the query with the first page's peek-ahead window; the controller + // grows the limit from here via setWindow. + collection = createLiveQueryCollection({ + query: input.query.limit(pageSize + 1).offset(0), + // Construction happens during render. Synchronization starts only when + // useSyncExternalStore commits the controller subscription. + startSync: false, + gcTime: DEFAULT_GC_TIME_MS, + }) } - collectionRef.current = collection - configRef.current = queryFnOrCollection + } + + if (!collection) { + throw new Error(`useLiveInfiniteQuery: Failed to create a collection.`) + } + + if (inputIsCollection) { + warning = + getLiveQueryWindowCollectionWarning(collection, pageSize + 1) ?? null } else { - // 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), - // Construction happens during render. Synchronization starts only when - // useSyncExternalStore commits the controller subscription. - startSync: false, - gcTime: DEFAULT_GC_TIME_MS, - }) - depsRef.current = [...deps] + assertLiveQueryWindowManyResult(collection) } - } - if (needsNewController) { - const previousController = controllerRef.current - const canPreservePageCount = - previousController !== null && - (!needsNewCollection || - (previousInputKind === `query` && dependenciesStructurallyEqual)) - const initialPageCount = canPreservePageCount - ? Math.max(1, previousController.getSnapshot().pages.length) + const canPreservePageCount = shouldPreserveLiveQueryWindowPageCount({ + hasPreviousController: committed !== null, + previousInputKind: committed?.inputKind, + inputKind, + sameCollection: + inputIsCollection && committed?.inputCollection === collection, + dependenciesChanged, + dependenciesStructurallyEqual, + pageShapeChanged, + }) + const previousPageCount = committed + ? Math.max(1, committed.controller.getSnapshot().pages.length) : 1 - pageSizeRef.current = pageSize - initialPageParamRef.current = initialPageParam - controllerRef.current = createLiveQueryWindowController( - collectionRef.current, - { + const initialPageCount = canPreservePageCount ? previousPageCount : 1 + renderState = { + inputKind, + inputCollection: inputIsCollection ? collection : null, + dependencies: inputIsCollection ? null : [...deps], + pageSize, + initialPageParam, + collection, + controller: createLiveQueryWindowController(collection, { pageSize, initialPageParam, initialPageCount, - }, - ) + }), + warning, + warned: false, + } } - const controller = controllerRef.current! + const currentRenderState = renderState! + const controller = currentRenderState.controller const subscribe = useCallback( - (onStoreChange: () => void) => controller.subscribe(onStoreChange), - [controller], + (onStoreChange: () => void) => { + const unsubscribe = controller.subscribe(onStoreChange) + committedRef.current = currentRenderState + if (currentRenderState.warning && !currentRenderState.warned) { + currentRenderState.warned = true + console.warn(currentRenderState.warning) + } + return unsubscribe + }, + [controller, currentRenderState], ) const getSnapshot = useCallback(() => controller.getSnapshot(), [controller]) const snapshot = useSyncExternalStore(subscribe, getSnapshot) - const fetchNextPage = useCallback(() => { - void controller.fetchNextPage().catch(() => { - // Pagination errors are exposed through the controller snapshot. The - // hook's void callback has no promise error channel, so consume it here. - }) - }, [controller]) + const fetchNextPage = useCallback( + () => fetchNextLiveQueryWindowPage(controller), + [controller], + ) return { data: snapshot.data as InferResultType, diff --git a/packages/react-db/tests/infinite-query-conformance.test.tsx b/packages/react-db/tests/infinite-query-conformance.test.tsx new file mode 100644 index 0000000000..bf7bd198eb --- /dev/null +++ b/packages/react-db/tests/infinite-query-conformance.test.tsx @@ -0,0 +1,204 @@ +/** React driver for the shared infinite-query conformance suite. */ +import { act, renderHook } from '@testing-library/react' +import { + BTreeIndex, + createCollection, + createLiveQueryCollection, + gt, +} from '@tanstack/db' +import { mockSyncCollectionOptions } from '../../db/tests/utils' +import { runInfiniteQuerySuite } from '../../db/tests/conformance/infinite-suite' +import { makeInfiniteOnDemandSource } from '../../db/tests/conformance/infinite-on-demand' +import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' +import type { RenderHookResult } from '@testing-library/react' +import type { + InfiniteQueryConfig, + InfiniteQueryDriver, + InfiniteQueryHandle, +} from '../../db/tests/conformance/infinite-contract' +import type { + QueryBuild, + SourceHandle, +} from '../../db/tests/conformance/contract' + +let sourceSequence = 0 + +function makeSource( + initialData: ReadonlyArray, +): SourceHandle { + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `infinite-conformance-react-${sourceSequence++}`, + getKey: (row) => row.id, + initialData: [...initialData], + }), + ) + const write = (type: `insert` | `update` | `delete`, value: T) => { + collection.utils.begin() + collection.utils.write({ type, value }) + collection.utils.commit() + } + return { + collection, + insert: (row) => write(`insert`, row), + update: (row) => write(`update`, row), + remove: (row) => write(`delete`, row), + } +} + +function makePrecreated(build: QueryBuild) { + return { + collection: createLiveQueryCollection({ query: build as any }), + } +} + +function makeHandle(hook: RenderHookResult): InfiniteQueryHandle { + return { + current() { + const result = hook.result.current + return { + data: result.data, + pages: result.pages, + pageParams: result.pageParams, + hasNextPage: result.hasNextPage, + isFetchingNextPage: result.isFetchingNextPage, + error: result.error, + status: result.status, + collection: result.collection, + } + }, + fetchNextPage() { + let request!: Promise + act(() => { + request = hook.result.current.fetchNextPage() + }) + return request + }, + async flush() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + }, + async apply(fn) { + await act(async () => { + fn() + await Promise.resolve() + }) + }, + unmount() { + hook.unmount() + }, + } +} + +function mount(build: QueryBuild, config: InfiniteQueryConfig = {}) { + return makeHandle( + renderHook(() => useLiveInfiniteQuery(build as any, config as any)), + ) +} + +function mountControllable

( + build: (q: any, param: P) => any, + initial: P, + config: InfiniteQueryConfig = {}, +) { + const hook = renderHook( + ({ param }: { param: P }) => + useLiveInfiniteQuery((q: any) => build(q, param), config as any, [param]), + { initialProps: { param: initial } }, + ) + const handle = makeHandle(hook) + return { + ...handle, + setParamSync(param: P) { + act(() => hook.rerender({ param })) + }, + } +} + +function mountCollection(collection: any, config: InfiniteQueryConfig = {}) { + return makeHandle( + renderHook(() => useLiveInfiniteQuery(collection, config as any)), + ) +} + +function mountCollectionControllable( + initial: any, + config: InfiniteQueryConfig = {}, +) { + const hook = renderHook( + ({ collection }) => useLiveInfiniteQuery(collection, config as any), + { initialProps: { collection: initial } }, + ) + const handle = makeHandle(hook) + return { + ...handle, + replaceCollectionSync(collection: any) { + act(() => hook.rerender({ collection })) + }, + } +} + +function mountConfigControllable( + build: QueryBuild, + initial: InfiniteQueryConfig, +) { + const hook = renderHook( + ({ config }: { config: InfiniteQueryConfig }) => + useLiveInfiniteQuery(build as any, config as any), + { initialProps: { config: initial } }, + ) + const handle = makeHandle(hook) + return { + ...handle, + setConfigSync(config: InfiniteQueryConfig) { + act(() => hook.rerender({ config })) + }, + } +} + +function mountInputControllable( + collection: any, + build: QueryBuild, + config: InfiniteQueryConfig = {}, +) { + const hook = renderHook( + ({ kind }: { kind: `collection` | `query` }) => + useLiveInfiniteQuery( + kind === `collection` ? collection : build, + config as any, + [kind], + ), + { + initialProps: { + kind: `collection` as `collection` | `query`, + }, + }, + ) + const handle = makeHandle(hook) + return { + ...handle, + setInputKindSync(kind: `collection` | `query`) { + act(() => hook.rerender({ kind })) + }, + } +} + +const reactInfiniteDriver: InfiniteQueryDriver = { + name: `react`, + gt, + makeSource, + makeOnDemandSource: (data, delay) => + makeInfiniteOnDemandSource({ createCollection, BTreeIndex }, data, delay), + makePrecreated, + mount, + mountControllable, + mountCollection, + mountCollectionControllable, + mountConfigControllable, + mountInputControllable, + knownGaps: [], +} + +runInfiniteQuerySuite(reactInfiniteDriver) diff --git a/packages/react-db/tests/useLiveInfiniteQuery.test-d.tsx b/packages/react-db/tests/useLiveInfiniteQuery.test-d.tsx new file mode 100644 index 0000000000..cca7d10e08 --- /dev/null +++ b/packages/react-db/tests/useLiveInfiniteQuery.test-d.tsx @@ -0,0 +1,23 @@ +import { describe, expectTypeOf, it } from 'vitest' +import type { + UseLiveInfiniteQueryConfig, + UseLiveInfiniteQueryReturn, +} from '../src/useLiveInfiniteQuery' +import type { Context } from '@tanstack/db' + +describe(`useLiveInfiniteQuery type assertions`, () => { + it(`keeps legacy generic wrappers source-compatible`, () => { + function acceptsContext( + _config: UseLiveInfiniteQueryConfig, + _result: UseLiveInfiniteQueryReturn, + ): void {} + + void acceptsContext + }) + + it(`exposes the controller fetch promise`, () => { + expectTypeOf< + UseLiveInfiniteQueryReturn[`fetchNextPage`] + >().toEqualTypeOf<() => Promise>() + }) +}) diff --git a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx index 77e33542f0..de27b0dac9 100644 --- a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx +++ b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx @@ -2,15 +2,13 @@ import { describe, expect, it, vi } from 'vitest' import { act, render, renderHook, waitFor } from '@testing-library/react' import { Suspense } from 'react' import { - BTreeIndex, createCollection, createLiveQueryCollection, eq, + gt, } from '@tanstack/db' import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' import { mockSyncCollectionOptions } from '../../db/tests/utils' -import { createFilterFunctionFromExpression } from '../../db/src/collection/change-events' -import type { InitialQueryBuilder, LoadSubsetOptions } from '@tanstack/db' import type { ReactNode } from 'react' type Post = { @@ -35,80 +33,6 @@ function createMockPosts(count: number): Array { return posts } -type OnDemandCollectionOptions = { - id: string - allPosts: Array - autoIndex?: `off` | `eager` - asyncDelay?: number -} - -/** - * Creates an on-demand collection with a loadSubset handler that supports - * sorting, cursor-based pagination, and limit. Returns the collection and - * a reference to recorded loadSubset calls for test assertions. - */ -function createOnDemandCollection(opts: OnDemandCollectionOptions) { - const loadSubsetCalls: Array = [] - const { id, allPosts, autoIndex, asyncDelay } = opts - - const collection = createCollection({ - id, - getKey: (post: Post) => post.id, - syncMode: `on-demand`, - startSync: true, - autoIndex: autoIndex ?? `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: ({ markReady, begin, write, commit }) => { - markReady() - - return { - loadSubset: (subsetOpts: LoadSubsetOptions) => { - loadSubsetCalls.push({ ...subsetOpts }) - - let filtered = [...allPosts].sort( - (a, b) => b.createdAt - a.createdAt, - ) - - if (subsetOpts.cursor) { - const whereFromFn = createFilterFunctionFromExpression( - subsetOpts.cursor.whereFrom, - ) - filtered = filtered.filter(whereFromFn) - } - - if (subsetOpts.limit !== undefined) { - filtered = filtered.slice(0, subsetOpts.limit) - } - - function writeAll(): void { - begin() - for (const post of filtered) { - write({ type: `insert`, value: post }) - } - commit() - } - - if (asyncDelay !== undefined) { - return new Promise((resolve) => { - setTimeout(() => { - writeAll() - resolve() - }, asyncDelay) - }) - } - - writeAll() - return true - }, - } - }, - }, - }) - - return { collection, loadSubsetCalls } -} - describe(`useLiveInfiniteQuery`, () => { it(`does not activate a query-function collection for an abandoned render`, async () => { const source = createCollection( @@ -172,2162 +96,246 @@ describe(`useLiveInfiniteQuery`, () => { rendered.unmount() }) - it(`should fetch initial page of data`, async () => { - const posts = createMockPosts(50) - const collection = createCollection( + it(`preserves committed pages across an abandoned dependency update`, async () => { + const source = createCollection( mockSyncCollectionOptions({ autoIndex: `eager`, - id: `initial-page-test`, - getKey: (post: Post) => post.id, - initialData: posts, + id: `abandoned-infinite-query-update`, + getKey: (post) => post.id, + initialData: createMockPosts(20), }), ) + const never = new Promise(() => {}) + let shouldSuspend = false + let current: + | { + isReady: boolean + pages: Array> + fetchNextPage: () => Promise + } + | undefined - const { result } = renderHook(() => { - return useLiveInfiniteQuery( + function Query({ minimum }: { minimum: number }): ReactNode { + current = 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, - }, + .from({ post: source }) + .where(({ post }) => gt(post.createdAt, minimum)) + .orderBy(({ post }) => post.createdAt, `desc`), + { pageSize: 3 }, + [minimum], ) - }) + if (shouldSuspend) throw never + return null + } - await waitFor(() => { - expect(result.current.isReady).toBe(true) + function App({ minimum }: { minimum: number }): ReactNode { + return ( + + + + ) + } + + const rendered = render() + await waitFor(() => expect(current?.isReady).toBe(true)) + await act(async () => { + await current!.fetchNextPage() + await current!.fetchNextPage() }) + expect(current?.pages.map((page) => page.length)).toEqual([3, 3, 3]) - // Should have 1 page initially - expect(result.current.pages).toHaveLength(1) - expect(result.current.pages[0]).toHaveLength(10) + shouldSuspend = true + rendered.rerender() + await Promise.resolve() - // Data should be flattened - expect(result.current.data).toHaveLength(10) + shouldSuspend = false + rendered.rerender() + await waitFor(() => expect(current?.isReady).toBe(true)) + expect(current?.pages.map((page) => page.length)).toEqual([3, 3, 3]) + rendered.unmount() + }) - // Should have next page since we have 50 items total - expect(result.current.hasNextPage).toBe(true) + it(`recognizes a structurally valid collection from another realm`, () => { + const foreignCollection = { + id: `foreign-live-query`, + subscribeChanges: () => () => {}, + startSyncImmediate: () => {}, + utils: { + setWindow: () => true as const, + getWindow: () => undefined, + }, + } - // First item should be Post 1 (most recent by createdAt) - expect(result.current.pages[0]![0]).toMatchObject({ - id: `1`, - title: `Post 1`, - }) + expect(() => + renderHook(() => + useLiveInfiniteQuery(foreignCollection as any, { pageSize: 3 }), + ), + ).toThrow(/orderBy/) }) - it(`should fetch multiple pages`, async () => { - const posts = createMockPosts(50) - const collection = createCollection( + it(`resolves the fetch promise and exposes pagination failures in state`, async () => { + const source = createCollection( mockSyncCollectionOptions({ - autoIndex: `eager`, - id: `multiple-pages-test`, - getKey: (post: Post) => post.id, - initialData: posts, + id: `infinite-query-pagination-failure`, + getKey: (post) => post.id, + initialData: createMockPosts(10), }), ) - - 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, - }, - ) + const query = createLiveQueryCollection({ + query: (q) => + q.from({ post: source }).orderBy(({ post }) => post.createdAt, `desc`), }) + const { result } = renderHook(() => + useLiveInfiniteQuery(query, { pageSize: 2 }), + ) await waitFor(() => { expect(result.current.isReady).toBe(true) + expect(result.current.hasNextPage).toBe(true) }) - // Initially 1 page - expect(result.current.pages).toHaveLength(1) - expect(result.current.hasNextPage).toBe(true) + const failure = new Error(`window load failed`) + vi.spyOn(query.utils, `setWindow`).mockRejectedValueOnce(failure) - // Fetch next page + let request!: Promise act(() => { - result.current.fetchNextPage() - }) - - await waitFor(() => { - expect(result.current.pages).toHaveLength(2) + request = result.current.fetchNextPage() }) - - 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 act(async () => { + await expect(request).resolves.toBeUndefined() }) await waitFor(() => { - expect(result.current.pages).toHaveLength(3) + expect(result.current.isError).toBe(true) + expect(result.current.error).toBe(failure) + expect(result.current.isFetchingNextPage).toBe(false) }) - - expect(result.current.data).toHaveLength(30) + expect(result.current.pages).toHaveLength(1) expect(result.current.hasNextPage).toBe(true) }) - it(`should detect when no more pages available`, async () => { - const posts = createMockPosts(25) - const collection = createCollection( + it(`compares dependencies by identity instead of serialization`, async () => { + const source = createCollection( mockSyncCollectionOptions({ - autoIndex: `eager`, - id: `no-more-pages-test`, - getKey: (post: Post) => post.id, - initialData: posts, + id: `infinite-query-map-deps`, + getKey: (post) => post.id, + initialData: createMockPosts(10), }), ) - - 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, + const { result, rerender } = renderHook( + ({ filter }: { filter: Map }) => + useLiveInfiniteQuery( + (q) => + q + .from({ post: source }) + .where(({ post }) => eq(post.category, filter.get(`category`))) + .orderBy(({ post }) => post.createdAt, `desc`), + { pageSize: 3 }, + [filter], + ), + { + initialProps: { + filter: new Map([[`category`, `tech`]]), }, - ) - }) + }, + ) await waitFor(() => { expect(result.current.isReady).toBe(true) + expect(result.current.data.length).toBeGreaterThan(0) + expect( + result.current.data.every((post) => post.category === `tech`), + ).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() - }) + rerender({ filter: new Map([[`category`, `life`]]) }) await waitFor(() => { - expect(result.current.pages).toHaveLength(3) + expect(result.current.data.length).toBeGreaterThan(0) + expect( + result.current.data.every((post) => post.category === `life`), + ).toBe(true) }) - - // 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( + it(`releases a replaced controller through the external-store unsubscribe`, async () => { + const source = createCollection( mockSyncCollectionOptions({ - autoIndex: `eager`, - id: `empty-results-test`, - getKey: (post: Post) => post.id, - initialData: [], + id: `infinite-query-controller-replacement`, + getKey: (post) => post.id, + initialData: createMockPosts(20), }), ) - - 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, - }, - ) + const query = createLiveQueryCollection({ + query: (q) => + q.from({ post: source }).orderBy(({ post }) => post.createdAt, `desc`), }) + const { result, rerender, unmount } = renderHook( + ({ pageSize }) => useLiveInfiniteQuery(query, { pageSize }), + { initialProps: { pageSize: 2 } }, + ) - await waitFor(() => { - expect(result.current.isReady).toBe(true) - }) + await waitFor(() => expect(result.current.isReady).toBe(true)) + expect(query.subscriberCount).toBe(1) - // 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) + rerender({ pageSize: 3 }) + await waitFor(() => expect(result.current.pages[0]).toHaveLength(3)) + expect(query.subscriberCount).toBe(1) + + unmount() + expect(query.subscriberCount).toBe(0) }) - it(`should update pages when underlying data changes`, async () => { - const posts = createMockPosts(30) - const collection = createCollection( + it(`binds fetchNextPage to the controller that returned it`, async () => { + const sourceA = createCollection( mockSyncCollectionOptions({ - autoIndex: `eager`, - id: `live-updates-test`, - getKey: (post: Post) => post.id, - initialData: posts, + id: `infinite-query-generation-a`, + getKey: (post) => post.id, + initialData: createMockPosts(10), }), ) - - 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( + const sourceB = createCollection( mockSyncCollectionOptions({ - autoIndex: `eager`, - id: `deletions-test`, - getKey: (post: Post) => post.id, - initialData: posts, + id: `infinite-query-generation-b`, + getKey: (post) => post.id, + initialData: createMockPosts(10), }), ) - - 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() + const queryA = createLiveQueryCollection({ + query: (q) => + q.from({ post: sourceA }).orderBy(({ post }) => post.createdAt, `desc`), }) - - await waitFor(() => { - // First item should have changed - expect(result.current.data[0]!.id).not.toBe(firstItemId) + const queryB = createLiveQueryCollection({ + query: (q) => + q.from({ post: sourceB }).orderBy(({ post }) => post.createdAt, `desc`), }) - - // 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 handle deletion from partial page with descending order`, async () => { - // Create only 5 items - fewer than the pageSize of 20 - const posts = createMockPosts(5) - const collection = createCollection( - mockSyncCollectionOptions({ - autoIndex: `eager`, - id: `partial-page-deletion-desc-test`, - getKey: (post: Post) => post.id, - initialData: posts, - }), + const { result, rerender } = renderHook( + ({ query }) => useLiveInfiniteQuery(query, { pageSize: 2 }), + { initialProps: { query: queryA } }, ) - const { result } = renderHook(() => { - return useLiveInfiniteQuery( - (q) => - q - .from({ posts: collection }) - .orderBy(({ posts: p }) => p.createdAt, `desc`), - { - pageSize: 20, - getNextPageParam: (lastPage) => - lastPage.length === 20 ? lastPage.length : undefined, - }, - ) - }) + await waitFor(() => expect(result.current.isReady).toBe(true)) + const fetchFromA = result.current.fetchNextPage + rerender({ query: queryB }) await waitFor(() => { + expect(result.current.collection).toBe(queryB) expect(result.current.isReady).toBe(true) + expect(result.current.pages).toHaveLength(1) }) - // Should have all 5 items on one page (partial page) - expect(result.current.pages).toHaveLength(1) - expect(result.current.data).toHaveLength(5) - expect(result.current.hasNextPage).toBe(false) - - // Verify the first item (most recent by createdAt descending) - const firstItemId = result.current.data[0]!.id - expect(firstItemId).toBe(`1`) // Post 1 has the highest createdAt - - // Delete the first item (the one that appears first in descending order) act(() => { - collection.utils.begin() - collection.utils.write({ - type: `delete`, - value: posts[0]!, // Post 1 - }) - collection.utils.commit() + void fetchFromA() }) - - // The deleted item should disappear from the result - await waitFor(() => { - expect(result.current.data).toHaveLength(4) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) }) - - // Verify the deleted item is no longer in the data - expect( - result.current.data.find((p) => p.id === firstItemId), - ).toBeUndefined() - - // Verify the new first item is Post 2 - expect(result.current.data[0]!.id).toBe(`2`) - - // Still should have 1 page with 4 items expect(result.current.pages).toHaveLength(1) - expect(result.current.pages[0]).toHaveLength(4) - expect(result.current.hasNextPage).toBe(false) - }) - - it(`should handle deletion from partial page with ascending order`, async () => { - // Create only 5 items - fewer than the pageSize of 20 - const posts = createMockPosts(5) - const collection = createCollection( - mockSyncCollectionOptions({ - autoIndex: `eager`, - id: `partial-page-deletion-asc-test`, - getKey: (post: Post) => post.id, - initialData: posts, - }), - ) - const { result } = renderHook(() => { - return useLiveInfiniteQuery( - (q) => - q - .from({ posts: collection }) - .orderBy(({ posts: p }) => p.createdAt, `asc`), // ascending order - { - pageSize: 20, - getNextPageParam: (lastPage) => - lastPage.length === 20 ? lastPage.length : undefined, - }, - ) - }) - - await waitFor(() => { - expect(result.current.isReady).toBe(true) - }) - - // Should have all 5 items on one page (partial page) - expect(result.current.pages).toHaveLength(1) - expect(result.current.data).toHaveLength(5) - expect(result.current.hasNextPage).toBe(false) - - // In ascending order, Post 5 has the lowest createdAt and appears first - const firstItemId = result.current.data[0]!.id - expect(firstItemId).toBe(`5`) // Post 5 has the lowest createdAt - - // Delete the first item (the one that appears first in ascending order) - act(() => { - collection.utils.begin() - collection.utils.write({ - type: `delete`, - value: posts[4]!, // Post 5 (index 4 in array) - }) - collection.utils.commit() - }) - - // The deleted item should disappear from the result - await waitFor(() => { - expect(result.current.data).toHaveLength(4) - }) - - // Verify the deleted item is no longer in the data - expect( - result.current.data.find((p) => p.id === firstItemId), - ).toBeUndefined() - - // Still should have 1 page with 4 items - expect(result.current.pages).toHaveLength(1) - expect(result.current.pages[0]).toHaveLength(4) - expect(result.current.hasNextPage).toBe(false) - }) - - it(`should work with where clauses`, async () => { - const posts = createMockPosts(50) - const collection = createCollection( - mockSyncCollectionOptions({ - autoIndex: `eager`, - 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({ - autoIndex: `eager`, - 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(`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) - - act(() => result.current.fetchNextPage()) - await waitFor(() => expect(result.current.pages).toHaveLength(2)) - act(() => result.current.fetchNextPage()) - await waitFor(() => expect(result.current.pages).toHaveLength(3)) - - // Grow the page size without discarding the committed page count. act(() => { - rerender({ pageSize: 10 }) - }) - - await waitFor(() => expect(result.current.data).toHaveLength(30)) - expect(result.current.pages).toHaveLength(3) - expect(result.current.pages[0]).toHaveLength(10) - }) - - it(`compares dependencies by identity instead of serialization`, async () => { - const source = createCollection( - mockSyncCollectionOptions({ - id: `infinite-query-map-deps`, - getKey: (post) => post.id, - initialData: createMockPosts(10), - }), - ) - const { result, rerender } = renderHook( - ({ filter }: { filter: Map }) => - useLiveInfiniteQuery( - (q) => - q - .from({ post: source }) - .where(({ post }) => eq(post.category, filter.get(`category`))) - .orderBy(({ post }) => post.createdAt, `desc`), - { pageSize: 3 }, - [filter], - ), - { - initialProps: { - filter: new Map([[`category`, `tech`]]), - }, - }, - ) - - await waitFor(() => { - expect(result.current.isReady).toBe(true) - expect(result.current.data.length).toBeGreaterThan(0) - expect( - result.current.data.every((post) => post.category === `tech`), - ).toBe(true) - }) - - rerender({ filter: new Map([[`category`, `life`]]) }) - - await waitFor(() => { - expect(result.current.data.length).toBeGreaterThan(0) - expect( - result.current.data.every((post) => post.category === `life`), - ).toBe(true) - }) - }) - - it(`preserves loaded pages when dependencies are structurally unchanged`, async () => { - const source = createCollection( - mockSyncCollectionOptions({ - id: `infinite-query-structurally-equal-deps`, - getKey: (post) => post.id, - initialData: createMockPosts(20), - }), - ) - const { result, rerender } = renderHook( - ({ filter }: { filter: { category: string } }) => - useLiveInfiniteQuery( - (q) => - q - .from({ post: source }) - .where(({ post }) => eq(post.category, filter.category)) - .orderBy(({ post }) => post.createdAt, `desc`), - { pageSize: 2 }, - [filter], - ), - { initialProps: { filter: { category: `tech` } } }, - ) - - await waitFor(() => expect(result.current.isReady).toBe(true)) - act(() => result.current.fetchNextPage()) - await waitFor(() => expect(result.current.pages).toHaveLength(2)) - - rerender({ filter: { category: `tech` } }) - - await waitFor(() => expect(result.current.isReady).toBe(true)) - expect(result.current.pages).toHaveLength(2) - }) - - it(`releases a replaced controller through the external-store unsubscribe`, async () => { - const source = createCollection( - mockSyncCollectionOptions({ - id: `infinite-query-controller-replacement`, - getKey: (post) => post.id, - initialData: createMockPosts(20), - }), - ) - const query = createLiveQueryCollection({ - query: (q) => - q.from({ post: source }).orderBy(({ post }) => post.createdAt, `desc`), - }) - const { result, rerender, unmount } = renderHook( - ({ pageSize }) => useLiveInfiniteQuery(query, { pageSize }), - { initialProps: { pageSize: 2 } }, - ) - - await waitFor(() => expect(result.current.isReady).toBe(true)) - expect(query.subscriberCount).toBe(1) - - rerender({ pageSize: 3 }) - await waitFor(() => expect(result.current.pages[0]).toHaveLength(3)) - expect(query.subscriberCount).toBe(1) - - unmount() - expect(query.subscriberCount).toBe(0) - }) - - it(`binds fetchNextPage to the controller that returned it`, async () => { - const sourceA = createCollection( - mockSyncCollectionOptions({ - id: `infinite-query-generation-a`, - getKey: (post) => post.id, - initialData: createMockPosts(10), - }), - ) - const sourceB = createCollection( - mockSyncCollectionOptions({ - id: `infinite-query-generation-b`, - getKey: (post) => post.id, - initialData: createMockPosts(10), - }), - ) - const queryA = createLiveQueryCollection({ - query: (q) => - q.from({ post: sourceA }).orderBy(({ post }) => post.createdAt, `desc`), - }) - const queryB = createLiveQueryCollection({ - query: (q) => - q.from({ post: sourceB }).orderBy(({ post }) => post.createdAt, `desc`), - }) - const { result, rerender } = renderHook( - ({ query }) => useLiveInfiniteQuery(query, { pageSize: 2 }), - { initialProps: { query: queryA } }, - ) - - await waitFor(() => expect(result.current.isReady).toBe(true)) - const fetchFromA = result.current.fetchNextPage - - rerender({ query: queryB }) - await waitFor(() => { - expect(result.current.collection).toBe(queryB) - expect(result.current.isReady).toBe(true) - expect(result.current.pages).toHaveLength(1) - }) - - act(() => fetchFromA()) - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)) + void result.current.fetchNextPage() }) - expect(result.current.pages).toHaveLength(1) - - act(() => result.current.fetchNextPage()) await waitFor(() => expect(result.current.pages).toHaveLength(2)) }) - - it(`exposes pagination failures without an unhandled rejection`, async () => { - const source = createCollection( - mockSyncCollectionOptions({ - id: `infinite-query-pagination-failure`, - getKey: (post) => post.id, - initialData: createMockPosts(10), - }), - ) - const query = createLiveQueryCollection({ - query: (q) => - q.from({ post: source }).orderBy(({ post }) => post.createdAt, `desc`), - }) - const { result } = renderHook(() => - useLiveInfiniteQuery(query, { pageSize: 2 }), - ) - - await waitFor(() => { - expect(result.current.isReady).toBe(true) - expect(result.current.hasNextPage).toBe(true) - }) - - const failure = new Error(`window load failed`) - vi.spyOn(query.utils, `setWindow`).mockRejectedValueOnce(failure) - - act(() => result.current.fetchNextPage()) - - await waitFor(() => { - expect(result.current.isError).toBe(true) - expect(result.current.error).toBe(failure) - expect(result.current.isFetchingNextPage).toBe(false) - }) - expect(result.current.pages).toHaveLength(1) - expect(result.current.hasNextPage).toBe(true) - }) - - it(`should track pageParams correctly`, async () => { - const posts = createMockPosts(30) - const collection = createCollection( - mockSyncCollectionOptions({ - autoIndex: `eager`, - 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({ - autoIndex: `eager`, - id: `exact-boundary-test`, - getKey: (post: Post) => post.id, - initialData: posts, - }), - ) - - const { result } = renderHook(() => { - return useLiveInfiniteQuery( - (q) => - q - .from({ posts: collection }) - .orderBy(({ posts: p }) => p.createdAt, `desc`), - { - pageSize: 10, - // Better getNextPageParam that checks against total data available - getNextPageParam: (lastPage, allPages) => { - // If last page is not full, we're done - if (lastPage.length < 10) return undefined - // Check if we've likely loaded all data (this is a heuristic) - // In a real app with backend, you'd check response metadata - const totalLoaded = allPages.flat().length - // If we have less than a full page left, no more pages - return totalLoaded - }, - }, - ) - }) - - await waitFor(() => { - expect(result.current.isReady).toBe(true) - }) - - expect(result.current.hasNextPage).toBe(true) - - // Fetch page 2 - act(() => { - result.current.fetchNextPage() - }) - - await waitFor(() => { - expect(result.current.pages).toHaveLength(2) - }) - - expect(result.current.pages[1]).toHaveLength(10) - // With setWindow peek-ahead, we can now detect no more pages immediately - // We request 21 items (2 * 10 + 1 peek) but only get 20, so we know there's no more - expect(result.current.hasNextPage).toBe(false) - - // Verify total data - expect(result.current.data).toHaveLength(20) - }) - - it(`should not fetch when already fetching`, async () => { - const posts = createMockPosts(50) - const collection = createCollection( - mockSyncCollectionOptions({ - autoIndex: `eager`, - id: `concurrent-fetch-test`, - getKey: (post: Post) => post.id, - initialData: posts, - }), - ) - - const { result } = renderHook(() => { - return useLiveInfiniteQuery( - (q) => - q - .from({ posts: collection }) - .orderBy(({ posts: p }) => p.createdAt, `desc`), - { - pageSize: 10, - getNextPageParam: (lastPage) => - lastPage.length === 10 ? lastPage.length : undefined, - }, - ) - }) - - await waitFor(() => { - expect(result.current.isReady).toBe(true) - }) - - expect(result.current.pages).toHaveLength(1) - - // With sync data, all fetches complete immediately, so all 3 calls will succeed - // The key is that they won't cause race conditions or errors - act(() => { - result.current.fetchNextPage() - }) - - await waitFor(() => { - expect(result.current.pages).toHaveLength(2) - }) - - act(() => { - result.current.fetchNextPage() - }) - - await waitFor(() => { - expect(result.current.pages).toHaveLength(3) - }) - - act(() => { - result.current.fetchNextPage() - }) - - await waitFor(() => { - expect(result.current.pages).toHaveLength(4) - }) - - // All fetches should have succeeded - expect(result.current.pages).toHaveLength(4) - expect(result.current.data).toHaveLength(40) - }) - - it(`should not fetch when hasNextPage is false`, async () => { - const posts = createMockPosts(5) - const collection = createCollection( - mockSyncCollectionOptions({ - autoIndex: `eager`, - 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({ - autoIndex: `eager`, - id: `initial-param-test`, - getKey: (post: Post) => post.id, - initialData: posts, - }), - ) - - const { result } = renderHook(() => { - return useLiveInfiniteQuery( - (q) => - q - .from({ posts: collection }) - .orderBy(({ posts: p }) => p.createdAt, `desc`), - { - pageSize: 10, - initialPageParam: 100, - getNextPageParam: (lastPage, _allPages, lastPageParam) => - lastPage.length === 10 ? lastPageParam + 1 : undefined, - }, - ) - }) - - await waitFor(() => { - expect(result.current.isReady).toBe(true) - }) - - expect(result.current.pageParams).toEqual([100]) - - act(() => { - result.current.fetchNextPage() - }) - - await waitFor(() => { - expect(result.current.pageParams).toEqual([100, 101]) - }) - }) - - it(`should detect hasNextPage change when new items are synced`, async () => { - // Start with exactly 20 items (2 pages) - const posts = createMockPosts(20) - const collection = createCollection( - mockSyncCollectionOptions({ - autoIndex: `eager`, - id: `sync-detection-test`, - getKey: (post: Post) => post.id, - initialData: posts, - }), - ) - - const { result } = renderHook(() => { - return useLiveInfiniteQuery( - (q) => - q - .from({ posts: collection }) - .orderBy(({ posts: p }) => p.createdAt, `desc`), - { - pageSize: 10, - getNextPageParam: (lastPage) => - lastPage.length === 10 ? lastPage.length : undefined, - }, - ) - }) - - await waitFor(() => { - expect(result.current.isReady).toBe(true) - }) - - // Load both pages - act(() => { - result.current.fetchNextPage() - }) - - await waitFor(() => { - expect(result.current.pages).toHaveLength(2) - }) - - // Should have no next page (exactly 20 items, 2 full pages, peek returns nothing) - expect(result.current.hasNextPage).toBe(false) - expect(result.current.data).toHaveLength(20) - - // Add 5 more items to the collection - act(() => { - collection.utils.begin() - for (let i = 0; i < 5; i++) { - collection.utils.write({ - type: `insert`, - value: { - id: `new-${i}`, - title: `New Post ${i}`, - content: `Content ${i}`, - createdAt: Date.now() + i, - category: `tech`, - }, - }) - } - collection.utils.commit() - }) - - // Should now detect that there's a next page available - await waitFor(() => { - expect(result.current.hasNextPage).toBe(true) - }) - - // Data should still be 20 items (we haven't fetched the next page yet) - expect(result.current.data).toHaveLength(20) - expect(result.current.pages).toHaveLength(2) - - // Fetch the next page - act(() => { - result.current.fetchNextPage() - }) - - await waitFor(() => { - expect(result.current.pages).toHaveLength(3) - }) - - // Third page should have the new items - expect(result.current.pages[2]).toHaveLength(5) - expect(result.current.data).toHaveLength(25) - - // No more pages available now - expect(result.current.hasNextPage).toBe(false) - }) - - it(`should set isFetchingNextPage to false when data is immediately available`, async () => { - const posts = createMockPosts(50) - const collection = createCollection( - mockSyncCollectionOptions({ - autoIndex: `eager`, - id: `immediate-data-test`, - getKey: (post: Post) => post.id, - initialData: posts, - }), - ) - - const { result } = renderHook(() => { - return useLiveInfiniteQuery( - (q) => - q - .from({ posts: collection }) - .orderBy(({ posts: p }) => p.createdAt, `desc`), - { - pageSize: 10, - getNextPageParam: (lastPage) => - lastPage.length === 10 ? lastPage.length : undefined, - }, - ) - }) - - await waitFor(() => { - expect(result.current.isReady).toBe(true) - }) - - // Initially 1 page and not fetching - expect(result.current.pages).toHaveLength(1) - expect(result.current.isFetchingNextPage).toBe(false) - - // Fetch next page - should remain false because data is immediately available - act(() => { - result.current.fetchNextPage() - }) - - // Since data is *synchronously* available, isFetchingNextPage should be false - expect(result.current.pages).toHaveLength(2) - expect(result.current.isFetchingNextPage).toBe(false) - }) - - it(`should request limit+1 (peek-ahead) from loadSubset for hasNextPage detection`, async () => { - // Verifies that useLiveInfiniteQuery requests pageSize+1 items from loadSubset - // to detect whether there are more pages available (peek-ahead strategy) - const PAGE_SIZE = 10 - const { collection, loadSubsetCalls } = createOnDemandCollection({ - id: `peek-ahead-limit-test`, - allPosts: createMockPosts(PAGE_SIZE), // Exactly PAGE_SIZE posts - }) - - const { result } = renderHook(() => { - return useLiveInfiniteQuery( - (q) => - q - .from({ posts: collection }) - .orderBy(({ posts: p }) => p.createdAt, `desc`), - { - pageSize: PAGE_SIZE, - getNextPageParam: (lastPage) => - lastPage.length === PAGE_SIZE ? lastPage.length : undefined, - }, - ) - }) - - await waitFor(() => { - expect(result.current.isReady).toBe(true) - }) - - const callWithLimit = loadSubsetCalls.find( - (call) => call.limit !== undefined, - ) - expect(callWithLimit).toBeDefined() - expect(callWithLimit!.limit).toBe(PAGE_SIZE + 1) - - // With exactly PAGE_SIZE posts, hasNextPage should be false (no peek-ahead item returned) - expect(result.current.hasNextPage).toBe(false) - expect(result.current.data).toHaveLength(PAGE_SIZE) - }) - - it(`should detect hasNextPage via peek-ahead with exactly pageSize+1 items in on-demand collection`, async () => { - // Boundary test: with exactly pageSize+1 items, the peek-ahead item should - // signal hasNextPage=true but NOT appear in user-visible data - const PAGE_SIZE = 10 - const { collection } = createOnDemandCollection({ - id: `peek-ahead-boundary-test`, - allPosts: createMockPosts(PAGE_SIZE + 1), - }) - - const { result } = renderHook(() => { - return useLiveInfiniteQuery( - (q) => - q - .from({ posts: collection }) - .orderBy(({ posts: p }) => p.createdAt, `desc`), - { - pageSize: PAGE_SIZE, - getNextPageParam: (lastPage) => - lastPage.length === PAGE_SIZE ? lastPage.length : undefined, - }, - ) - }) - - await waitFor(() => { - expect(result.current.isReady).toBe(true) - }) - - // Peek-ahead item detected: hasNextPage should be true - expect(result.current.hasNextPage).toBe(true) - // But user-visible data should be exactly pageSize (peek-ahead excluded) - expect(result.current.data).toHaveLength(PAGE_SIZE) - expect(result.current.pages).toHaveLength(1) - expect(result.current.pages[0]).toHaveLength(PAGE_SIZE) - }) - - it(`should work with on-demand collection and fetch multiple pages`, async () => { - // End-to-end test: on-demand collection where ALL data comes from loadSubset - // (no initial data). Simulates the real Electric on-demand scenario. - const PAGE_SIZE = 10 - const { collection, loadSubsetCalls } = createOnDemandCollection({ - id: `on-demand-e2e-test`, - allPosts: createMockPosts(25), // 2 full pages + 5 items - autoIndex: `eager`, - }) - - const { result } = renderHook(() => { - return useLiveInfiniteQuery( - (q) => - q - .from({ posts: collection }) - .orderBy(({ posts: p }) => p.createdAt, `desc`), - { - pageSize: PAGE_SIZE, - getNextPageParam: (lastPage) => - lastPage.length === PAGE_SIZE ? lastPage.length : undefined, - }, - ) - }) - - await waitFor(() => { - expect(result.current.isReady).toBe(true) - }) - - // Page 1: 10 items - expect(result.current.pages).toHaveLength(1) - expect(result.current.data).toHaveLength(PAGE_SIZE) - expect(result.current.hasNextPage).toBe(true) - expect(result.current.data[0]!.id).toBe(`1`) - expect(result.current.data[9]!.id).toBe(`10`) - - // Fetch page 2 - act(() => { - result.current.fetchNextPage() - }) - - await waitFor(() => { - expect(result.current.pages).toHaveLength(2) - }) - - expect(loadSubsetCalls.length).toBeGreaterThan(1) - expect(result.current.data).toHaveLength(20) - expect(result.current.hasNextPage).toBe(true) - expect(result.current.pages[1]![0]!.id).toBe(`11`) - expect(result.current.pages[1]![9]!.id).toBe(`20`) - - // Fetch page 3 (partial page) - act(() => { - result.current.fetchNextPage() - }) - - await waitFor(() => { - expect(result.current.pages).toHaveLength(3) - }) - - expect(result.current.data).toHaveLength(25) - expect(result.current.pages[2]).toHaveLength(5) - expect(result.current.hasNextPage).toBe(false) - expect(result.current.pages[2]![0]!.id).toBe(`21`) - expect(result.current.pages[2]![4]!.id).toBe(`25`) - }) - - it(`should work with on-demand collection with async loadSubset`, async () => { - // Same as the sync on-demand test, but loadSubset returns a Promise - // to simulate async network requests (the real Electric scenario). - const PAGE_SIZE = 10 - const { collection, loadSubsetCalls } = createOnDemandCollection({ - id: `on-demand-async-test`, - allPosts: createMockPosts(25), - autoIndex: `eager`, - asyncDelay: 10, - }) - - const { result } = renderHook(() => { - return useLiveInfiniteQuery( - (q) => - q - .from({ posts: collection }) - .orderBy(({ posts: p }) => p.createdAt, `desc`), - { - pageSize: PAGE_SIZE, - getNextPageParam: (lastPage) => - lastPage.length === PAGE_SIZE ? lastPage.length : undefined, - }, - ) - }) - - await waitFor(() => { - expect(result.current.isReady).toBe(true) - }) - - await waitFor(() => { - expect(result.current.data).toHaveLength(PAGE_SIZE) - }) - - expect(result.current.pages).toHaveLength(1) - expect(result.current.hasNextPage).toBe(true) - - const initialCallCount = loadSubsetCalls.length - - // Fetch page 2 - act(() => { - result.current.fetchNextPage() - }) - - expect(result.current.isFetchingNextPage).toBe(true) - - await waitFor( - () => { - expect(result.current.data).toHaveLength(20) - }, - { timeout: 500 }, - ) - - expect(result.current.pages).toHaveLength(2) - expect(loadSubsetCalls.length).toBeGreaterThan(initialCallCount) - expect(result.current.hasNextPage).toBe(true) - - // Fetch page 3 (partial page) to verify async path handles end-of-data - const callCountBeforePage3 = loadSubsetCalls.length - - act(() => { - result.current.fetchNextPage() - }) - - await waitFor( - () => { - expect(result.current.data).toHaveLength(25) - }, - { timeout: 500 }, - ) - - expect(result.current.pages).toHaveLength(3) - expect(result.current.pages[2]).toHaveLength(5) - expect(loadSubsetCalls.length).toBeGreaterThan(callCountBeforePage3) - expect(result.current.hasNextPage).toBe(false) - }) - - it(`should track isFetchingNextPage when async loading is triggered`, async () => { - // Define all data upfront - const allPosts = createMockPosts(30) - - const collection = createCollection({ - id: `async-loading-test`, - getKey: (post: Post) => post.id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: ({ markReady, begin, write, commit }) => { - // Provide initial data by slicing the first 15 elements - begin() - const initialPosts = allPosts.slice(0, 15) - for (const post of initialPosts) { - write({ - type: `insert`, - value: post, - }) - } - commit() - markReady() - - return { - loadSubset: (opts: LoadSubsetOptions) => { - // Filter the data array based on opts - let filtered = allPosts - - // Apply where clause if provided - if (opts.where) { - const filterFn = createFilterFunctionFromExpression(opts.where) - filtered = filtered.filter(filterFn) - } - - // Sort by createdAt descending if orderBy is provided - if (opts.orderBy && opts.orderBy.length > 0) { - filtered = filtered.sort((a, b) => { - // We know ordering is always by createdAt descending - return b.createdAt - a.createdAt - }) - } - - // Apply cursor expressions if present (new cursor-based pagination) - if (opts.cursor) { - const { whereFrom, whereCurrent } = opts.cursor - try { - const whereFromFn = - createFilterFunctionFromExpression(whereFrom) - const fromData = filtered.filter(whereFromFn) - - const whereCurrentFn = - createFilterFunctionFromExpression(whereCurrent) - const currentData = filtered.filter(whereCurrentFn) - - // Combine current (ties) with from (next page), deduplicate - const seenIds = new Set() - filtered = [] - for (const item of currentData) { - if (!seenIds.has(item.id)) { - seenIds.add(item.id) - filtered.push(item) - } - } - // Apply limit only to fromData - const limitedFromData = opts.limit - ? fromData.slice(0, opts.limit) - : fromData - for (const item of limitedFromData) { - if (!seenIds.has(item.id)) { - seenIds.add(item.id) - filtered.push(item) - } - } - // Re-sort after combining - filtered.sort((a, b) => b.createdAt - a.createdAt) - } catch (e) { - throw new Error(`Test loadSubset: cursor parsing failed`, { - cause: e, - }) - } - } else if (opts.limit !== undefined) { - // Apply limit only if no cursor (cursor handles limit internally) - filtered = filtered.slice(0, opts.limit) - } - - // Subsequent calls simulate async loading with a real timeout - const loadPromise = new Promise((resolve) => { - setTimeout(() => { - begin() - - // Insert the requested posts - for (const post of filtered) { - write({ - type: `insert`, - value: post, - }) - } - - commit() - resolve() - }, 50) - }) - - return loadPromise - }, - } - }, - }, - }) - - const { result } = renderHook(() => { - return useLiveInfiniteQuery( - (q) => - q - .from({ posts: collection }) - .orderBy(({ posts: p }) => p.createdAt, `desc`), - { - pageSize: 10, - getNextPageParam: (lastPage) => - lastPage.length === 10 ? lastPage.length : undefined, - }, - ) - }) - - await waitFor(() => { - expect(result.current.isReady).toBe(true) - }) - - // Wait for initial window setup to complete - await waitFor(() => { - expect(result.current.isFetchingNextPage).toBe(false) - }) - - expect(result.current.pages).toHaveLength(1) - - // Fetch next page which will trigger async loading - act(() => { - result.current.fetchNextPage() - }) - - // Should be fetching now and so isFetchingNextPage should be true *synchronously!* - expect(result.current.isFetchingNextPage).toBe(true) - - // Wait for loading to complete - await waitFor( - () => { - expect(result.current.isFetchingNextPage).toBe(false) - }, - { timeout: 200 }, - ) - - // Should have 2 pages now - expect(result.current.pages).toHaveLength(2) - expect(result.current.data).toHaveLength(20) - }, 10000) - - describe(`pre-created collections`, () => { - it(`should accept pre-created live query collection`, async () => { - const posts = createMockPosts(50) - const collection = createCollection( - mockSyncCollectionOptions({ - autoIndex: `eager`, - 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({ - autoIndex: `eager`, - 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({ - autoIndex: `eager`, - 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({ - autoIndex: `eager`, - 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({ - autoIndex: `eager`, - 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({ - autoIndex: `eager`, - 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(`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() - - const warn = vi.spyOn(console, `warn`).mockImplementation(() => {}) - try { - renderHook(() => - useLiveInfiniteQuery(liveQueryCollection, { pageSize: 10 }), - ) - expect(warn).toHaveBeenCalledWith( - expect.stringContaining(`Pre-created collection has window`), - ) - expect(liveQueryCollection.utils.getWindow()).toEqual({ - offset: 0, - limit: 11, - }) - } finally { - warn.mockRestore() - } - }) - - it(`should handle live updates with pre-created collection`, async () => { - const posts = createMockPosts(30) - const collection = createCollection( - mockSyncCollectionOptions({ - autoIndex: `eager`, - 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({ - autoIndex: `eager`, - 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) - }) - }) - - it(`accepts circular dependency values`, async () => { - const posts = createMockPosts(10) - const collection = createCollection( - mockSyncCollectionOptions({ - autoIndex: `eager`, - id: `circular-deps-test`, - getKey: (post: Post) => post.id, - initialData: posts, - }), - ) - - const circular: Record = { a: 1 } - circular.self = circular - - const { result } = renderHook(() => - useLiveInfiniteQuery( - (q) => - q - .from({ posts: collection }) - .orderBy(({ posts: p }) => p.createdAt, `desc`), - { pageSize: 5 }, - [circular], - ), - ) - - await waitFor(() => expect(result.current.isReady).toBe(true)) - }) - - it(`recreates when switching collection to query function and back`, async () => { - const collectionSource = createCollection( - mockSyncCollectionOptions({ - id: `input-kind-collection-source`, - getKey: (post) => post.id, - initialData: createMockPosts(10), - }), - ) - const querySource = createCollection( - mockSyncCollectionOptions({ - id: `input-kind-query-source`, - getKey: (post) => post.id, - initialData: createMockPosts(10).map((post) => ({ - ...post, - id: `query-${post.id}`, - })), - }), - ) - const collectionInput = createLiveQueryCollection({ - query: (q) => - q - .from({ posts: collectionSource }) - .orderBy(({ posts }) => posts.createdAt, `desc`) - .limit(4), - }) - await collectionInput.preload() - const queryInput = (q: InitialQueryBuilder) => - q - .from({ posts: querySource }) - .orderBy(({ posts }) => posts.createdAt, `desc`) - - const { result, rerender } = renderHook( - ({ useCollection }: { useCollection: boolean }) => - useLiveInfiniteQuery( - (useCollection ? collectionInput : queryInput) as any, - { pageSize: 3 }, - ...((useCollection ? [] : [[]]) as [Array] | []), - ), - { initialProps: { useCollection: true } }, - ) - - await waitFor(() => expect(result.current.isReady).toBe(true)) - expect((result.current.data[0] as Post).id).toBe(`1`) - rerender({ useCollection: false }) - await waitFor(() => - expect((result.current.data[0] as Post).id).toBe(`query-1`), - ) - rerender({ useCollection: true }) - await waitFor(() => expect((result.current.data[0] as Post).id).toBe(`1`)) - }) - - it(`recreates when switching query function to collection and back`, async () => { - const querySource = createCollection( - mockSyncCollectionOptions({ - id: `input-kind-query-first-source`, - getKey: (post) => post.id, - initialData: createMockPosts(10), - }), - ) - const collectionSource = createCollection( - mockSyncCollectionOptions({ - id: `input-kind-collection-second-source`, - getKey: (post) => post.id, - initialData: createMockPosts(10).map((post) => ({ - ...post, - id: `collection-${post.id}`, - })), - }), - ) - const queryInput = (q: InitialQueryBuilder) => - q - .from({ posts: querySource }) - .orderBy(({ posts }) => posts.createdAt, `desc`) - const collectionInput = createLiveQueryCollection({ - query: (q) => - q - .from({ posts: collectionSource }) - .orderBy(({ posts }) => posts.createdAt, `desc`) - .limit(4), - }) - await collectionInput.preload() - - const { result, rerender } = renderHook( - ({ useCollection }: { useCollection: boolean }) => - useLiveInfiniteQuery( - (useCollection ? collectionInput : queryInput) as any, - { pageSize: 3 }, - ...((useCollection ? [] : [[]]) as [Array] | []), - ), - { initialProps: { useCollection: false } }, - ) - - await waitFor(() => expect(result.current.isReady).toBe(true)) - expect((result.current.data[0] as Post).id).toBe(`1`) - rerender({ useCollection: true }) - await waitFor(() => - expect((result.current.data[0] as Post).id).toBe(`collection-1`), - ) - rerender({ useCollection: false }) - await waitFor(() => expect((result.current.data[0] as Post).id).toBe(`1`)) - }) }) diff --git a/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts b/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts index c3aa60c94c..bf9ad2cef5 100644 --- a/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts @@ -1,9 +1,15 @@ import { + assertLiveQueryWindowManyResult, + compareLiveQueryWindowDependencies, createLiveQueryCollection, createLiveQueryWindowController, - isCollection, + fetchNextLiveQueryWindowPage, + getLiveQueryWindowCollectionWarning, + normalizeLiveQueryWindowPageSize, + resolveLiveQueryWindowInput, + shouldPreserveLiveQueryWindowPageCount, } from '@tanstack/db' -import { untrack } from 'svelte' +import { tick, untrack } from 'svelte' // Type-only: used in `ReturnType` below. import type { UseLiveQueryReturnWithCollection, @@ -19,85 +25,21 @@ import type { UtilsRecord, } from '@tanstack/db' -const DEFAULT_PAGE_SIZE = 20 const DEFAULT_GC_TIME_MS = 1 type MaybeGetter = T | (() => T) type InternalCollection = Collection -type WindowedCollection = InternalCollection & { - utils: { - setWindow: (options: { - offset: number - limit: number - }) => true | Promise - getWindow?: () => { offset: number; limit: number } | undefined - } +type PreviousController = { + getSnapshot: () => { pages: ReadonlyArray> } } -type ResolvedInput = - | { kind: `collection`; collection: InternalCollection } - | { - kind: `query` - query: (q: InitialQueryBuilder) => QueryBuilder - } - type InfiniteQueryOptions = { pageSize?: number initialPageParam?: number } -function hasSetWindow( - collection: InternalCollection, -): collection is WindowedCollection { - return typeof collection.utils.setWindow === `function` -} - -function normalizePageSize(pageSize: number | undefined): number { - if ( - pageSize === undefined || - !Number.isSafeInteger(pageSize) || - pageSize <= 0 - ) { - return DEFAULT_PAGE_SIZE - } - return pageSize -} - -function resolveInput( - input: unknown, -): ResolvedInput { - if (isCollection(input)) { - return { kind: `collection`, collection: input } - } - - if (typeof input !== `function`) { - throw new Error( - `useLiveInfiniteQuery: First argument must be either a pre-created live query collection or a query function. ` + - `Received: ${typeof input}`, - ) - } - - // A zero-argument function can be a reactive collection getter. Query - // callbacks conventionally accept the builder, so avoid probing those. - if (input.length === 0) { - try { - const collection = (input as () => unknown)() - if (isCollection(collection)) { - return { kind: `collection`, collection } - } - } catch { - // Query callbacks that close over their builder can still have arity 0. - } - } - - return { - kind: `query`, - query: input as (q: InitialQueryBuilder) => QueryBuilder, - } -} - export type LiveInfiniteQueryConfig = InfiniteQueryOptions & { /** * @deprecated Pagination uses the shared controller's peek-ahead strategy. @@ -175,56 +117,86 @@ export function useLiveInfiniteQuery( deps: Array<() => unknown> = [], ): UseLiveInfiniteQueryReturn { let validatedCollection: InternalCollection | null = null + let previousController: PreviousController | null = null + let previousInput: ReturnType< + typeof resolveLiveQueryWindowInput + > | null = null + let previousDependencies: Array | null = null + let previousPageSize: number | null = null + let previousInitialPageParam: number | null = null - const pageSize = $derived(normalizePageSize(config.pageSize)) + const pageSize = $derived(normalizeLiveQueryWindowPageSize(config.pageSize)) const initialPageParam = $derived(config.initialPageParam ?? 0) const controller = $derived.by(() => { - for (const dependency of deps) dependency() + const dependencies = deps.map((dependency) => dependency()) + + const input = resolveLiveQueryWindowInput(queryFnOrCollection) + const dependencyComparison = compareLiveQueryWindowDependencies( + previousDependencies, + dependencies, + ) + const dependenciesChanged = dependencyComparison.changed + const dependenciesStructurallyEqual = dependencyComparison.structurallyEqual + const pageShapeChanged = + previousPageSize !== pageSize || + previousInitialPageParam !== initialPageParam + const sameCollection = + input.kind === `collection` && + previousInput?.kind === `collection` && + previousInput.collection === input.collection + const canPreservePageCount = shouldPreserveLiveQueryWindowPageCount({ + hasPreviousController: previousController !== null, + previousInputKind: previousInput?.kind, + inputKind: input.kind, + sameCollection, + dependenciesChanged, + dependenciesStructurallyEqual, + pageShapeChanged, + }) + const previousPageCount = previousController + ? Math.max(1, previousController.getSnapshot().pages.length) + : 1 + const initialPageCount = canPreservePageCount ? previousPageCount : 1 + + previousInput = input + previousDependencies = [...dependencies] + previousPageSize = pageSize + previousInitialPageParam = initialPageParam - const input = resolveInput(queryFnOrCollection) if (input.kind === `collection`) { const collection = input.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.`, - ) - } + const warning = getLiveQueryWindowCollectionWarning( + collection, + pageSize + 1, + ) if (validatedCollection !== collection) { validatedCollection = collection - const currentWindow = collection.utils.getWindow?.() - const expectedLimit = pageSize + 1 - if ( - currentWindow && - (currentWindow.offset !== 0 || currentWindow.limit !== expectedLimit) - ) { - console.warn( - `useLiveInfiniteQuery: Pre-created collection has window {offset: ${currentWindow.offset}, limit: ${currentWindow.limit}} ` + - `but the hook expects {offset: 0, limit: ${expectedLimit}}. Adjusting window now.`, - ) - } + if (warning) console.warn(warning) } - return createLiveQueryWindowController(collection, { + const currentController = createLiveQueryWindowController(collection, { pageSize, initialPageParam, + initialPageCount, }) + previousController = currentController + return currentController } const collection = createLiveQueryCollection({ - query: (q: InitialQueryBuilder) => - input - .query(q) - .limit(pageSize + 1) - .offset(0), + query: input.query.limit(pageSize + 1).offset(0), startSync: false, gcTime: DEFAULT_GC_TIME_MS, }) - return createLiveQueryWindowController(collection, { + assertLiveQueryWindowManyResult(collection) + const currentController = createLiveQueryWindowController(collection, { pageSize, initialPageParam, + initialPageCount, }) + previousController = currentController + return currentController }) let snapshot = $state.raw(untrack(() => controller.getSnapshot())) @@ -243,7 +215,13 @@ export function useLiveInfiniteQuery( } }) - const fetchNextPage = () => controller.fetchNextPage() + const fetchNextPage = async () => { + // A dependency can invalidate the derived controller before Svelte runs the + // effect that subscribes it. Queue the imperative call until that handoff + // has completed so it cannot target an inactive controller. + await tick() + await fetchNextLiveQueryWindowPage(controller) + } return { get state() { diff --git a/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts b/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts new file mode 100644 index 0000000000..0e3718838e --- /dev/null +++ b/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts @@ -0,0 +1,206 @@ +/** Svelte driver for the shared infinite-query conformance suite. */ +import { + BTreeIndex, + createCollection, + createLiveQueryCollection, + gt, +} from '@tanstack/db' +import { flushSync } from 'svelte' +import { mockSyncCollectionOptions } from '../../db/tests/utils' +import { runInfiniteQuerySuite } from '../../db/tests/conformance/infinite-suite' +import { makeInfiniteOnDemandSource } from '../../db/tests/conformance/infinite-on-demand' +import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery.svelte.js' +import type { + InfiniteQueryConfig, + InfiniteQueryDriver, + InfiniteQueryHandle, +} from '../../db/tests/conformance/infinite-contract' +import type { + QueryBuild, + SourceHandle, +} from '../../db/tests/conformance/contract' + +let sourceSequence = 0 + +function makeSource( + initialData: ReadonlyArray, +): SourceHandle { + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `infinite-conformance-svelte-${sourceSequence++}`, + getKey: (row) => row.id, + initialData: [...initialData], + }), + ) + const write = (type: `insert` | `update` | `delete`, value: T) => { + collection.utils.begin() + collection.utils.write({ type, value }) + collection.utils.commit() + } + return { + collection, + insert: (row) => write(`insert`, row), + update: (row) => write(`update`, row), + remove: (row) => write(`delete`, row), + } +} + +function makePrecreated(build: QueryBuild) { + return { + collection: createLiveQueryCollection({ query: build as any }), + } +} + +async function settle(): Promise { + flushSync() + await new Promise((resolve) => setTimeout(resolve, 0)) + flushSync() +} + +function makeHandle( + getResult: () => any, + dispose: () => void, +): InfiniteQueryHandle { + return { + current() { + const result = getResult() + return { + data: result.data, + pages: result.pages, + pageParams: result.pageParams, + hasNextPage: result.hasNextPage, + isFetchingNextPage: result.isFetchingNextPage, + error: result.error, + status: result.status, + collection: result.collection, + } + }, + fetchNextPage: () => getResult().fetchNextPage(), + flush: settle, + async apply(fn) { + fn() + await settle() + }, + unmount: dispose, + } +} + +function mount(build: QueryBuild, config: InfiniteQueryConfig = {}) { + let result: any + const dispose = $effect.root(() => { + result = useLiveInfiniteQuery(build as any, config as any) + }) + flushSync() + return makeHandle(() => result, dispose) +} + +function mountControllable

( + build: (q: any, param: P) => any, + initial: P, + config: InfiniteQueryConfig = {}, +) { + let result: any + let setParam!: (next: P) => void + const dispose = $effect.root(() => { + // The contract treats dependency values as caller-owned identities. Avoid + // deep proxying, which rewrites circular object identity before it reaches + // the adapter. + let param = $state.raw(initial) + result = useLiveInfiniteQuery((q: any) => build(q, param), config as any, [ + () => param, + ]) + setParam = (next) => { + param = next + } + }) + flushSync() + const handle = makeHandle(() => result, dispose) + return { ...handle, setParamSync: setParam } +} + +function mountCollection(collection: any, config: InfiniteQueryConfig = {}) { + let result: any + const dispose = $effect.root(() => { + result = useLiveInfiniteQuery(collection, config as any) + }) + flushSync() + return makeHandle(() => result, dispose) +} + +function mountCollectionControllable( + initial: any, + config: InfiniteQueryConfig = {}, +) { + let result: any + let replaceCollection!: (next: any) => void + const dispose = $effect.root(() => { + let collection = $state(initial) + result = useLiveInfiniteQuery(() => collection, config as any) + replaceCollection = (next) => { + collection = next + } + }) + flushSync() + const handle = makeHandle(() => result, dispose) + return { ...handle, replaceCollectionSync: replaceCollection } +} + +function mountConfigControllable( + build: QueryBuild, + initial: InfiniteQueryConfig, +) { + let result: any + let setConfig!: (next: InfiniteQueryConfig) => void + const dispose = $effect.root(() => { + const config = $state({ ...initial }) + result = useLiveInfiniteQuery(build as any, config as any) + setConfig = (next) => { + Object.assign(config, next) + } + }) + flushSync() + const handle = makeHandle(() => result, dispose) + return { ...handle, setConfigSync: setConfig } +} + +function mountInputControllable( + collection: any, + build: QueryBuild, + config: InfiniteQueryConfig = {}, +) { + let result: any + let setInputKind!: (next: `collection` | `query`) => void + const dispose = $effect.root(() => { + let kind = $state<`collection` | `query`>(`collection`) + result = useLiveInfiniteQuery( + (q: any) => (kind === `collection` ? collection : build(q)), + config as any, + [() => kind], + ) + setInputKind = (next) => { + kind = next + } + }) + flushSync() + const handle = makeHandle(() => result, dispose) + return { ...handle, setInputKindSync: setInputKind } +} + +const svelteInfiniteDriver: InfiniteQueryDriver = { + name: `svelte`, + gt, + makeSource, + makeOnDemandSource: (data, delay) => + makeInfiniteOnDemandSource({ createCollection, BTreeIndex }, data, delay), + makePrecreated, + mount, + mountControllable, + mountCollection, + mountCollectionControllable, + mountConfigControllable, + mountInputControllable, + knownGaps: [], +} + +runInfiniteQuerySuite(svelteInfiniteDriver) diff --git a/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts b/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts index 46283eb720..c7af26871d 100644 --- a/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts +++ b/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts @@ -1,12 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { flushSync } from 'svelte' -import { createCollection, createLiveQueryCollection, gt } from '@tanstack/db' +import { createCollection, createLiveQueryCollection } from '@tanstack/db' import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery.svelte.js' import { mockSyncCollectionOptions } from '../../db/tests/utils' -import type { - InitialQueryBuilder, - LiveQueryCollectionUtils, -} from '@tanstack/db' type Post = { id: string @@ -33,30 +29,20 @@ function createPostsCollection(id: string, count: number) { ) } -function usePostsInfiniteQuery( - posts: ReturnType, - config: { pageSize?: number; initialPageParam?: number }, -) { - return useLiveInfiniteQuery( - (q: InitialQueryBuilder) => - q.from({ posts }).orderBy(({ posts: post }) => post.createdAt, `desc`), - config, - ) +function createPostsLiveQuery(posts: ReturnType) { + return createLiveQueryCollection({ + query: (q) => + q + .from({ posts }) + .orderBy(({ posts: post }) => post.createdAt, `desc`) + .limit(4), + }) } -function useFilteredPostsInfiniteQuery( - posts: ReturnType, - minimum: () => number, +function usePostsCollectionInfiniteQuery( + getCollection: () => ReturnType, ) { - return useLiveInfiniteQuery( - (q: InitialQueryBuilder) => - q - .from({ posts }) - .where(({ posts: post }) => gt(post.createdAt, minimum())) - .orderBy(({ posts: post }) => post.createdAt, `desc`), - { pageSize: 3 }, - [minimum], - ) + return useLiveInfiniteQuery(getCollection, { pageSize: 3 }) } describe(`useLiveInfiniteQuery`, () => { @@ -68,137 +54,6 @@ describe(`useLiveInfiniteQuery`, () => { vi.restoreAllMocks() }) - function mountPostsQuery( - posts: ReturnType, - config: { pageSize?: number; initialPageParam?: number }, - ): ReturnType { - let query: ReturnType | undefined - cleanup = $effect.root(() => { - query = usePostsInfiniteQuery(posts, config) - }) - flushSync() - if (!query) throw new Error(`Failed to mount infinite query`) - return query - } - - it(`delegates page windows and snapshots to the shared controller`, async () => { - const posts = createPostsCollection(`svelte-infinite-controller`, 12) - const query = mountPostsQuery(posts, { - pageSize: 5, - initialPageParam: 3, - }) - - expect(query.data).toHaveLength(5) - expect(query.pages.map((page) => page.length)).toEqual([5]) - expect(query.pageParams).toEqual([3]) - expect(query.hasNextPage).toBe(true) - expect( - (query.collection.utils as LiveQueryCollectionUtils).getWindow(), - ).toEqual({ offset: 0, limit: 6 }) - - await query.fetchNextPage() - flushSync() - - expect(query.data).toHaveLength(10) - expect(query.pages.map((page) => page.length)).toEqual([5, 5]) - expect(query.pageParams).toEqual([3, 4]) - expect(query.hasNextPage).toBe(true) - expect( - (query.collection.utils as LiveQueryCollectionUtils).getWindow(), - ).toEqual({ offset: 0, limit: 11 }) - - await query.fetchNextPage() - flushSync() - - expect(query.data).toHaveLength(12) - expect(query.pages.map((page) => page.length)).toEqual([5, 5, 2]) - expect(query.hasNextPage).toBe(false) - }) - - it(`keeps loaded pages live when rows enter or leave the window`, async () => { - const posts = createPostsCollection(`svelte-infinite-live-rows`, 8) - const query = mountPostsQuery(posts, { pageSize: 3 }) - - await query.fetchNextPage() - flushSync() - expect(query.data.map((post) => post.id)).toEqual([ - `1`, - `2`, - `3`, - `4`, - `5`, - `6`, - ]) - - posts.utils.begin() - posts.utils.write({ - type: `insert`, - value: { id: `new`, title: `Newest`, createdAt: 100 }, - }) - posts.utils.commit() - flushSync() - - expect(query.data.map((post) => post.id)).toEqual([ - `new`, - `1`, - `2`, - `3`, - `4`, - `5`, - ]) - expect(query.pages.map((page) => page.length)).toEqual([3, 3]) - }) - - it(`recreates the controller at the first page when a dependency changes`, async () => { - const posts = createPostsCollection(`svelte-infinite-dependency`, 10) - let query: ReturnType | undefined - let setMinimum: ((value: number) => void) | undefined - - cleanup = $effect.root(() => { - let minimum = $state(0) - query = useFilteredPostsInfiniteQuery(posts, () => minimum) - setMinimum = (value) => { - minimum = value - } - }) - flushSync() - if (!query || !setMinimum) throw new Error(`Failed to mount infinite query`) - - await query.fetchNextPage() - flushSync() - expect(query.pages).toHaveLength(2) - - setMinimum(5) - flushSync() - - expect(query.pages).toHaveLength(1) - expect(query.data.map((post) => post.createdAt)).toEqual([10, 9, 8]) - }) - - it.each([ - { label: `empty`, count: 0, pageSize: 5, pageLengths: [0], limit: 6 }, - { label: `single row`, count: 1, pageSize: 5, pageLengths: [1], limit: 6 }, - { - label: `zero page size`, - count: 1, - pageSize: 0, - pageLengths: [1], - limit: 21, - }, - ])( - `handles $label pagination boundaries`, - ({ label, count, pageSize, pageLengths, limit }) => { - const posts = createPostsCollection(`svelte-infinite-${label}`, count) - const query = mountPostsQuery(posts, { pageSize }) - - expect(query.pages.map((page) => page.length)).toEqual(pageLengths) - expect(query.hasNextPage).toBe(false) - expect( - (query.collection.utils as LiveQueryCollectionUtils).getWindow(), - ).toEqual({ offset: 0, limit }) - }, - ) - it(`accepts a reactive getter for a pre-created ordered collection`, async () => { const posts = createPostsCollection(`svelte-infinite-precreated`, 7) const livePosts = createLiveQueryCollection({ @@ -227,4 +82,39 @@ describe(`useLiveInfiniteQuery`, () => { expect(livePosts.utils.getWindow()).toEqual({ offset: 0, limit: 4 }) }) }) + + it(`resets to the first page when a collection getter changes`, async () => { + const firstPosts = createPostsCollection(`svelte-infinite-swap-first`, 8) + const secondPosts = createPostsCollection(`svelte-infinite-swap-second`, 4) + const firstQuery = createPostsLiveQuery(firstPosts) + const secondQuery = createPostsLiveQuery(secondPosts) + await Promise.all([firstQuery.preload(), secondQuery.preload()]) + + let query: ReturnType | undefined + let replaceCollection: + | ((collection: typeof secondQuery) => void) + | undefined + cleanup = $effect.root(() => { + let selectedQuery = $state(firstQuery) + query = usePostsCollectionInfiniteQuery(() => selectedQuery) + replaceCollection = (collection) => { + selectedQuery = collection + } + }) + flushSync() + if (!query || !replaceCollection) { + throw new Error(`Failed to mount infinite query`) + } + + await query.fetchNextPage() + flushSync() + expect(query.pages).toHaveLength(2) + + replaceCollection(secondQuery) + flushSync() + + expect(query.collection).toBe(secondQuery) + expect(query.pages).toHaveLength(1) + expect(query.data.map((post) => post.createdAt)).toEqual([4, 3, 2]) + }) }) diff --git a/packages/svelte-db/tests/useLiveInfiniteQuery.test-d.ts b/packages/svelte-db/tests/useLiveInfiniteQuery.test-d.ts new file mode 100644 index 0000000000..af4ac919ec --- /dev/null +++ b/packages/svelte-db/tests/useLiveInfiniteQuery.test-d.ts @@ -0,0 +1,32 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery.svelte.js' +import type { + UseLiveInfiniteQueryConfig, + UseLiveInfiniteQueryReturn, +} from '../src/useLiveInfiniteQuery.svelte.js' +import type { Context, InitialQueryBuilder } from '@tanstack/db' + +describe(`useLiveInfiniteQuery type assertions`, () => { + it(`keeps legacy generic wrappers source-compatible`, () => { + function acceptsContext( + _config: UseLiveInfiniteQueryConfig, + _result: UseLiveInfiniteQueryReturn, + ): void {} + + void acceptsContext + }) + + it(`preserves the awaitable fetch callback`, () => { + expectTypeOf< + UseLiveInfiniteQueryReturn[`fetchNextPage`] + >().toEqualTypeOf<() => Promise>() + }) + + it(`does not advertise disabled null queries`, () => { + useLiveInfiniteQuery( + // @ts-expect-error Infinite queries do not support disabled null queries. + (_q: InitialQueryBuilder) => null, + { pageSize: 5 }, + ) + }) +}) diff --git a/packages/vue-db/src/index.ts b/packages/vue-db/src/index.ts index 681078b9c2..9016993064 100644 --- a/packages/vue-db/src/index.ts +++ b/packages/vue-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/vue-db/src/useLiveInfiniteQuery.ts b/packages/vue-db/src/useLiveInfiniteQuery.ts new file mode 100644 index 0000000000..521ca38c18 --- /dev/null +++ b/packages/vue-db/src/useLiveInfiniteQuery.ts @@ -0,0 +1,267 @@ +import { computed, shallowRef, toValue, watchEffect } from 'vue' +import { + assertLiveQueryWindowManyResult, + compareLiveQueryWindowDependencies, + createLiveQueryCollection, + createLiveQueryWindowController, + fetchNextLiveQueryWindowPage, + getLiveQueryWindowCollectionWarning, + normalizeLiveQueryWindowPageSize, + resolveLiveQueryWindowInput, + shouldPreserveLiveQueryWindowPageCount, +} from '@tanstack/db' +import type { + Collection, + CollectionStatus, + Context, + GetResult, + InferResultType, + InitialQueryBuilder, + NonSingleResult, + QueryBuilder, + UtilsRecord, +} from '@tanstack/db' +import type { ComputedRef, MaybeRefOrGetter } from 'vue' + +const DEFAULT_GC_TIME_MS = 1 + +type InternalCollection = Collection + +type PreviousController = { + getSnapshot: () => { pages: ReadonlyArray> } +} + +type InfiniteQueryOptions = { + pageSize?: number + initialPageParam?: number +} + +export type LiveInfiniteQueryConfig = InfiniteQueryOptions & { + /** + * @deprecated Pagination uses the shared controller's peek-ahead strategy. + * This remains for compatibility with TanStack Query conventions. + */ + getNextPageParam?: ( + lastPage: Array, + allPages: Array>, + lastPageParam: number, + allPageParams: Array, + ) => number | undefined +} + +export type UseLiveInfiniteQueryConfig< + TContext extends Context & NonSingleResult, +> = LiveInfiniteQueryConfig[number]> + +export interface UseLiveInfiniteQueryReturn< + TContext extends Context & NonSingleResult, +> { + state: ComputedRef>> + data: ComputedRef> + collection: ComputedRef< + Collection, string | number, UtilsRecord> + > + status: ComputedRef + isLoading: ComputedRef + isReady: ComputedRef + isIdle: ComputedRef + isError: ComputedRef + isCleanedUp: ComputedRef + pages: ComputedRef[number]>>> + pageParams: ComputedRef> + fetchNextPage: () => Promise + hasNextPage: ComputedRef + isFetchingNextPage: ComputedRef + error: ComputedRef +} + +export interface UseLiveInfiniteQueryReturnWithCollection< + TResult extends object, + TKey extends string | number, + TUtils extends UtilsRecord, +> { + state: ComputedRef> + data: ComputedRef> + collection: ComputedRef> + status: ComputedRef + isLoading: ComputedRef + isReady: ComputedRef + isIdle: ComputedRef + isError: ComputedRef + isCleanedUp: ComputedRef + pages: ComputedRef>> + pageParams: ComputedRef> + fetchNextPage: () => Promise + hasNextPage: ComputedRef + isFetchingNextPage: ComputedRef + error: ComputedRef +} + +/** + * Create a Vue-native reactive view over the shared live-query window + * controller. The query must include an `orderBy` clause. + */ +export function useLiveInfiniteQuery< + TResult extends object, + TKey extends string | number, + TUtils extends UtilsRecord, +>( + liveQueryCollection: MaybeRefOrGetter< + Collection & NonSingleResult + >, + config: LiveInfiniteQueryConfig, +): UseLiveInfiniteQueryReturnWithCollection + +export function useLiveInfiniteQuery< + TContext extends Context & NonSingleResult, +>( + queryFn: (q: InitialQueryBuilder) => QueryBuilder, + config: UseLiveInfiniteQueryConfig, + deps?: Array>, +): UseLiveInfiniteQueryReturn + +export function useLiveInfiniteQuery< + TContext extends Context & NonSingleResult, +>( + queryFnOrCollection: unknown, + config: InfiniteQueryOptions, + deps: Array> = [], +): UseLiveInfiniteQueryReturn { + let validatedCollection: InternalCollection | null = null + let previousController: PreviousController | null = null + let previousInput: ReturnType< + typeof resolveLiveQueryWindowInput + > | null = null + let previousDependencies: Array | null = null + let previousPageSize: number | null = null + let previousInitialPageParam: number | null = null + + const controller = computed(() => { + const dependencies = deps.map((dependency) => toValue(dependency)) + + const pageSize = normalizeLiveQueryWindowPageSize(config.pageSize) + const initialPageParam = config.initialPageParam ?? 0 + const unwrappedInput = + typeof queryFnOrCollection === `function` + ? queryFnOrCollection + : toValue(queryFnOrCollection) + const input = resolveLiveQueryWindowInput(unwrappedInput) + const dependencyComparison = compareLiveQueryWindowDependencies( + previousDependencies, + dependencies, + ) + const dependenciesChanged = dependencyComparison.changed + const dependenciesStructurallyEqual = dependencyComparison.structurallyEqual + const pageShapeChanged = + previousPageSize !== pageSize || + previousInitialPageParam !== initialPageParam + const sameCollection = + input.kind === `collection` && + previousInput?.kind === `collection` && + previousInput.collection === input.collection + const canPreservePageCount = shouldPreserveLiveQueryWindowPageCount({ + hasPreviousController: previousController !== null, + previousInputKind: previousInput?.kind, + inputKind: input.kind, + sameCollection, + dependenciesChanged, + dependenciesStructurallyEqual, + pageShapeChanged, + }) + const previousPageCount = previousController + ? Math.max(1, previousController.getSnapshot().pages.length) + : 1 + const initialPageCount = canPreservePageCount ? previousPageCount : 1 + + previousInput = input + previousDependencies = [...dependencies] + previousPageSize = pageSize + previousInitialPageParam = initialPageParam + + if (input.kind === `collection`) { + const collection = input.collection + const warning = getLiveQueryWindowCollectionWarning( + collection, + pageSize + 1, + ) + + if (validatedCollection !== collection) { + validatedCollection = collection + if (warning) console.warn(warning) + } + + const currentController = createLiveQueryWindowController(collection, { + pageSize, + initialPageParam, + initialPageCount, + }) + previousController = currentController + return currentController + } + + const collection = createLiveQueryCollection({ + query: input.query.limit(pageSize + 1).offset(0), + startSync: false, + gcTime: DEFAULT_GC_TIME_MS, + }) + assertLiveQueryWindowManyResult(collection) + const currentController = createLiveQueryWindowController(collection, { + pageSize, + initialPageParam, + initialPageCount, + }) + previousController = currentController + return currentController + }) + + const snapshot = shallowRef(controller.value.getSnapshot()) + + watchEffect( + (onInvalidate) => { + const currentController = controller.value + const updateSnapshot = () => { + snapshot.value = currentController.getSnapshot() + } + + updateSnapshot() + const unsubscribe = currentController.subscribe(updateSnapshot) + updateSnapshot() + + onInvalidate(() => { + unsubscribe() + currentController.dispose() + }) + }, + { flush: `sync` }, + ) + + return { + state: computed( + () => snapshot.value.state as Map>, + ), + data: computed(() => snapshot.value.data as InferResultType), + collection: computed( + () => + snapshot.value.collection as Collection< + GetResult, + string | number, + UtilsRecord + >, + ), + status: computed(() => snapshot.value.status as CollectionStatus), + isLoading: computed(() => snapshot.value.isLoading), + isReady: computed(() => snapshot.value.isReady), + isIdle: computed(() => snapshot.value.isIdle), + isError: computed(() => snapshot.value.isError), + isCleanedUp: computed(() => snapshot.value.isCleanedUp), + pages: computed( + () => + snapshot.value.pages as Array[number]>>, + ), + pageParams: computed(() => snapshot.value.pageParams as Array), + fetchNextPage: () => fetchNextLiveQueryWindowPage(controller.value), + hasNextPage: computed(() => snapshot.value.hasNextPage), + isFetchingNextPage: computed(() => snapshot.value.isFetchingNextPage), + error: computed(() => snapshot.value.error), + } +} diff --git a/packages/vue-db/tests/infinite-query-conformance.test.ts b/packages/vue-db/tests/infinite-query-conformance.test.ts new file mode 100644 index 0000000000..f41bbbfd61 --- /dev/null +++ b/packages/vue-db/tests/infinite-query-conformance.test.ts @@ -0,0 +1,204 @@ +/** Vue driver for the shared infinite-query conformance suite. */ +import { + BTreeIndex, + createCollection, + createLiveQueryCollection, + gt, +} from '@tanstack/db' +import { effectScope, nextTick, reactive, ref, shallowRef } from 'vue' +import { mockSyncCollectionOptions } from '../../db/tests/utils' +import { runInfiniteQuerySuite } from '../../db/tests/conformance/infinite-suite' +import { makeInfiniteOnDemandSource } from '../../db/tests/conformance/infinite-on-demand' +import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' +import type { + InfiniteQueryConfig, + InfiniteQueryDriver, + InfiniteQueryHandle, +} from '../../db/tests/conformance/infinite-contract' +import type { + QueryBuild, + SourceHandle, +} from '../../db/tests/conformance/contract' + +let sourceSequence = 0 + +function makeSource( + initialData: ReadonlyArray, +): SourceHandle { + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `infinite-conformance-vue-${sourceSequence++}`, + getKey: (row) => row.id, + initialData: [...initialData], + }), + ) + const write = (type: `insert` | `update` | `delete`, value: T) => { + collection.utils.begin() + collection.utils.write({ type, value }) + collection.utils.commit() + } + return { + collection, + insert: (row) => write(`insert`, row), + update: (row) => write(`update`, row), + remove: (row) => write(`delete`, row), + } +} + +function makePrecreated(build: QueryBuild) { + return { + collection: createLiveQueryCollection({ query: build as any }), + } +} + +async function settle(): Promise { + await nextTick() + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +function runInScope(fn: () => R) { + const scope = effectScope() + let result!: R + scope.run(() => { + result = fn() + }) + return { result, scope } +} + +function makeHandle( + result: any, + scope: ReturnType, +): InfiniteQueryHandle { + return { + current() { + return { + data: result.data.value, + pages: result.pages.value, + pageParams: result.pageParams.value, + hasNextPage: result.hasNextPage.value, + isFetchingNextPage: result.isFetchingNextPage.value, + error: result.error.value, + status: result.status.value, + collection: result.collection.value, + } + }, + fetchNextPage: () => result.fetchNextPage(), + flush: settle, + async apply(fn) { + fn() + await settle() + }, + unmount() { + scope.stop() + }, + } +} + +function mount(build: QueryBuild, config: InfiniteQueryConfig = {}) { + const { result, scope } = runInScope(() => + useLiveInfiniteQuery(build as any, config as any), + ) + return makeHandle(result, scope) +} + +function mountControllable

( + build: (q: any, param: P) => any, + initial: P, + config: InfiniteQueryConfig = {}, +) { + const param = ref(initial) as { value: P } + const { result, scope } = runInScope(() => + useLiveInfiniteQuery((q: any) => build(q, param.value), config as any, [ + param, + ]), + ) + const handle = makeHandle(result, scope) + return { + ...handle, + setParamSync(next: P) { + param.value = next + }, + } +} + +function mountCollection(collection: any, config: InfiniteQueryConfig = {}) { + const { result, scope } = runInScope(() => + useLiveInfiniteQuery(collection, config as any), + ) + return makeHandle(result, scope) +} + +function mountCollectionControllable( + initial: any, + config: InfiniteQueryConfig = {}, +) { + const collection = shallowRef(initial) + const { result, scope } = runInScope(() => + useLiveInfiniteQuery(collection, config as any), + ) + const handle = makeHandle(result, scope) + return { + ...handle, + replaceCollectionSync(next: any) { + collection.value = next + }, + } +} + +function mountConfigControllable( + build: QueryBuild, + initial: InfiniteQueryConfig, +) { + const config = reactive({ ...initial }) + const { result, scope } = runInScope(() => + useLiveInfiniteQuery(build as any, config as any), + ) + const handle = makeHandle(result, scope) + return { + ...handle, + setConfigSync(next: InfiniteQueryConfig) { + Object.assign(config, next) + }, + } +} + +function mountInputControllable( + collection: any, + build: QueryBuild, + config: InfiniteQueryConfig = {}, +) { + const kind = ref<`collection` | `query`>(`collection`) + const { result, scope } = runInScope(() => + useLiveInfiniteQuery( + (q: any) => (kind.value === `collection` ? collection : build(q)), + config as any, + [kind], + ), + ) + const handle = makeHandle(result, scope) + return { + ...handle, + setInputKindSync(next: `collection` | `query`) { + kind.value = next + }, + } +} + +const vueInfiniteDriver: InfiniteQueryDriver = { + name: `vue`, + gt, + makeSource, + makeOnDemandSource: (data, delay) => + makeInfiniteOnDemandSource({ createCollection, BTreeIndex }, data, delay), + makePrecreated, + mount, + mountControllable, + mountCollection, + mountCollectionControllable, + mountConfigControllable, + mountInputControllable, + knownGaps: [], +} + +runInfiniteQuerySuite(vueInfiniteDriver) diff --git a/packages/vue-db/tests/useLiveInfiniteQuery.test-d.ts b/packages/vue-db/tests/useLiveInfiniteQuery.test-d.ts new file mode 100644 index 0000000000..9d868faa86 --- /dev/null +++ b/packages/vue-db/tests/useLiveInfiniteQuery.test-d.ts @@ -0,0 +1,64 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { shallowRef } from 'vue' +import { createCollection, createLiveQueryCollection } from '@tanstack/db' +import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' +import { mockSyncCollectionOptions } from '../../db/tests/utils' +import type { InitialQueryBuilder } from '@tanstack/db' + +type Post = { + id: string + createdAt: number +} + +describe(`useLiveInfiniteQuery type assertions`, () => { + it(`preserves query and pre-created collection result types`, () => { + const posts = createCollection( + mockSyncCollectionOptions({ + id: `vue-infinite-types`, + getKey: (post) => post.id, + initialData: [], + }), + ) + + const queryResult = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q.from({ posts }).orderBy(({ posts: post }) => post.createdAt, `desc`), + { pageSize: 5 }, + ) + expectTypeOf(queryResult.data.value[0]!.id).toEqualTypeOf() + expectTypeOf(queryResult.data.value[0]!.createdAt).toEqualTypeOf() + expectTypeOf(queryResult.fetchNextPage()).toEqualTypeOf>() + + const livePosts = createLiveQueryCollection((q: InitialQueryBuilder) => + q.from({ posts }).orderBy(({ posts: post }) => post.createdAt, `desc`), + ) + const collectionResult = useLiveInfiniteQuery(shallowRef(livePosts), { + pageSize: 5, + getNextPageParam: (lastPage) => lastPage[0]?.createdAt, + }) + + expectTypeOf(collectionResult.data.value[0]!.id).toEqualTypeOf() + expectTypeOf( + collectionResult.data.value[0]!.createdAt, + ).toEqualTypeOf() + expectTypeOf(collectionResult.state.value.get(`1`)?.id).toEqualTypeOf< + string | undefined + >() + + useLiveInfiniteQuery( + // @ts-expect-error Infinite queries cannot use a single-result query. + (q: InitialQueryBuilder) => + q + .from({ posts }) + .orderBy(({ posts: post }) => post.createdAt, `desc`) + .findOne(), + { pageSize: 5 }, + ) + + useLiveInfiniteQuery( + // @ts-expect-error Infinite queries do not support disabled null queries. + (_q: InitialQueryBuilder) => null, + { pageSize: 5 }, + ) + }) +}) diff --git a/packages/vue-db/tests/useLiveInfiniteQuery.test.ts b/packages/vue-db/tests/useLiveInfiniteQuery.test.ts new file mode 100644 index 0000000000..9657d1bccf --- /dev/null +++ b/packages/vue-db/tests/useLiveInfiniteQuery.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { effectScope, nextTick, shallowRef } from 'vue' +import { createCollection, createLiveQueryCollection } from '@tanstack/db' +import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' +import { mockSyncCollectionOptions } from '../../db/tests/utils' +import type { InitialQueryBuilder } from '@tanstack/db' + +type Post = { + id: string + title: string + createdAt: number +} + +function createPosts(count: number): Array { + return Array.from({ length: count }, (_, index) => ({ + id: String(index + 1), + title: `Post ${index + 1}`, + createdAt: count - index, + })) +} + +function createPostsCollection(id: string, count: number) { + return createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id, + getKey: (post) => post.id, + initialData: createPosts(count), + }), + ) +} + +async function flushVue(): Promise { + await nextTick() + await Promise.resolve() +} + +describe(`useLiveInfiniteQuery`, () => { + let cleanup: (() => void) | undefined + + afterEach(() => { + cleanup?.() + cleanup = undefined + vi.restoreAllMocks() + }) + + it(`accepts a reactive ref for a pre-created ordered collection`, async () => { + const posts = createPostsCollection(`vue-infinite-precreated`, 7) + const livePosts = createLiveQueryCollection({ + query: (q) => + q + .from({ posts }) + .orderBy(({ posts: post }) => post.createdAt, `desc`) + .limit(2) + .offset(1), + }) + await livePosts.preload() + const warning = vi.spyOn(console, `warn`).mockImplementation(() => {}) + const scope = effectScope() + const collection = shallowRef(livePosts) + const query = scope.run(() => + useLiveInfiniteQuery(collection, { + pageSize: 3, + getNextPageParam: (lastPage) => lastPage[0]?.createdAt, + }), + ) + cleanup = () => scope.stop() + if (!query) throw new Error(`Failed to mount infinite query`) + await flushVue() + + expect(query.collection.value).toBe(livePosts) + expect(query.data.value.map((post) => post.id)).toEqual([`1`, `2`, `3`]) + expect(query.state.value.get(`1`)?.title).toBe(`Post 1`) + expect(query.hasNextPage.value).toBe(true) + expect(warning).toHaveBeenCalledOnce() + expect(livePosts.utils.getWindow()).toEqual({ offset: 0, limit: 4 }) + }) + + it(`resets to the first page when a collection ref changes`, async () => { + const firstPosts = createPostsCollection(`vue-infinite-swap-first`, 8) + const secondPosts = createPostsCollection(`vue-infinite-swap-second`, 4) + const firstQuery = createLiveQueryCollection({ + query: (q) => + q + .from({ posts: firstPosts }) + .orderBy(({ posts: post }) => post.createdAt, `desc`) + .limit(4), + }) + const secondQuery = createLiveQueryCollection({ + query: (q) => + q + .from({ posts: secondPosts }) + .orderBy(({ posts: post }) => post.createdAt, `desc`) + .limit(4), + }) + await Promise.all([firstQuery.preload(), secondQuery.preload()]) + + const scope = effectScope() + const selectedQuery = shallowRef(firstQuery) + const query = scope.run(() => + useLiveInfiniteQuery(selectedQuery, { pageSize: 3 }), + ) + cleanup = () => scope.stop() + if (!query) throw new Error(`Failed to mount infinite query`) + await flushVue() + + await query.fetchNextPage() + await flushVue() + expect(query.pages.value).toHaveLength(2) + + selectedQuery.value = secondQuery + await flushVue() + + expect(query.collection.value).toBe(secondQuery) + expect(query.pages.value).toHaveLength(1) + expect(query.data.value.map((post) => post.createdAt)).toEqual([4, 3, 2]) + }) + + it(`does not recreate a controller through a retained callback after unmount`, async () => { + const posts = createPostsCollection(`vue-infinite-retained-fetch`, 7) + let queryBuilds = 0 + const scope = effectScope() + const query = scope.run(() => + useLiveInfiniteQuery( + (q: InitialQueryBuilder) => { + queryBuilds++ + return q + .from({ posts }) + .orderBy(({ posts: post }) => post.createdAt, `desc`) + }, + { pageSize: 3 }, + ), + ) + if (!query) throw new Error(`Failed to mount infinite query`) + await flushVue() + expect(queryBuilds).toBe(1) + + const retainedFetch = query.fetchNextPage + scope.stop() + await retainedFetch() + await retainedFetch() + + expect(queryBuilds).toBe(1) + }) +})