From 35b813dadf6cd033b252ea286c552fcdf94b4d19 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Wed, 12 Aug 2026 15:25:18 -0600 Subject: [PATCH 1/3] feat(svelte-db): add infinite query binding Co-authored-by: Reijhanniel Jearl Campos --- .changeset/spicy-roses-hide.md | 5 + packages/svelte-db/src/index.ts | 1 + .../src/useLiveInfiniteQuery.svelte.ts | 264 ++++++++++++++++++ .../tests/useLiveInfiniteQuery.svelte.test.ts | 183 ++++++++++++ 4 files changed, 453 insertions(+) create mode 100644 .changeset/spicy-roses-hide.md create mode 100644 packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts create mode 100644 packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts diff --git a/.changeset/spicy-roses-hide.md b/.changeset/spicy-roses-hide.md new file mode 100644 index 0000000000..4e8deea2ec --- /dev/null +++ b/.changeset/spicy-roses-hide.md @@ -0,0 +1,5 @@ +--- +'@tanstack/svelte-db': minor +--- + +Add `useLiveInfiniteQuery` as a Svelte binding over the shared live-query window controller. diff --git a/packages/svelte-db/src/index.ts b/packages/svelte-db/src/index.ts index a07f98a64c..b7a01e00ad 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 0000000000..32e6bb1d9f --- /dev/null +++ b/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts @@ -0,0 +1,264 @@ +import { + createLiveQueryCollection, + createLiveQueryWindowController, + isCollection, +} from '@tanstack/db' +import { untrack } from 'svelte' +// Type-only: used in `ReturnType` below. +import type { useLiveQuery } from './useLiveQuery.svelte.js' +import type { + Collection, + Context, + InferResultType, + InitialQueryBuilder, + LiveQueryWindowController, + LiveQueryWindowSnapshot, + NonSingleResult, + QueryBuilder, +} from '@tanstack/db' + +const DEFAULT_PAGE_SIZE = 20 +const DEFAULT_GC_TIME_MS = 1 + +type MaybeGetter = T | (() => T) + +type WindowedCollection = Collection & { + utils: { + setWindow: (options: { + offset: number + limit: number + }) => true | Promise + getWindow?: () => { offset: number; limit: number } | undefined + } +} + +type ResolvedInput = + | { kind: `collection`; collection: Collection } + | { + kind: `query` + query: (q: InitialQueryBuilder) => QueryBuilder + } + +function hasSetWindow( + collection: Collection, +): collection is WindowedCollection { + return typeof collection.utils?.setWindow === `function` +} + +function resolveInput( + input: unknown, +): ResolvedInput { + if (isCollection(input)) { + return { kind: `collection`, collection: input } + } + + if (typeof input !== `function`) { + throw new Error( + `useLiveInfiniteQuery: First argument must be either a pre-created live query collection or a query function. ` + + `Received: ${typeof input}`, + ) + } + + // A zero-argument function can be a reactive collection getter. Query + // callbacks conventionally accept the builder, so avoid probing those. + if (input.length === 0) { + try { + const collection = (input as () => unknown)() + if (isCollection(collection)) { + return { kind: `collection`, collection } + } + } catch { + // Query callbacks that close over their builder can still have arity 0. + } + } + + return { + kind: `query`, + query: input as (q: InitialQueryBuilder) => QueryBuilder, + } +} + +export type UseLiveInfiniteQueryConfig = { + pageSize?: number + initialPageParam?: number + /** + * @deprecated Pagination uses the shared controller's peek-ahead strategy. + * This remains for 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 + error: unknown +} + +type EnabledLiveQueryReturn = ReturnType< + typeof useLiveQuery +> + +/** + * Create a Svelte-native reactive view over the shared live-query window + * controller. The query must include an `orderBy` clause. + */ +export function useLiveInfiniteQuery< + TResult extends object, + TKey extends string | number, + TUtils extends Record, +>( + liveQueryCollection: MaybeGetter< + Collection & NonSingleResult + >, + config: UseLiveInfiniteQueryConfig, +): UseLiveInfiniteQueryReturn + +export function useLiveInfiniteQuery( + queryFn: (q: InitialQueryBuilder) => QueryBuilder, + config: UseLiveInfiniteQueryConfig, + deps?: Array<() => unknown>, +): UseLiveInfiniteQueryReturn + +export function useLiveInfiniteQuery( + queryFnOrCollection: unknown, + config: UseLiveInfiniteQueryConfig, + deps: Array<() => unknown> = [], +): UseLiveInfiniteQueryReturn { + let validatedCollection: Collection | null = null + + const pageSize = $derived( + config.pageSize !== undefined && config.pageSize > 0 + ? config.pageSize + : DEFAULT_PAGE_SIZE, + ) + const initialPageParam = $derived(config.initialPageParam ?? 0) + + const controller = $derived.by(() => { + for (const dependency of deps) dependency() + + const input = resolveInput(queryFnOrCollection) + let collection: Collection + + if (input.kind === `collection`) { + collection = input.collection + if (!hasSetWindow(collection)) { + throw new Error( + `useLiveInfiniteQuery: Pre-created live query collection must have an orderBy clause for infinite pagination to work. ` + + `Please add .orderBy() to your createLiveQueryCollection query.`, + ) + } + + if (validatedCollection !== collection) { + validatedCollection = collection + const currentWindow = collection.utils.getWindow?.() + const expectedLimit = pageSize + 1 + if ( + currentWindow && + (currentWindow.offset !== 0 || currentWindow.limit !== expectedLimit) + ) { + console.warn( + `useLiveInfiniteQuery: Pre-created collection has window {offset: ${currentWindow.offset}, limit: ${currentWindow.limit}} ` + + `but the hook expects {offset: 0, limit: ${expectedLimit}}. Adjusting window now.`, + ) + } + } + } else { + collection = createLiveQueryCollection({ + query: (q: InitialQueryBuilder) => + input + .query(q) + .limit(pageSize + 1) + .offset(0), + startSync: false, + gcTime: DEFAULT_GC_TIME_MS, + }) + } + + return createLiveQueryWindowController(collection, { + pageSize, + initialPageParam, + }) + }) + + let snapshot = $state.raw>( + untrack(() => controller.getSnapshot()), + ) + + $effect(() => { + const currentController: LiveQueryWindowController = controller + snapshot = currentController.getSnapshot() + const unsubscribe = currentController.subscribe(() => { + snapshot = currentController.getSnapshot() + }) + snapshot = currentController.getSnapshot() + + return () => { + unsubscribe() + currentController.dispose() + } + }) + + const fetchNextPage = () => { + void controller.fetchNextPage().catch(() => { + // Pagination errors are exposed through the controller snapshot. + }) + } + + return { + get state() { + return snapshot.state as EnabledLiveQueryReturn[`state`] + }, + get data() { + return snapshot.data as InferResultType + }, + get collection() { + return snapshot.collection as EnabledLiveQueryReturn[`collection`] + }, + get status() { + return snapshot.status as EnabledLiveQueryReturn[`status`] + }, + get isLoading() { + return snapshot.isLoading + }, + get isReady() { + return snapshot.isReady + }, + get isIdle() { + return snapshot.isIdle + }, + get isError() { + return snapshot.isError + }, + get isCleanedUp() { + return snapshot.isCleanedUp + }, + get pages() { + return snapshot.pages as Array[number]>> + }, + get pageParams() { + return snapshot.pageParams as Array + }, + get hasNextPage() { + return snapshot.hasNextPage + }, + get isFetchingNextPage() { + return snapshot.isFetchingNextPage + }, + get error() { + return snapshot.error + }, + fetchNextPage, + } +} 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 0000000000..45c9a82313 --- /dev/null +++ b/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts @@ -0,0 +1,183 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { flushSync } from 'svelte' +import { createCollection, createLiveQueryCollection, gt } from '@tanstack/db' +import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery.svelte.js' +import { mockSyncCollectionOptions } from '../../db/tests/utils' +import type { + InitialQueryBuilder, + LiveQueryCollectionUtils, +} from '@tanstack/db' + +type Post = { + id: string + title: string + createdAt: number +} + +function createPosts(count: number): Array { + return Array.from({ length: count }, (_, index) => ({ + id: String(index + 1), + title: `Post ${index + 1}`, + createdAt: count - index, + })) +} + +function createPostsCollection(id: string, count: number) { + return createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id, + getKey: (post) => post.id, + initialData: createPosts(count), + }), + ) +} + +describe(`useLiveInfiniteQuery`, () => { + let cleanup: (() => void) | undefined + + afterEach(() => { + cleanup?.() + cleanup = undefined + vi.restoreAllMocks() + }) + + it(`delegates page windows and snapshots to the shared controller`, () => { + const posts = createPostsCollection(`svelte-infinite-controller`, 12) + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts }) + .orderBy(({ posts: post }) => post.createdAt, `desc`), + { pageSize: 5, initialPageParam: 3 }, + ) + + flushSync() + + expect(query.data).toHaveLength(5) + expect(query.pages.map((page) => page.length)).toEqual([5]) + expect(query.pageParams).toEqual([3]) + expect(query.hasNextPage).toBe(true) + expect( + (query.collection.utils as LiveQueryCollectionUtils).getWindow(), + ).toEqual({ offset: 0, limit: 6 }) + + query.fetchNextPage() + flushSync() + + expect(query.data).toHaveLength(10) + expect(query.pages.map((page) => page.length)).toEqual([5, 5]) + expect(query.pageParams).toEqual([3, 4]) + expect(query.hasNextPage).toBe(true) + expect( + (query.collection.utils as LiveQueryCollectionUtils).getWindow(), + ).toEqual({ offset: 0, limit: 11 }) + + query.fetchNextPage() + flushSync() + + expect(query.data).toHaveLength(12) + expect(query.pages.map((page) => page.length)).toEqual([5, 5, 2]) + expect(query.hasNextPage).toBe(false) + }) + }) + + it(`keeps loaded pages live when rows enter or leave the window`, () => { + const posts = createPostsCollection(`svelte-infinite-live-rows`, 8) + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts }) + .orderBy(({ posts: post }) => post.createdAt, `desc`), + { pageSize: 3 }, + ) + + flushSync() + query.fetchNextPage() + flushSync() + expect(query.data.map((post) => post.id)).toEqual([ + `1`, + `2`, + `3`, + `4`, + `5`, + `6`, + ]) + + posts.utils.begin() + posts.utils.write({ + type: `insert`, + value: { id: `new`, title: `Newest`, createdAt: 100 }, + }) + posts.utils.commit() + flushSync() + + expect(query.data.map((post) => post.id)).toEqual([ + `new`, + `1`, + `2`, + `3`, + `4`, + `5`, + ]) + expect(query.pages.map((page) => page.length)).toEqual([3, 3]) + }) + }) + + it(`recreates the controller at the first page when a dependency changes`, () => { + const posts = createPostsCollection(`svelte-infinite-dependency`, 10) + + cleanup = $effect.root(() => { + let minimum = $state(0) + const query = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts }) + .where(({ posts: post }) => gt(post.createdAt, minimum)) + .orderBy(({ posts: post }) => post.createdAt, `desc`), + { pageSize: 3 }, + [() => minimum], + ) + + flushSync() + query.fetchNextPage() + flushSync() + expect(query.pages).toHaveLength(2) + + minimum = 5 + flushSync() + + expect(query.pages).toHaveLength(1) + expect(query.data.map((post) => post.createdAt)).toEqual([10, 9, 8]) + }) + }) + + it(`accepts a reactive getter for a pre-created ordered collection`, async () => { + const posts = createPostsCollection(`svelte-infinite-precreated`, 7) + const livePosts = createLiveQueryCollection({ + query: (q) => + q + .from({ posts }) + .orderBy(({ posts: post }) => post.createdAt, `desc`) + .limit(2) + .offset(1), + }) + await livePosts.preload() + const warning = vi.spyOn(console, `warn`).mockImplementation(() => {}) + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery(() => livePosts, { pageSize: 3 }) + flushSync() + + expect(query.collection).toBe(livePosts) + expect(query.data!.map((post: Post) => post.id)).toEqual([`1`, `2`, `3`]) + expect(query.hasNextPage).toBe(true) + expect(warning).toHaveBeenCalledOnce() + expect(livePosts.utils.getWindow()).toEqual({ offset: 0, limit: 4 }) + }) + }) +}) From f1c24cd96daca0b6b66db78684b8a327324076a3 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Wed, 12 Aug 2026 15:35:17 -0600 Subject: [PATCH 2/3] fix(svelte-db): preserve infinite query contracts --- .../src/useLiveInfiniteQuery.svelte.ts | 69 +++-- .../tests/useLiveInfiniteQuery.svelte.test.ts | 243 +++++++++++------- 2 files changed, 194 insertions(+), 118 deletions(-) diff --git a/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts b/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts index 32e6bb1d9f..1abe4e3567 100644 --- a/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts @@ -5,7 +5,10 @@ import { } from '@tanstack/db' import { untrack } from 'svelte' // Type-only: used in `ReturnType` below. -import type { useLiveQuery } from './useLiveQuery.svelte.js' +import type { + UseLiveQueryReturnWithCollection, + useLiveQuery, +} from './useLiveQuery.svelte.js' import type { Collection, Context, @@ -39,12 +42,28 @@ type ResolvedInput = query: (q: InitialQueryBuilder) => QueryBuilder } +type InfiniteQueryOptions = { + pageSize?: number + initialPageParam?: number +} + function hasSetWindow( collection: Collection, ): collection is WindowedCollection { return typeof collection.utils?.setWindow === `function` } +function normalizePageSize(pageSize: number | undefined): number { + if ( + pageSize === undefined || + !Number.isSafeInteger(pageSize) || + pageSize <= 0 + ) { + return DEFAULT_PAGE_SIZE + } + return pageSize +} + function resolveInput( input: unknown, ): ResolvedInput { @@ -78,21 +97,22 @@ function resolveInput( } } -export type UseLiveInfiniteQueryConfig = { - pageSize?: number - initialPageParam?: number +export type LiveInfiniteQueryConfig = InfiniteQueryOptions & { /** * @deprecated Pagination uses the shared controller's peek-ahead strategy. * This remains for compatibility with TanStack Query conventions. */ getNextPageParam?: ( - lastPage: Array[number]>, - allPages: Array[number]>>, + lastPage: Array, + allPages: Array>, lastPageParam: number, allPageParams: Array, ) => number | undefined } +export type UseLiveInfiniteQueryConfig = + LiveInfiniteQueryConfig[number]> + export type UseLiveInfiniteQueryReturn = Omit< ReturnType>, `data` @@ -100,7 +120,24 @@ export type UseLiveInfiniteQueryReturn = Omit< data: InferResultType pages: Array[number]>> pageParams: Array - fetchNextPage: () => void + fetchNextPage: () => Promise + hasNextPage: boolean + isFetchingNextPage: boolean + error: unknown +} + +export type UseLiveInfiniteQueryReturnWithCollection< + TResult extends object, + TKey extends string | number, + TUtils extends Record, +> = Omit< + UseLiveQueryReturnWithCollection>, + `data` +> & { + data: Array + pages: Array> + pageParams: Array + fetchNextPage: () => Promise hasNextPage: boolean isFetchingNextPage: boolean error: unknown @@ -122,8 +159,8 @@ export function useLiveInfiniteQuery< liveQueryCollection: MaybeGetter< Collection & NonSingleResult >, - config: UseLiveInfiniteQueryConfig, -): UseLiveInfiniteQueryReturn + config: LiveInfiniteQueryConfig, +): UseLiveInfiniteQueryReturnWithCollection export function useLiveInfiniteQuery( queryFn: (q: InitialQueryBuilder) => QueryBuilder, @@ -133,16 +170,12 @@ export function useLiveInfiniteQuery( export function useLiveInfiniteQuery( queryFnOrCollection: unknown, - config: UseLiveInfiniteQueryConfig, + config: InfiniteQueryOptions, deps: Array<() => unknown> = [], ): UseLiveInfiniteQueryReturn { let validatedCollection: Collection | null = null - const pageSize = $derived( - config.pageSize !== undefined && config.pageSize > 0 - ? config.pageSize - : DEFAULT_PAGE_SIZE, - ) + const pageSize = $derived(normalizePageSize(config.pageSize)) const initialPageParam = $derived(config.initialPageParam ?? 0) const controller = $derived.by(() => { @@ -210,11 +243,7 @@ export function useLiveInfiniteQuery( } }) - const fetchNextPage = () => { - void controller.fetchNextPage().catch(() => { - // Pagination errors are exposed through the controller snapshot. - }) - } + const fetchNextPage = () => controller.fetchNextPage() return { get state() { diff --git a/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts b/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts index 45c9a82313..46283eb720 100644 --- a/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts +++ b/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts @@ -33,6 +33,32 @@ function createPostsCollection(id: string, count: number) { ) } +function usePostsInfiniteQuery( + posts: ReturnType, + config: { pageSize?: number; initialPageParam?: number }, +) { + return useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q.from({ posts }).orderBy(({ posts: post }) => post.createdAt, `desc`), + config, + ) +} + +function useFilteredPostsInfiniteQuery( + posts: ReturnType, + minimum: () => number, +) { + return useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts }) + .where(({ posts: post }) => gt(post.createdAt, minimum())) + .orderBy(({ posts: post }) => post.createdAt, `desc`), + { pageSize: 3 }, + [minimum], + ) +} + describe(`useLiveInfiniteQuery`, () => { let cleanup: (() => void) | undefined @@ -42,120 +68,137 @@ describe(`useLiveInfiniteQuery`, () => { vi.restoreAllMocks() }) - it(`delegates page windows and snapshots to the shared controller`, () => { - const posts = createPostsCollection(`svelte-infinite-controller`, 12) - + function mountPostsQuery( + posts: ReturnType, + config: { pageSize?: number; initialPageParam?: number }, + ): ReturnType { + let query: ReturnType | undefined cleanup = $effect.root(() => { - const query = useLiveInfiniteQuery( - (q: InitialQueryBuilder) => - q - .from({ posts }) - .orderBy(({ posts: post }) => post.createdAt, `desc`), - { pageSize: 5, initialPageParam: 3 }, - ) - - flushSync() - - expect(query.data).toHaveLength(5) - expect(query.pages.map((page) => page.length)).toEqual([5]) - expect(query.pageParams).toEqual([3]) - expect(query.hasNextPage).toBe(true) - expect( - (query.collection.utils as LiveQueryCollectionUtils).getWindow(), - ).toEqual({ offset: 0, limit: 6 }) - - query.fetchNextPage() - flushSync() - - expect(query.data).toHaveLength(10) - expect(query.pages.map((page) => page.length)).toEqual([5, 5]) - expect(query.pageParams).toEqual([3, 4]) - expect(query.hasNextPage).toBe(true) - expect( - (query.collection.utils as LiveQueryCollectionUtils).getWindow(), - ).toEqual({ offset: 0, limit: 11 }) - - query.fetchNextPage() - flushSync() + query = usePostsInfiniteQuery(posts, config) + }) + flushSync() + if (!query) throw new Error(`Failed to mount infinite query`) + return query + } - expect(query.data).toHaveLength(12) - expect(query.pages.map((page) => page.length)).toEqual([5, 5, 2]) - expect(query.hasNextPage).toBe(false) + it(`delegates page windows and snapshots to the shared controller`, async () => { + const posts = createPostsCollection(`svelte-infinite-controller`, 12) + const query = mountPostsQuery(posts, { + pageSize: 5, + initialPageParam: 3, }) + + expect(query.data).toHaveLength(5) + expect(query.pages.map((page) => page.length)).toEqual([5]) + expect(query.pageParams).toEqual([3]) + expect(query.hasNextPage).toBe(true) + expect( + (query.collection.utils as LiveQueryCollectionUtils).getWindow(), + ).toEqual({ offset: 0, limit: 6 }) + + await query.fetchNextPage() + flushSync() + + expect(query.data).toHaveLength(10) + expect(query.pages.map((page) => page.length)).toEqual([5, 5]) + expect(query.pageParams).toEqual([3, 4]) + expect(query.hasNextPage).toBe(true) + expect( + (query.collection.utils as LiveQueryCollectionUtils).getWindow(), + ).toEqual({ offset: 0, limit: 11 }) + + await query.fetchNextPage() + flushSync() + + expect(query.data).toHaveLength(12) + expect(query.pages.map((page) => page.length)).toEqual([5, 5, 2]) + expect(query.hasNextPage).toBe(false) }) - it(`keeps loaded pages live when rows enter or leave the window`, () => { + it(`keeps loaded pages live when rows enter or leave the window`, async () => { const posts = createPostsCollection(`svelte-infinite-live-rows`, 8) - - cleanup = $effect.root(() => { - const query = useLiveInfiniteQuery( - (q: InitialQueryBuilder) => - q - .from({ posts }) - .orderBy(({ posts: post }) => post.createdAt, `desc`), - { pageSize: 3 }, - ) - - flushSync() - query.fetchNextPage() - flushSync() - expect(query.data.map((post) => post.id)).toEqual([ - `1`, - `2`, - `3`, - `4`, - `5`, - `6`, - ]) - - posts.utils.begin() - posts.utils.write({ - type: `insert`, - value: { id: `new`, title: `Newest`, createdAt: 100 }, - }) - posts.utils.commit() - flushSync() - - expect(query.data.map((post) => post.id)).toEqual([ - `new`, - `1`, - `2`, - `3`, - `4`, - `5`, - ]) - expect(query.pages.map((page) => page.length)).toEqual([3, 3]) + const query = mountPostsQuery(posts, { pageSize: 3 }) + + await query.fetchNextPage() + flushSync() + expect(query.data.map((post) => post.id)).toEqual([ + `1`, + `2`, + `3`, + `4`, + `5`, + `6`, + ]) + + posts.utils.begin() + posts.utils.write({ + type: `insert`, + value: { id: `new`, title: `Newest`, createdAt: 100 }, }) + posts.utils.commit() + flushSync() + + expect(query.data.map((post) => post.id)).toEqual([ + `new`, + `1`, + `2`, + `3`, + `4`, + `5`, + ]) + expect(query.pages.map((page) => page.length)).toEqual([3, 3]) }) - it(`recreates the controller at the first page when a dependency changes`, () => { + it(`recreates the controller at the first page when a dependency changes`, async () => { const posts = createPostsCollection(`svelte-infinite-dependency`, 10) + let query: ReturnType | undefined + let setMinimum: ((value: number) => void) | undefined cleanup = $effect.root(() => { let minimum = $state(0) - const query = useLiveInfiniteQuery( - (q: InitialQueryBuilder) => - q - .from({ posts }) - .where(({ posts: post }) => gt(post.createdAt, minimum)) - .orderBy(({ posts: post }) => post.createdAt, `desc`), - { pageSize: 3 }, - [() => minimum], - ) + query = useFilteredPostsInfiniteQuery(posts, () => minimum) + setMinimum = (value) => { + minimum = value + } + }) + flushSync() + if (!query || !setMinimum) throw new Error(`Failed to mount infinite query`) - flushSync() - query.fetchNextPage() - flushSync() - expect(query.pages).toHaveLength(2) + await query.fetchNextPage() + flushSync() + expect(query.pages).toHaveLength(2) - minimum = 5 - flushSync() + setMinimum(5) + flushSync() - expect(query.pages).toHaveLength(1) - expect(query.data.map((post) => post.createdAt)).toEqual([10, 9, 8]) - }) + expect(query.pages).toHaveLength(1) + expect(query.data.map((post) => post.createdAt)).toEqual([10, 9, 8]) }) + it.each([ + { label: `empty`, count: 0, pageSize: 5, pageLengths: [0], limit: 6 }, + { label: `single row`, count: 1, pageSize: 5, pageLengths: [1], limit: 6 }, + { + label: `zero page size`, + count: 1, + pageSize: 0, + pageLengths: [1], + limit: 21, + }, + ])( + `handles $label pagination boundaries`, + ({ label, count, pageSize, pageLengths, limit }) => { + const posts = createPostsCollection(`svelte-infinite-${label}`, count) + const query = mountPostsQuery(posts, { pageSize }) + + expect(query.pages.map((page) => page.length)).toEqual(pageLengths) + expect(query.hasNextPage).toBe(false) + expect( + (query.collection.utils as LiveQueryCollectionUtils).getWindow(), + ).toEqual({ offset: 0, limit }) + }, + ) + it(`accepts a reactive getter for a pre-created ordered collection`, async () => { const posts = createPostsCollection(`svelte-infinite-precreated`, 7) const livePosts = createLiveQueryCollection({ @@ -170,11 +213,15 @@ describe(`useLiveInfiniteQuery`, () => { const warning = vi.spyOn(console, `warn`).mockImplementation(() => {}) cleanup = $effect.root(() => { - const query = useLiveInfiniteQuery(() => livePosts, { pageSize: 3 }) + const query = useLiveInfiniteQuery(() => livePosts, { + pageSize: 3, + getNextPageParam: (lastPage) => lastPage[0]?.createdAt, + }) flushSync() expect(query.collection).toBe(livePosts) - expect(query.data!.map((post: Post) => post.id)).toEqual([`1`, `2`, `3`]) + expect(query.data.map((post) => post.id)).toEqual([`1`, `2`, `3`]) + expect(query.state.get(`1`)?.title).toBe(`Post 1`) expect(query.hasNextPage).toBe(true) expect(warning).toHaveBeenCalledOnce() expect(livePosts.utils.getWindow()).toEqual({ offset: 0, limit: 4 }) From 6d3395bfd3e551d1394853d7acd74e5c4799089e Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Wed, 12 Aug 2026 15:41:15 -0600 Subject: [PATCH 3/3] refactor(svelte-db): tighten infinite query internals --- .../src/useLiveInfiniteQuery.svelte.ts | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts b/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts index 1abe4e3567..c3aa60c94c 100644 --- a/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts @@ -14,10 +14,9 @@ import type { Context, InferResultType, InitialQueryBuilder, - LiveQueryWindowController, - LiveQueryWindowSnapshot, NonSingleResult, QueryBuilder, + UtilsRecord, } from '@tanstack/db' const DEFAULT_PAGE_SIZE = 20 @@ -25,7 +24,9 @@ const DEFAULT_GC_TIME_MS = 1 type MaybeGetter = T | (() => T) -type WindowedCollection = Collection & { +type InternalCollection = Collection + +type WindowedCollection = InternalCollection & { utils: { setWindow: (options: { offset: number @@ -36,7 +37,7 @@ type WindowedCollection = Collection & { } type ResolvedInput = - | { kind: `collection`; collection: Collection } + | { kind: `collection`; collection: InternalCollection } | { kind: `query` query: (q: InitialQueryBuilder) => QueryBuilder @@ -48,9 +49,9 @@ type InfiniteQueryOptions = { } function hasSetWindow( - collection: Collection, + collection: InternalCollection, ): collection is WindowedCollection { - return typeof collection.utils?.setWindow === `function` + return typeof collection.utils.setWindow === `function` } function normalizePageSize(pageSize: number | undefined): number { @@ -173,7 +174,7 @@ export function useLiveInfiniteQuery( config: InfiniteQueryOptions, deps: Array<() => unknown> = [], ): UseLiveInfiniteQueryReturn { - let validatedCollection: Collection | null = null + let validatedCollection: InternalCollection | null = null const pageSize = $derived(normalizePageSize(config.pageSize)) const initialPageParam = $derived(config.initialPageParam ?? 0) @@ -182,10 +183,8 @@ export function useLiveInfiniteQuery( for (const dependency of deps) dependency() const input = resolveInput(queryFnOrCollection) - let collection: Collection - if (input.kind === `collection`) { - collection = input.collection + const collection = input.collection if (!hasSetWindow(collection)) { throw new Error( `useLiveInfiniteQuery: Pre-created live query collection must have an orderBy clause for infinite pagination to work. ` + @@ -207,30 +206,31 @@ export function useLiveInfiniteQuery( ) } } - } else { - collection = createLiveQueryCollection({ - query: (q: InitialQueryBuilder) => - input - .query(q) - .limit(pageSize + 1) - .offset(0), - startSync: false, - gcTime: DEFAULT_GC_TIME_MS, + return createLiveQueryWindowController(collection, { + pageSize, + initialPageParam, }) } + const collection = createLiveQueryCollection({ + query: (q: InitialQueryBuilder) => + input + .query(q) + .limit(pageSize + 1) + .offset(0), + startSync: false, + gcTime: DEFAULT_GC_TIME_MS, + }) return createLiveQueryWindowController(collection, { pageSize, initialPageParam, }) }) - let snapshot = $state.raw>( - untrack(() => controller.getSnapshot()), - ) + let snapshot = $state.raw(untrack(() => controller.getSnapshot())) $effect(() => { - const currentController: LiveQueryWindowController = controller + const currentController = controller snapshot = currentController.getSnapshot() const unsubscribe = currentController.subscribe(() => { snapshot = currentController.getSnapshot()