diff --git a/.changeset/spicy-roses-hide.md b/.changeset/spicy-roses-hide.md
new file mode 100644
index 000000000..5bb063f82
--- /dev/null
+++ b/.changeset/spicy-roses-hide.md
@@ -0,0 +1,59 @@
+---
+'@tanstack/svelte-db': minor
+---
+
+Add `useLiveInfiniteQuery` rune for infinite scrolling with live updates.
+
+The new `useLiveInfiniteQuery` provides an infinite query pattern similar to TanStack Query's `useInfiniteQuery`, but integrated with TanStack DB's reactive local collections. It maintains a reactive window into your data, allowing for efficient pagination and automatic updates as data changes.
+
+**Key features:**
+
+- **Automatic Live Updates**: Reactive integration with local collections using Svelte runes.
+- **Efficient Pagination**: Uses a dynamic window mechanism to track visible data without re-executing complex queries.
+- **Automatic Page Detection**: Includes a built-in peek-ahead strategy to detect if more pages are available without manual `getNextPageParam` logic.
+- **Flexible Rendering**: Provides both a flattened `data` array and a structured `pages` array.
+
+**Example usage:**
+
+```svelte
+
+
+{#if query.isLoading}
+
Loading...
+{:else}
+
+ {#each query.pages as page}
+ {#each page as post (post.id)}
+
+ {/each}
+ {/each}
+
+ {#if query.hasNextPage}
+
query.fetchNextPage()}
+ >
+ {query.isFetchingNextPage ? 'Loading...' : 'Load More'}
+
+ {/if}
+
+{/if}
+```
+
+**Requirements:**
+
+- The query must include an `.orderBy()` clause to support the underlying windowing mechanism.
+- Supports both offset-based and cursor-based sync implementations via the standard TanStack DB sync protocol.
diff --git a/packages/svelte-db/src/index.ts b/packages/svelte-db/src/index.ts
index a07f98a64..b7a01e00a 100644
--- a/packages/svelte-db/src/index.ts
+++ b/packages/svelte-db/src/index.ts
@@ -1,5 +1,6 @@
// Re-export all public APIs
export * from './useLiveQuery.svelte.js'
+export * from './useLiveInfiniteQuery.svelte.js'
// Re-export everything from @tanstack/db
export * from '@tanstack/db'
diff --git a/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts b/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts
new file mode 100644
index 000000000..07493a57f
--- /dev/null
+++ b/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts
@@ -0,0 +1,329 @@
+import { CollectionImpl } from '@tanstack/db'
+import { untrack } from 'svelte'
+import { useLiveQuery } from './useLiveQuery.svelte.js'
+import type { MaybeGetter } from './useLiveQuery.svelte.js'
+import type {
+ Collection,
+ Context,
+ InferResultType,
+ InitialQueryBuilder,
+ LiveQueryCollectionUtils,
+ NonSingleResult,
+ QueryBuilder,
+} from '@tanstack/db'
+
+/**
+ * Type guard to check if utils object has setWindow method (LiveQueryCollectionUtils)
+ */
+function isLiveQueryCollectionUtils(
+ utils: unknown,
+): utils is LiveQueryCollectionUtils {
+ return typeof (utils as any).setWindow === `function`
+}
+
+/**
+ * Normalizes the input into a stable value and type flag.
+ * Handles: Collection, () => Collection (getter), or (q) => Query (fn).
+ */
+function resolveInput(input: any) {
+ let unwrapped = input
+ let isCollection = unwrapped instanceof CollectionImpl
+
+ if (!isCollection && typeof unwrapped === `function`) {
+ try {
+ // Try to see if it's a getter for a collection
+ const potentiallyColl = unwrapped()
+ if (potentiallyColl instanceof CollectionImpl) {
+ unwrapped = potentiallyColl
+ isCollection = true
+ }
+ } catch {
+ // It's likely a query function that expects arguments
+ }
+ }
+
+ if (!isCollection && typeof unwrapped !== `function`) {
+ throw new Error(
+ `useLiveInfiniteQuery: First argument must be either a pre-created live query collection (CollectionImpl) ` +
+ `or a query function. Received: ${typeof unwrapped}`,
+ )
+ }
+
+ return { unwrapped, isCollection }
+}
+
+export type UseLiveInfiniteQueryConfig = {
+ pageSize?: number
+ initialPageParam?: number
+ /**
+ * @deprecated This callback is not used by the current implementation.
+ * Pagination is determined internally via a peek-ahead strategy.
+ * Provided for API compatibility with TanStack Query conventions.
+ */
+ getNextPageParam?: (
+ lastPage: Array[number]>,
+ allPages: Array[number]>>,
+ lastPageParam: number,
+ allPageParams: Array,
+ ) => number | undefined
+}
+
+export type UseLiveInfiniteQueryReturn = Omit<
+ ReturnType>,
+ `data`
+> & {
+ data: InferResultType
+ pages: Array[number]>>
+ pageParams: Array
+ fetchNextPage: () => void
+ hasNextPage: boolean
+ isFetchingNextPage: boolean
+}
+
+/**
+ * Pure utility to slice data into pages based on count and size
+ */
+function paginate(
+ data: Array,
+ pageSize: number,
+ pageCount: number,
+ initialParam: number,
+) {
+ const pages: Array> = []
+ const pageParams: Array = []
+
+ for (let i = 0; i < pageCount; i++) {
+ const start = i * pageSize
+ const end = (i + 1) * pageSize
+ pages.push(data.slice(start, end))
+ pageParams.push(initialParam + i)
+ }
+
+ return { pages, pageParams }
+}
+
+/**
+ * Create an infinite query using a query function with live updates
+ *
+ * Uses `utils.setWindow()` to dynamically adjust the limit/offset window
+ * without recreating the live query collection on each page change.
+ *
+ * @param queryFnOrCollection - Query function or pre-created collection
+ * @param config - Configuration including pageSize and getNextPageParam
+ * @param deps - Array of reactive dependencies that trigger query re-execution when changed
+ * @returns Object with pages, data, and pagination controls
+ *
+ * @remarks
+ * **IMPORTANT - Destructuring in Svelte 5:**
+ * Direct destructuring breaks reactivity. To destructure, wrap with `$derived`:
+ *
+ * ❌ **Incorrect** - Loses reactivity:
+ * ```ts
+ * const { data, pages, fetchNextPage } = useLiveInfiniteQuery(...)
+ * ```
+ *
+ * ✅ **Correct** - Maintains reactivity:
+ * ```ts
+ * // Option 1: Use dot notation (recommended)
+ * const query = useLiveInfiniteQuery(...)
+ * // Access: query.data, query.pages, query.fetchNextPage()
+ *
+ * // Option 2: Wrap with $derived for destructuring
+ * const query = useLiveInfiniteQuery(...)
+ * const { data, pages, fetchNextPage } = $derived(query)
+ * ```
+ *
+ * This is a fundamental Svelte 5 limitation, not a library bug.
+ */
+
+// Overload for pre-created collection (non-single result)
+export function useLiveInfiniteQuery<
+ TResult extends object,
+ TKey extends string | number,
+ TUtils extends Record,
+>(
+ liveQueryCollection: MaybeGetter<
+ Collection & NonSingleResult
+ >,
+ config: UseLiveInfiniteQueryConfig,
+): UseLiveInfiniteQueryReturn
+
+// Overload for query function
+export function useLiveInfiniteQuery(
+ queryFn: (q: InitialQueryBuilder) => QueryBuilder,
+ config: UseLiveInfiniteQueryConfig,
+ deps?: Array<() => unknown>,
+): UseLiveInfiniteQueryReturn
+
+// Implementation
+export function useLiveInfiniteQuery(
+ queryFnOrCollection: any,
+ config: UseLiveInfiniteQueryConfig,
+ deps: Array<() => unknown> = [],
+): UseLiveInfiniteQueryReturn {
+ const pageSize = $derived(config.pageSize ?? 20)
+ const initialPageParam = $derived(config.initialPageParam ?? 0)
+
+ // 1. Resolve input reactively
+ const input = $derived(resolveInput(queryFnOrCollection))
+
+ // 2. Local pagination state
+ let loadedPageCount = $state(1)
+ let isFetchingNextPage = $state(false)
+ let currentCollectionInstance: any = null
+ let hasValidatedCollection = false
+
+ // 3. Underlying live query
+ const query = useLiveQuery(() => {
+ const { isCollection: isColl, unwrapped } = input
+ if (isColl) return unwrapped
+
+ return (q: InitialQueryBuilder) =>
+ unwrapped(q)
+ .limit(pageSize + 1)
+ .offset(0)
+ }, deps)
+
+ // 4. Reset pagination on collection change
+ $effect(() => {
+ if (query.collection !== currentCollectionInstance) {
+ untrack(() => {
+ currentCollectionInstance = query.collection
+ hasValidatedCollection = false
+ loadedPageCount = 1
+ })
+ }
+ })
+
+ // 5. Window adjustment effect
+ $effect(() => {
+ const { collection, isReady } = query
+ if (!isReady) return
+
+ const utils = collection.utils
+ const expectedOffset = 0
+ const expectedLimit = loadedPageCount * (pageSize + 1) // +1 per page for peek ahead consistency
+
+ // Check if collection has orderBy (required for setWindow)
+ if (!isLiveQueryCollectionUtils(utils)) {
+ // For pre-created collections, throw an error if no orderBy
+ if (input.isCollection) {
+ throw new Error(
+ `useLiveInfiniteQuery: Pre-created live query collection must have an orderBy clause for infinite pagination to work.` +
+ `Please add .orderBy() to your createLiveQueryCollection query.`,
+ )
+ }
+ return
+ }
+
+ // Validation warning for pre-created collections
+ if (input.isCollection && !hasValidatedCollection) {
+ const win = utils.getWindow()
+ if (
+ win &&
+ (win.offset !== expectedOffset || win.limit !== expectedLimit)
+ ) {
+ console.warn(
+ `useLiveInfiniteQuery: Pre-created collection has window {offset: ${win.offset}, limit: ${win.limit}} ` +
+ `but hook expects {offset: 0, limit: ${expectedLimit}}. Adjusting now.`,
+ )
+ }
+ hasValidatedCollection = true
+ }
+
+ let cancelled = false
+ const result = utils.setWindow({
+ offset: expectedOffset,
+ limit: expectedLimit,
+ })
+
+ if (result !== true) {
+ isFetchingNextPage = true
+ result
+ .catch((err: unknown) => {
+ if (!cancelled) console.error(`useLiveInfiniteQuery failed:`, err)
+ })
+ .finally(() => {
+ if (!cancelled) isFetchingNextPage = false
+ })
+ } else {
+ isFetchingNextPage = false
+ }
+
+ return () => {
+ cancelled = true
+ }
+ })
+
+ // 6. Data derivation
+ const result = $derived.by(() => {
+ const dataArray = (Array.isArray(query.data) ? query.data : []) as Array<
+ InferResultType[number]
+ >
+
+ const requestedCount = loadedPageCount * pageSize
+ const { pages, pageParams } = paginate(
+ dataArray,
+ pageSize,
+ loadedPageCount,
+ initialPageParam,
+ )
+
+ return {
+ pages,
+ pageParams,
+ data: dataArray.slice(0, requestedCount) as InferResultType,
+ hasNextPage: dataArray.length > requestedCount,
+ }
+ })
+
+ const fetchNextPage = () => {
+ if (result.hasNextPage && !isFetchingNextPage) {
+ loadedPageCount++
+ }
+ }
+
+ // 7. Public API with concise delegation
+ return {
+ get state() {
+ return query.state as Map
+ },
+ get collection() {
+ return query.collection as any
+ },
+ get status() {
+ return query.status
+ },
+ get isLoading() {
+ return query.isLoading
+ },
+ get isReady() {
+ return query.isReady
+ },
+ get isIdle() {
+ return query.isIdle
+ },
+ get isError() {
+ return query.isError
+ },
+ get isCleanedUp() {
+ return query.isCleanedUp
+ },
+ get data() {
+ return result.data
+ },
+ get pages() {
+ return result.pages
+ },
+ get pageParams() {
+ return result.pageParams
+ },
+ get hasNextPage() {
+ return result.hasNextPage
+ },
+ get isFetchingNextPage() {
+ return isFetchingNextPage
+ },
+ fetchNextPage,
+ } as UseLiveInfiniteQueryReturn
+}
diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts
index 76dedd3c7..5de82e66c 100644
--- a/packages/svelte-db/src/useLiveQuery.svelte.ts
+++ b/packages/svelte-db/src/useLiveQuery.svelte.ts
@@ -59,9 +59,9 @@ export interface UseLiveQueryReturnWithCollection<
isCleanedUp: boolean
}
-type MaybeGetter = T | (() => T)
+export type MaybeGetter = T | (() => T)
-function toValue(value: MaybeGetter): T {
+export function toValue(value: MaybeGetter): T {
if (typeof value === `function`) {
return (value as () => T)()
}
@@ -465,6 +465,7 @@ export function useLiveQuery(
currentUnsubscribe()
currentUnsubscribe = null
}
+ status = `cleaned-up` as const
}
})
diff --git a/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts b/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts
new file mode 100644
index 000000000..0db0a8e66
--- /dev/null
+++ b/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts
@@ -0,0 +1,1583 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import { flushSync } from 'svelte'
+import {
+ BTreeIndex,
+ createCollection,
+ createLiveQueryCollection,
+ eq,
+} from '@tanstack/db'
+import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery.svelte.js'
+import { mockSyncCollectionOptions } from '../../db/tests/utils'
+import { createFilterFunctionFromExpression } from '../../db/src/collection/change-events'
+import type { InitialQueryBuilder, LoadSubsetOptions } from '@tanstack/db'
+
+type Post = {
+ id: string
+ title: string
+ content: string
+ createdAt: number
+ category: string
+}
+
+function createMockPosts(count: number): Array {
+ const posts: Array = []
+ for (let i = 1; i <= count; i++) {
+ posts.push({
+ id: `${i}`,
+ title: `Post ${i}`,
+ content: `Content ${i}`,
+ createdAt: 1000000 - i * 1000, // Descending order
+ category: i % 2 === 0 ? `tech` : `life`,
+ })
+ }
+ return posts
+}
+
+type OnDemandCollectionOptions = {
+ id: string
+ allPosts: Array
+ autoIndex?: `off` | `eager`
+ asyncDelay?: number
+}
+
+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`, () => {
+ let cleanup: (() => void) | null = null
+
+ afterEach(() => {
+ cleanup?.()
+ })
+
+ it(`should fetch initial page of data`, () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `initial-page-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ cleanup = $effect.root(() => {
+ const query = useLiveInfiniteQuery(
+ (q: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`)
+ .select(({ posts: p }) => ({
+ id: p.id,
+ title: p.title,
+ createdAt: p.createdAt,
+ })),
+ {
+ pageSize: 10,
+ },
+ )
+
+ flushSync()
+
+ expect(query.isReady).toBe(true)
+ expect(query.pages).toHaveLength(1)
+ expect(query.pages[0]).toHaveLength(10)
+ expect(query.data).toHaveLength(10)
+ expect(query.hasNextPage).toBe(true)
+ expect(query.pages[0]![0]).toMatchObject({
+ id: `1`,
+ title: `Post 1`,
+ })
+ })
+ })
+
+ it(`should fetch multiple pages`, () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `multiple-pages-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ cleanup = $effect.root(() => {
+ const query = useLiveInfiniteQuery(
+ (q: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ },
+ )
+
+ flushSync()
+
+ expect(query.isReady).toBe(true)
+ expect(query.pages).toHaveLength(1)
+ expect(query.hasNextPage).toBe(true)
+
+ query.fetchNextPage()
+ flushSync()
+
+ expect(query.pages).toHaveLength(2)
+ expect(query.pages[0]).toHaveLength(10)
+ expect(query.pages[1]).toHaveLength(10)
+ expect(query.data).toHaveLength(20)
+ expect(query.hasNextPage).toBe(true)
+
+ query.fetchNextPage()
+ flushSync()
+
+ expect(query.pages).toHaveLength(3)
+ expect(query.data).toHaveLength(30)
+ expect(query.hasNextPage).toBe(true)
+ })
+ })
+
+ it(`should detect when no more pages available`, () => {
+ const posts = createMockPosts(25)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `no-more-pages-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ cleanup = $effect.root(() => {
+ const query = useLiveInfiniteQuery(
+ (q: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ },
+ )
+
+ flushSync()
+
+ expect(query.isReady).toBe(true)
+ expect(query.pages).toHaveLength(1)
+ expect(query.hasNextPage).toBe(true)
+
+ query.fetchNextPage()
+ flushSync()
+
+ expect(query.pages).toHaveLength(2)
+ expect(query.pages[1]).toHaveLength(10)
+ expect(query.hasNextPage).toBe(true)
+
+ query.fetchNextPage()
+ flushSync()
+
+ expect(query.pages).toHaveLength(3)
+ expect(query.pages[2]).toHaveLength(5)
+ expect(query.data).toHaveLength(25)
+ expect(query.hasNextPage).toBe(false)
+ })
+ })
+
+ it(`should handle empty results`, () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `empty-results-test`,
+ getKey: (post: Post) => post.id,
+ initialData: [],
+ }),
+ )
+
+ cleanup = $effect.root(() => {
+ const query = useLiveInfiniteQuery(
+ (q: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ },
+ )
+
+ flushSync()
+
+ expect(query.isReady).toBe(true)
+ expect(query.pages).toHaveLength(1)
+ expect(query.pages[0]).toHaveLength(0)
+ expect(query.data).toHaveLength(0)
+ expect(query.hasNextPage).toBe(false)
+ })
+ })
+
+ it(`should update pages when underlying data changes`, () => {
+ const posts = createMockPosts(30)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `live-updates-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ cleanup = $effect.root(() => {
+ const query = useLiveInfiniteQuery(
+ (q: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ },
+ )
+
+ flushSync()
+
+ query.fetchNextPage()
+ flushSync()
+
+ expect(query.pages).toHaveLength(2)
+ expect(query.data).toHaveLength(20)
+
+ collection.utils.begin()
+ collection.utils.write({
+ type: `insert`,
+ value: {
+ id: `new-1`,
+ title: `New Post`,
+ content: `New Content`,
+ createdAt: 1000001,
+ category: `tech`,
+ },
+ })
+ collection.utils.commit()
+
+ flushSync()
+
+ expect(query.pages[0]![0]).toMatchObject({
+ id: `new-1`,
+ title: `New Post`,
+ })
+
+ expect(query.pages).toHaveLength(2)
+ expect(query.data).toHaveLength(20)
+ expect(query.pages[0]).toHaveLength(10)
+ expect(query.pages[1]).toHaveLength(10)
+ })
+ })
+
+ it(`should work with where clauses`, () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `where-clause-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ cleanup = $effect.root(() => {
+ const query = useLiveInfiniteQuery(
+ (q: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection })
+ .where(({ posts: p }) => eq(p.category, `tech`))
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 5,
+ },
+ )
+
+ flushSync()
+
+ expect(query.pages).toHaveLength(1)
+ expect(query.pages[0]).toHaveLength(5)
+
+ query.pages[0]!.forEach((post: Post) => {
+ expect(post.category).toBe(`tech`)
+ })
+
+ expect(query.hasNextPage).toBe(true)
+
+ query.fetchNextPage()
+ flushSync()
+
+ expect(query.pages).toHaveLength(2)
+ expect(query.data).toHaveLength(10)
+ })
+ })
+
+ it(`should re-execute query when dependencies change`, () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `deps-change-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ cleanup = $effect.root(() => {
+ let category = $state(`tech`)
+
+ const query = useLiveInfiniteQuery(
+ (q: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection })
+ .where(({ posts: p }) => eq(p.category, category))
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 5,
+ },
+ [() => category],
+ )
+
+ flushSync()
+
+ query.fetchNextPage()
+ flushSync()
+
+ expect(query.pages).toHaveLength(2)
+
+ category = `life`
+ flushSync()
+
+ expect(query.pages).toHaveLength(1)
+ query.pages[0]!.forEach((post: Post) => {
+ expect(post.category).toBe(`life`)
+ })
+ })
+ })
+
+ it(`should track pageParams correctly`, () => {
+ const posts = createMockPosts(30)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `page-params-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ cleanup = $effect.root(() => {
+ const query = useLiveInfiniteQuery(
+ (q: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ initialPageParam: 0,
+ },
+ )
+
+ flushSync()
+
+ expect(query.pageParams).toEqual([0])
+
+ query.fetchNextPage()
+ flushSync()
+
+ expect(query.pageParams).toEqual([0, 1])
+
+ query.fetchNextPage()
+ flushSync()
+
+ expect(query.pageParams).toEqual([0, 1, 2])
+ })
+ })
+
+ 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: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`)
+ .limit(5),
+ })
+
+ await liveQueryCollection.preload()
+
+ cleanup = $effect.root(() => {
+ const query = useLiveInfiniteQuery(liveQueryCollection, {
+ pageSize: 10,
+ })
+
+ flushSync()
+
+ expect(query.isReady).toBe(true)
+ expect(query.pages).toHaveLength(1)
+ expect(query.pages[0]).toHaveLength(10)
+ expect(query.data).toHaveLength(10)
+ expect(query.hasNextPage).toBe(true)
+ expect(query.pages[0]![0]).toMatchObject({
+ id: `1`,
+ title: `Post 1`,
+ })
+ })
+ })
+
+ it(`should work with on-demand collection via peek-ahead`, () => {
+ const PAGE_SIZE = 10
+ const { collection } = createOnDemandCollection({
+ id: `peek-ahead-boundary-test`,
+ allPosts: createMockPosts(PAGE_SIZE + 1),
+ })
+
+ cleanup = $effect.root(() => {
+ const query = useLiveInfiniteQuery(
+ (q: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: PAGE_SIZE,
+ },
+ )
+
+ flushSync()
+
+ expect(query.isReady).toBe(true)
+ expect(query.hasNextPage).toBe(true)
+ expect(query.data).toHaveLength(PAGE_SIZE)
+ expect(query.pages).toHaveLength(1)
+ expect(query.pages[0]).toHaveLength(PAGE_SIZE)
+ })
+ })
+
+ it(`should handle deletions across pages`, () => {
+ const posts = createMockPosts(25)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `deletions-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ cleanup = $effect.root(() => {
+ const query = useLiveInfiniteQuery(
+ (q: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ },
+ )
+
+ flushSync()
+
+ query.fetchNextPage()
+ flushSync()
+
+ expect(query.pages).toHaveLength(2)
+ expect(query.data).toHaveLength(20)
+ const firstItemId = query.data[0]!.id
+
+ collection.utils.begin()
+ collection.utils.write({
+ type: `delete`,
+ value: posts[0]!,
+ })
+ collection.utils.commit()
+
+ flushSync()
+
+ expect(query.data[0]!.id).not.toBe(firstItemId)
+ expect(query.pages).toHaveLength(2)
+ expect(query.data).toHaveLength(20)
+ expect(query.pages[0]).toHaveLength(10)
+ expect(query.pages[1]).toHaveLength(10)
+ })
+ })
+
+ it(`should handle deletion from partial page with descending order`, () => {
+ const posts = createMockPosts(5)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `partial-page-deletion-desc-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ cleanup = $effect.root(() => {
+ const query = useLiveInfiniteQuery(
+ (q: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 20,
+ },
+ )
+
+ flushSync()
+
+ expect(query.pages).toHaveLength(1)
+ expect(query.data).toHaveLength(5)
+
+ const firstItemId = query.data[0]!.id
+ expect(firstItemId).toBe(`1`)
+
+ collection.utils.begin()
+ collection.utils.write({
+ type: `delete`,
+ value: posts[0]!,
+ })
+ collection.utils.commit()
+
+ flushSync()
+
+ expect(query.data).toHaveLength(4)
+ expect(query.data.find((p: Post) => p.id === firstItemId)).toBeUndefined()
+ expect(query.data[0]!.id).toBe(`2`)
+ expect(query.pages).toHaveLength(1)
+ expect(query.pages[0]).toHaveLength(4)
+ })
+ })
+
+ it(`should handle deletion from partial page with ascending order`, () => {
+ const posts = createMockPosts(5)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `partial-page-deletion-asc-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ cleanup = $effect.root(() => {
+ const query = useLiveInfiniteQuery(
+ (q: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `asc`),
+ {
+ pageSize: 20,
+ },
+ )
+
+ flushSync()
+
+ expect(query.pages).toHaveLength(1)
+ expect(query.data).toHaveLength(5)
+
+ const firstItemId = query.data[0]!.id
+ expect(firstItemId).toBe(`5`)
+
+ collection.utils.begin()
+ collection.utils.write({
+ type: `delete`,
+ value: posts[4]!,
+ })
+ collection.utils.commit()
+
+ flushSync()
+
+ expect(query.data).toHaveLength(4)
+ expect(query.data.find((p: Post) => p.id === firstItemId)).toBeUndefined()
+ expect(query.pages).toHaveLength(1)
+ expect(query.pages[0]).toHaveLength(4)
+ })
+ })
+
+ it(`should handle exact page size boundaries`, () => {
+ const posts = createMockPosts(20)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `exact-boundary-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ cleanup = $effect.root(() => {
+ const query = useLiveInfiniteQuery(
+ (q: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ },
+ )
+
+ flushSync()
+
+ expect(query.hasNextPage).toBe(true)
+
+ query.fetchNextPage()
+ flushSync()
+
+ expect(query.pages).toHaveLength(2)
+ expect(query.pages[1]).toHaveLength(10)
+ expect(query.hasNextPage).toBe(false)
+ expect(query.data).toHaveLength(20)
+ })
+ })
+
+ it(`should not fetch when already fetching`, () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `concurrent-fetch-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ cleanup = $effect.root(() => {
+ const query = useLiveInfiniteQuery(
+ (q: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ },
+ )
+
+ flushSync()
+ expect(query.pages).toHaveLength(1)
+
+ query.fetchNextPage()
+ flushSync()
+ expect(query.pages).toHaveLength(2)
+
+ query.fetchNextPage()
+ flushSync()
+ expect(query.pages).toHaveLength(3)
+
+ query.fetchNextPage()
+ flushSync()
+ expect(query.pages).toHaveLength(4)
+
+ expect(query.pages).toHaveLength(4)
+ expect(query.data).toHaveLength(40)
+ })
+ })
+
+ it(`should not fetch when hasNextPage is false`, () => {
+ const posts = createMockPosts(5)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `no-fetch-when-done-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ cleanup = $effect.root(() => {
+ const query = useLiveInfiniteQuery(
+ (q: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ },
+ )
+
+ flushSync()
+
+ expect(query.hasNextPage).toBe(false)
+ expect(query.pages).toHaveLength(1)
+
+ query.fetchNextPage()
+ flushSync()
+
+ expect(query.pages).toHaveLength(1)
+ })
+ })
+
+ it(`should support custom initialPageParam`, () => {
+ const posts = createMockPosts(30)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `initial-param-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ cleanup = $effect.root(() => {
+ const query = useLiveInfiniteQuery(
+ (q: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ initialPageParam: 100,
+ },
+ )
+
+ flushSync()
+
+ expect(query.pageParams).toEqual([100])
+
+ query.fetchNextPage()
+ flushSync()
+
+ expect(query.pageParams).toEqual([100, 101])
+ })
+ })
+
+ it(`should detect hasNextPage change when new items are synced`, () => {
+ const posts = createMockPosts(20)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `sync-detection-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ cleanup = $effect.root(() => {
+ const query = useLiveInfiniteQuery(
+ (q: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ },
+ )
+
+ flushSync()
+
+ query.fetchNextPage()
+ flushSync()
+
+ expect(query.pages).toHaveLength(2)
+ expect(query.hasNextPage).toBe(false)
+ expect(query.data).toHaveLength(20)
+
+ 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()
+
+ flushSync()
+
+ expect(query.hasNextPage).toBe(true)
+ expect(query.data).toHaveLength(20)
+ expect(query.pages).toHaveLength(2)
+
+ query.fetchNextPage()
+ flushSync()
+
+ expect(query.pages).toHaveLength(3)
+ expect(query.pages[2]).toHaveLength(5)
+ expect(query.data).toHaveLength(25)
+ expect(query.hasNextPage).toBe(false)
+ })
+ })
+
+ it(`should set isFetchingNextPage to false when data is immediately available`, () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `immediate-data-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ cleanup = $effect.root(() => {
+ const query = useLiveInfiniteQuery(
+ (queryBuilder: InitialQueryBuilder) =>
+ queryBuilder
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ },
+ )
+
+ flushSync()
+
+ expect(query.isReady).toBe(true)
+ expect(query.pages).toHaveLength(1)
+ expect(query.isFetchingNextPage).toBe(false)
+
+ query.fetchNextPage()
+ flushSync()
+
+ expect(query.pages).toHaveLength(2)
+ expect(query.isFetchingNextPage).toBe(false)
+ })
+ })
+
+ it(`should detect hasNextPage via peek-ahead with exactly pageSize+1 items in on-demand collection`, () => {
+ const PAGE_SIZE = 10
+ const { collection } = createOnDemandCollection({
+ id: `peek-ahead-boundary-test-on-demand`,
+ allPosts: createMockPosts(PAGE_SIZE + 1),
+ })
+
+ cleanup = $effect.root(() => {
+ const query = useLiveInfiniteQuery(
+ (queryBuilder: InitialQueryBuilder) =>
+ queryBuilder
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: PAGE_SIZE,
+ },
+ )
+
+ flushSync()
+
+ expect(query.isReady).toBe(true)
+ expect(query.hasNextPage).toBe(true)
+ expect(query.data).toHaveLength(PAGE_SIZE)
+ expect(query.pages).toHaveLength(1)
+ expect(query.pages[0]).toHaveLength(PAGE_SIZE)
+ })
+ })
+
+ it(`should request limit+1 (peek-ahead) from loadSubset for hasNextPage detection`, () => {
+ const PAGE_SIZE = 10
+ const { collection, loadSubsetCalls } = createOnDemandCollection({
+ id: `peek-ahead-limit-test`,
+ allPosts: createMockPosts(PAGE_SIZE),
+ })
+
+ cleanup = $effect.root(() => {
+ const query = useLiveInfiniteQuery(
+ (q: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: PAGE_SIZE,
+ },
+ )
+
+ flushSync()
+ expect(query.isReady).toBe(true)
+
+ const callWithLimit = loadSubsetCalls.find(
+ (call) => call.limit !== undefined,
+ )
+ expect(callWithLimit).toBeDefined()
+ expect(callWithLimit!.limit).toBe(PAGE_SIZE + 1)
+ expect(query.hasNextPage).toBe(false)
+ expect(query.data).toHaveLength(PAGE_SIZE)
+ })
+ })
+
+ it(`should work with on-demand collection and fetch multiple pages`, () => {
+ const PAGE_SIZE = 10
+ const { collection } = createOnDemandCollection({
+ id: `on-demand-e2e-test`,
+ allPosts: createMockPosts(25),
+ autoIndex: `eager`,
+ })
+
+ cleanup = $effect.root(() => {
+ const query = useLiveInfiniteQuery(
+ (queryBuilder: InitialQueryBuilder) =>
+ queryBuilder
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: PAGE_SIZE,
+ },
+ )
+
+ flushSync()
+ expect(query.isReady).toBe(true)
+
+ expect(query.pages).toHaveLength(1)
+ expect(query.data).toHaveLength(PAGE_SIZE)
+ expect(query.hasNextPage).toBe(true)
+
+ query.fetchNextPage()
+ flushSync()
+
+ expect(query.pages).toHaveLength(2)
+ expect(query.data).toHaveLength(20)
+ expect(query.hasNextPage).toBe(true)
+
+ query.fetchNextPage()
+ flushSync()
+
+ expect(query.pages).toHaveLength(3)
+ expect(query.data).toHaveLength(25)
+ expect(query.pages[2]).toHaveLength(5)
+ expect(query.hasNextPage).toBe(false)
+ })
+ })
+
+ it(`should work with on-demand collection with async loadSubset`, async () => {
+ const PAGE_SIZE = 10
+ const { collection } = createOnDemandCollection({
+ id: `on-demand-async-test`,
+ allPosts: createMockPosts(25),
+ autoIndex: `eager`,
+ asyncDelay: 10,
+ })
+
+ const query = await new Promise((resolve) => {
+ const rootCleanup = $effect.root(() => {
+ const q = useLiveInfiniteQuery(
+ (queryBuilder: InitialQueryBuilder) =>
+ queryBuilder
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: PAGE_SIZE,
+ },
+ )
+ $effect(() => {
+ if (q.isReady && q.data.length === PAGE_SIZE) {
+ resolve(q)
+ }
+ })
+ return () => {}
+ })
+ cleanup = rootCleanup
+ })
+
+ expect(query.pages).toHaveLength(1)
+ expect(query.hasNextPage).toBe(true)
+
+ query.fetchNextPage()
+ flushSync()
+ expect(query.isFetchingNextPage).toBe(true)
+
+ await new Promise((resolve) => setTimeout(resolve, 50))
+ flushSync()
+
+ expect(query.data).toHaveLength(20)
+ expect(query.pages).toHaveLength(2)
+ expect(query.hasNextPage).toBe(true)
+
+ query.fetchNextPage()
+ flushSync()
+ await new Promise((resolve) => setTimeout(resolve, 50))
+ flushSync()
+
+ expect(query.data).toHaveLength(25)
+ expect(query.pages).toHaveLength(3)
+ expect(query.hasNextPage).toBe(false)
+ })
+
+ it(`should track isFetchingNextPage when async loading is triggered`, async () => {
+ const PAGE_SIZE = 10
+ const allPosts = createMockPosts(30)
+ const { collection } = createOnDemandCollection({
+ id: `async-loading-test-robust`,
+ allPosts,
+ asyncDelay: 50,
+ })
+
+ const query = await new Promise((resolve) => {
+ const rootCleanup = $effect.root(() => {
+ const q = useLiveInfiniteQuery(
+ (queryBuilder: InitialQueryBuilder) =>
+ queryBuilder
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: PAGE_SIZE,
+ },
+ )
+ $effect(() => {
+ if (q.isReady && !q.isFetchingNextPage) {
+ resolve(q)
+ }
+ })
+ return () => {}
+ })
+ cleanup = rootCleanup
+ })
+
+ expect(query.pages).toHaveLength(1)
+ expect(query.data).toHaveLength(PAGE_SIZE)
+
+ query.fetchNextPage()
+ // Should be fetching now
+ flushSync()
+ expect(query.isFetchingNextPage).toBe(true)
+
+ // Wait for loadSubset (50ms) + buffer
+ await new Promise((resolve) => setTimeout(resolve, 150))
+ flushSync()
+
+ expect(query.isFetchingNextPage).toBe(false)
+ expect(query.pages).toHaveLength(2)
+ expect(query.data).toHaveLength(20)
+ })
+
+ describe(`pre-created collections`, () => {
+ 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: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`)
+ .limit(10)
+ .offset(0),
+ })
+
+ await liveQueryCollection.preload()
+
+ cleanup = $effect.root(() => {
+ const query = useLiveInfiniteQuery(liveQueryCollection, {
+ pageSize: 10,
+ })
+
+ flushSync()
+
+ expect(query.isReady).toBe(true)
+ expect(query.pages).toHaveLength(1)
+ expect(query.hasNextPage).toBe(true)
+
+ query.fetchNextPage()
+ flushSync()
+
+ expect(query.pages).toHaveLength(2)
+ expect(query.pages[0]).toHaveLength(10)
+ expect(query.pages[1]).toHaveLength(10)
+ expect(query.data).toHaveLength(20)
+ expect(query.hasNextPage).toBe(true)
+ })
+ })
+
+ it(`should reset pagination when collection instance changes`, () => {
+ 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: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection1 })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`)
+ .limit(10)
+ .offset(0),
+ })
+
+ 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: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection2 })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`)
+ .limit(10)
+ .offset(0),
+ })
+
+ cleanup = $effect.root(() => {
+ let coll = $state(liveQueryCollection1)
+
+ const query = useLiveInfiniteQuery(() => coll, {
+ pageSize: 10,
+ })
+
+ flushSync()
+
+ expect(query.isReady).toBe(true)
+
+ query.fetchNextPage()
+ flushSync()
+
+ expect(query.pages).toHaveLength(2)
+ expect(query.data).toHaveLength(20)
+
+ coll = liveQueryCollection2
+ flushSync()
+
+ expect(query.pages).toHaveLength(1)
+ expect(query.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,
+ }),
+ )
+
+ const liveQueryCollection = createLiveQueryCollection({
+ query: (q: InitialQueryBuilder) => q.from({ posts: collection }),
+ })
+
+ await liveQueryCollection.preload()
+
+ expect(() => {
+ $effect.root(() => {
+ useLiveInfiniteQuery(liveQueryCollection, {
+ pageSize: 10,
+ })
+ flushSync()
+ })
+ }).toThrow(/orderBy/)
+ })
+
+ it(`should throw error if first argument is not a collection or function`, () => {
+ expect(() => {
+ $effect.root(() => {
+ useLiveInfiniteQuery(`not a collection or function` as any, {
+ pageSize: 10,
+ })
+ flushSync()
+ })
+ }).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: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`)
+ .limit(5)
+ .offset(0),
+ })
+
+ await liveQueryCollection.preload()
+
+ cleanup = $effect.root(() => {
+ const query = useLiveInfiniteQuery(liveQueryCollection, {
+ pageSize: 10,
+ })
+
+ flushSync()
+
+ expect(query.isReady).toBe(true)
+ expect(query.pages).toHaveLength(1)
+ expect(query.pages[0]).toHaveLength(10)
+ expect(query.data).toHaveLength(10)
+ expect(query.hasNextPage).toBe(true)
+ })
+ })
+
+ 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: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`)
+ .limit(10)
+ .offset(0),
+ })
+
+ await liveQueryCollection.preload()
+
+ cleanup = $effect.root(() => {
+ const query = useLiveInfiniteQuery(liveQueryCollection, {
+ pageSize: 10,
+ })
+
+ flushSync()
+
+ expect(query.isReady).toBe(true)
+
+ query.fetchNextPage()
+ flushSync()
+
+ expect(query.pages).toHaveLength(2)
+ expect(query.data).toHaveLength(20)
+
+ collection.utils.begin()
+ collection.utils.write({
+ type: `insert`,
+ value: {
+ id: `new-1`,
+ title: `New Post`,
+ content: `New Content`,
+ createdAt: 1000001,
+ category: `tech`,
+ },
+ })
+ collection.utils.commit()
+
+ flushSync()
+
+ expect(query.pages[0]![0]).toMatchObject({
+ id: `new-1`,
+ title: `New Post`,
+ })
+
+ expect(query.pages).toHaveLength(2)
+ expect(query.data).toHaveLength(20)
+ })
+ })
+
+ it(`should maintain reactivity when destructuring return values with $derived`, () => {
+ const posts = createMockPosts(20)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `destructure-reactivity-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ cleanup = $effect.root(() => {
+ const query = useLiveInfiniteQuery(
+ (q: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 5,
+ },
+ )
+
+ // Destructure with $derived
+ const { data, hasNextPage, fetchNextPage } = $derived(query)
+
+ flushSync()
+
+ expect(data).toHaveLength(5)
+ expect(hasNextPage).toBe(true)
+
+ fetchNextPage()
+ flushSync()
+
+ // Should be reactive
+ expect(data).toHaveLength(10)
+ })
+ })
+
+ it(`should react to dynamic pageSize changes`, () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `dynamic-pagesize-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ cleanup = $effect.root(() => {
+ let pageSize = $state(5)
+ const query = useLiveInfiniteQuery(
+ (q: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ get pageSize() {
+ return pageSize
+ },
+ },
+ )
+
+ flushSync()
+ expect(query.pages[0]).toHaveLength(5)
+ expect(query.data).toHaveLength(5)
+
+ // Change pageSize reactively
+ pageSize = 10
+ flushSync()
+
+ expect(query.pages[0]).toHaveLength(10)
+ expect(query.data).toHaveLength(10)
+ })
+ })
+
+ it(`should handle cleanup of infinite query`, () => {
+ const posts = createMockPosts(10)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `cleanup-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ let query: any
+ const rootCleanup = $effect.root(() => {
+ query = useLiveInfiniteQuery(
+ (q: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 5,
+ },
+ )
+ return () => {}
+ })
+
+ flushSync()
+ expect(query.isCleanedUp).toBe(false)
+
+ rootCleanup()
+ flushSync()
+
+ expect(query.isCleanedUp).toBe(true)
+ })
+
+ 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,
+ }),
+ )
+
+ const loaderQuery = createLiveQueryCollection({
+ query: (q: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`)
+ .limit(20),
+ })
+
+ await loaderQuery.preload()
+
+ cleanup = $effect.root(() => {
+ const query = useLiveInfiniteQuery(loaderQuery, {
+ pageSize: 20,
+ })
+
+ flushSync()
+
+ expect(query.isReady).toBe(true)
+ expect(query.pages).toHaveLength(1)
+ expect(query.pages[0]).toHaveLength(20)
+ expect(query.data).toHaveLength(20)
+ expect(query.hasNextPage).toBe(true)
+
+ query.fetchNextPage()
+ flushSync()
+
+ expect(query.pages).toHaveLength(2)
+ expect(query.data).toHaveLength(40)
+ })
+ })
+ })
+
+ it(`should maintain peek-ahead limit consistency when fetching subsequent pages`, () => {
+ const PAGE_SIZE = 5
+ const allPosts = createMockPosts(20)
+ const loadSubsetCalls: Array = []
+
+ const collection = createCollection({
+ id: `peek-ahead-consistency`,
+ getKey: (p) => p.id,
+ syncMode: `on-demand`,
+ startSync: true,
+ defaultIndexType: BTreeIndex,
+ sync: {
+ sync: ({ markReady, begin, write, commit }) => {
+ markReady()
+ return {
+ loadSubset: (opts: LoadSubsetOptions) => {
+ loadSubsetCalls.push({ ...opts })
+ // Page-based calculation similar to what a user might do
+ const limit = opts.limit!
+ // Use PAGE_SIZE for page calculation
+ const page = Math.floor(opts.offset! / PAGE_SIZE) + 1
+
+ // Backend behavior: returns items for the requested page using the PROVIDED limit as page size
+ // This is common in APIs that use the limit parameter to define the page size for that request.
+ const start = (page - 1) * limit
+ const filtered = allPosts.slice(start, start + limit)
+
+ begin()
+ for (const post of filtered) {
+ write({ type: `insert`, value: post })
+ }
+ commit()
+ return true
+ },
+ }
+ },
+ },
+ })
+
+ collection.createIndex((p) => p.createdAt, { indexType: BTreeIndex })
+
+ cleanup = $effect.root(() => {
+ const query = useLiveInfiniteQuery(
+ (q: InitialQueryBuilder) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: PAGE_SIZE,
+ },
+ )
+
+ flushSync()
+ expect(query.hasNextPage).toBe(true)
+ expect(query.data).toHaveLength(5)
+
+ // First call should have limit 6 (pageSize + 1)
+ expect(loadSubsetCalls[0]!.limit).toBe(6)
+
+ query.fetchNextPage()
+ flushSync()
+
+ // When fetching page 2, we should still request with peek-ahead.
+ // For loadedPageCount=2, we expect total limit to be 12 (2 * (pageSize + 1))
+ // CollectionSubscriber will then request (12 - currentSize) = 12 - 6 = 6 items.
+ // If we requested limit 5 here, we would only get 4 new items due to overlap,
+ // resulting in total size 10 and hasNextPage=false.
+ expect(loadSubsetCalls[1]!.limit).toBe(6)
+ expect(query.data).toHaveLength(10)
+ expect(query.hasNextPage).toBe(true)
+ })
+ })
+})