+ }
+ 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