diff --git a/packages/vue-db/src/index.ts b/packages/vue-db/src/index.ts index 681078b9c..901699306 100644 --- a/packages/vue-db/src/index.ts +++ b/packages/vue-db/src/index.ts @@ -1,5 +1,6 @@ // Re-export all public APIs export * from './useLiveQuery' +export * from './useLiveInfiniteQuery' // Re-export everything from @tanstack/db export * from '@tanstack/db' diff --git a/packages/vue-db/src/useLiveInfiniteQuery.ts b/packages/vue-db/src/useLiveInfiniteQuery.ts new file mode 100644 index 000000000..a22efb0d8 --- /dev/null +++ b/packages/vue-db/src/useLiveInfiniteQuery.ts @@ -0,0 +1,289 @@ +import { computed, ref, toValue, unref, watch, watchEffect } from 'vue' +import { BaseQueryBuilder, CollectionImpl } from '@tanstack/db' +import { useLiveQuery } from './useLiveQuery' +import type { + Collection, + Context, + InferResultType, + InitialQueryBuilder, + LiveQueryCollectionUtils, + NonSingleResult, + QueryBuilder, +} from '@tanstack/db' +import type { ComputedRef, MaybeRefOrGetter } from 'vue' +import type { UseLiveQueryReturn } from './useLiveQuery' + +/** + * 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` +} + +export type UseLiveInfiniteQueryConfig = { + pageSize?: number + initialPageParam?: number + getNextPageParam: ( + lastPage: Array[number]>, + allPages: Array[number]>>, + lastPageParam: number, + allPageParams: Array, + ) => number | undefined +} + +export type UseLiveInfiniteQueryReturn = Omit< + UseLiveQueryReturn[number]>, + `data` +> & { + data: ComputedRef> + pages: ComputedRef[number]>>> + pageParams: ComputedRef> + fetchNextPage: () => void + hasNextPage: ComputedRef + isFetchingNextPage: ComputedRef +} + +// Overload for pre-created collection (non-single result) +export function useLiveInfiniteQuery< + TResult extends object, + TKey extends string | number, + TUtils extends Record, +>( + liveQueryCollection: + | (Collection & NonSingleResult) + | MaybeRefOrGetter & NonSingleResult>, + config: UseLiveInfiniteQueryConfig, +): UseLiveInfiniteQueryReturn + +// Overload for query function +export function useLiveInfiniteQuery( + queryFn: (q: InitialQueryBuilder) => QueryBuilder, + config: UseLiveInfiniteQueryConfig, + deps?: Array>, +): UseLiveInfiniteQueryReturn + +// Implementation +export function useLiveInfiniteQuery( + queryFnOrCollection: any, + config: UseLiveInfiniteQueryConfig, + deps: Array> = [], +): UseLiveInfiniteQueryReturn { + const pageSize = config.pageSize || 20 + const initialPageParam = config.initialPageParam ?? 0 + + // Track how many pages have been loaded + const loadedPageCount = ref(1) + const isFetchingNextPage = ref(false) + + // Detect if input is a collection or query function (reactive check) + const isCollectionCheck = (val: any) => + val instanceof CollectionImpl || + (val && + typeof val === `object` && + typeof val.subscribeChanges === `function`) + + // Safely resolve the input to useLiveQuery + const queryInput = computed(() => { + const raw = unref(queryFnOrCollection) + + // Check if it's already a collection + if (isCollectionCheck(raw)) { + return raw + } + + // Handle function case + if (typeof raw === `function`) { + // Check if it's a getter that returns a collection + // (Heuristic: length 0 implies getter, though strictly not guaranteed) + if (raw.length === 0) { + try { + // Probe the function + const res = raw() + if (isCollectionCheck(res)) { + return res + } + // If not a collection, fall through to treat as query function (or getter for query function) + } catch { + // Ignore errors, assume it requires args (e.g. strict checks) + } + } + + // Try to probe with a dummy builder to see if it returns a Collection directly + // This handles (q) => Collection case which useLiveQuery doesn't support natively in Vue + try { + const dummyBuilder = new BaseQueryBuilder() as InitialQueryBuilder + const res = raw(dummyBuilder) + if (isCollectionCheck(res)) { + return res + } + } catch { + // Ignore errors, assume it returns a builder that needs real execution + } + + // It's a query function (or assumed one). Wrap it to apply limit/offset. + return (q: InitialQueryBuilder) => { + const res = raw(q) + + // Handle case where function returns a Collection directly + if (isCollectionCheck(res)) { + return res + } + + // Apply limit/offset to QueryBuilder + if (res && typeof res.limit === `function`) { + return res.limit(pageSize).offset(0) + } + + return res + } + } + + return raw + }) + + + // Reset pagination when inputs change + watch( + [ + () => unref(queryFnOrCollection), + ...deps.map((d) => () => toValue(d)), + ], + ([newVal], [oldVal]) => { + // If collection instance changed + if (isCollectionCheck(newVal) && newVal !== oldVal) { + loadedPageCount.value = 1 + return + } + + // If it's a query function, any dependency change should reset + // (The watch source includes deps, so this callback fires on dep changes) + if (!isCollectionCheck(newVal)) { + loadedPageCount.value = 1 + } + }, + ) + + // Create a live query with initial limit and offset + const queryResult = useLiveQuery(queryInput as any, deps) + + // Adjust window when pagination changes + watchEffect(async () => { + const utils = queryResult.collection.value.utils + const currentLoadedCount = loadedPageCount.value + const expectedOffset = 0 + const expectedLimit = currentLoadedCount * pageSize + 1 // +1 for peek ahead + + // Check if collection has orderBy (required for setWindow) + if (!isLiveQueryCollectionUtils(utils)) { + // For pre-created collections, we should warn or error. + const unwrapped = unref(queryFnOrCollection) + // Check unwrapped or queryInput value + if (isCollectionCheck(unwrapped) || isCollectionCheck(queryInput.value)) { + // Only throw if we are sure it is a collection and not a query function being set up + 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 + } + + // Checking if window needs adjustment + const currentWindow = utils.getWindow() + if ( + currentWindow && + currentWindow.offset === expectedOffset && + currentWindow.limit === expectedLimit + ) { + return + } + + // Adjust the window + let result: true | Promise + try { + result = utils.setWindow({ + offset: expectedOffset, + limit: expectedLimit, + }) + } catch (err) { + // If setWindow fails (e.g. missing orderBy), we should probably rethrow or match React behavior + // React throws "Pre-created live query collection must have an orderBy..." + // We can throw the friendlier error here if it's the specific error + 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. Original error: ${err}`, + ) + } + + if (result !== true) { + isFetchingNextPage.value = true + try { + await result + } finally { + isFetchingNextPage.value = false + } + } else { + isFetchingNextPage.value = false + } + }) + + // Split the data array into pages and determine if there's a next page + const computedData = computed(() => { + const dataArray = ( + Array.isArray(queryResult.data.value) ? queryResult.data.value : [] + ) as InferResultType + const totalItemsRequested = loadedPageCount.value * pageSize + + // Check if we have more data than requested (the peek ahead item) + const hasMore = dataArray.length > totalItemsRequested + + // Build pages array (without the peek ahead item) + const pagesResult: Array[number]>> = [] + const pageParamsResult: Array = [] + + for (let i = 0; i < loadedPageCount.value; i++) { + const pageData = dataArray.slice(i * pageSize, (i + 1) * pageSize) + // Only push if there is data (handle case where data might be less than expected due to deletion/filter) + // Actually strictly following React impl: + pagesResult.push(pageData) + pageParamsResult.push(initialPageParam + i) + } + + // Flatten the pages for the data return (without peek ahead item) + const flatDataResult = dataArray.slice( + 0, + totalItemsRequested, + ) as InferResultType + + return { + pages: pagesResult, + pageParams: pageParamsResult, + hasNextPage: hasMore, + flatData: flatDataResult, + } + }) + + const pages = computed(() => computedData.value.pages) + const pageParams = computed(() => computedData.value.pageParams) + const hasNextPage = computed(() => computedData.value.hasNextPage) + const data = computed(() => computedData.value.flatData) + + // Fetch next page + const fetchNextPage = () => { + if (!hasNextPage.value || isFetchingNextPage.value) return + + loadedPageCount.value += 1 + } + + return { + ...queryResult, + data, + pages, + pageParams, + fetchNextPage, + hasNextPage, + isFetchingNextPage: computed(() => isFetchingNextPage.value), + } as UseLiveInfiniteQueryReturn +} diff --git a/packages/vue-db/tests/useLiveInfiniteQuery.test.ts b/packages/vue-db/tests/useLiveInfiniteQuery.test.ts new file mode 100644 index 000000000..49fc48ae0 --- /dev/null +++ b/packages/vue-db/tests/useLiveInfiniteQuery.test.ts @@ -0,0 +1,630 @@ +import { describe, expect, it } from 'vitest' +import { createCollection, createLiveQueryCollection, eq } from '@tanstack/db' +import { createApp, defineComponent, h, nextTick, onErrorCaptured, ref, shallowRef } from 'vue' +import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' +import { mockSyncCollectionOptions } from '../../db/tests/utils' +import type { InitialQueryBuilder } from '@tanstack/db' + +describe(`useLiveInfiniteQuery`, () => { + + +type Post = { + id: string + title: string + content: string + createdAt: number + category: string +} + +const 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 +} + +// Helper function to wait for Vue reactivity +async function waitForVueUpdate() { + await nextTick() + // Additional small delay to ensure collection updates are processed + await new Promise((resolve) => setTimeout(resolve, 50)) +} + + + + it(`should fetch initial page of data`, async () => { + const posts = createMockPosts(50) + const collection = createCollection( + mockSyncCollectionOptions({ + id: `initial-page-test-vue`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + const { pages, data, hasNextPage, isReady } = 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, + getNextPageParam: (lastPage) => + lastPage.length === 10 ? lastPage.length : undefined, + }, + ) + + await waitForVueUpdate() + + expect(isReady.value).toBe(true) + + // Should have 1 page initially + expect(pages.value).toHaveLength(1) + expect(pages.value[0]).toHaveLength(10) + + // Data should be flattened + expect(data.value).toHaveLength(10) + + // Should have next page since we have 50 items total + expect(hasNextPage.value).toBe(true) + + // First item should be Post 1 (most recent by createdAt) + expect(pages.value[0]![0]).toMatchObject({ + id: `1`, + title: `Post 1`, + }) + }) + + it(`should fetch multiple pages`, async () => { + const posts = createMockPosts(50) + const collection = createCollection( + mockSyncCollectionOptions({ + id: `multiple-pages-test-vue`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + const { pages, data, hasNextPage, fetchNextPage, isReady } = + useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: 10, + getNextPageParam: (lastPage) => + lastPage.length === 10 ? lastPage.length : undefined, + }, + ) + + await waitForVueUpdate() + + expect(isReady.value).toBe(true) + + // Initially 1 page + expect(pages.value).toHaveLength(1) + expect(hasNextPage.value).toBe(true) + + // Fetch next page + fetchNextPage() + + await waitForVueUpdate() + // Need a bit more time for the async setWindow to complete and trigger reactivity + await waitForVueUpdate() + + expect(pages.value).toHaveLength(2) + expect(pages.value[0]).toHaveLength(10) + expect(pages.value[1]).toHaveLength(10) + expect(data.value).toHaveLength(20) + expect(hasNextPage.value).toBe(true) + + // Fetch another page + fetchNextPage() + + await waitForVueUpdate() + await waitForVueUpdate() + + expect(pages.value).toHaveLength(3) + expect(data.value).toHaveLength(30) + expect(hasNextPage.value).toBe(true) + }) + + it(`should detect when no more pages available`, async () => { + const posts = createMockPosts(25) + const collection = createCollection( + mockSyncCollectionOptions({ + id: `no-more-pages-test-vue`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + const { pages, data, hasNextPage, fetchNextPage, isReady } = + useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: 10, + getNextPageParam: (lastPage) => + lastPage.length === 10 ? lastPage.length : undefined, + }, + ) + + await waitForVueUpdate() + expect(isReady.value).toBe(true) + + // Page 1: 10 items, has more + expect(pages.value).toHaveLength(1) + expect(hasNextPage.value).toBe(true) + + // Fetch page 2 + fetchNextPage() + await waitForVueUpdate() + await waitForVueUpdate() + + expect(pages.value).toHaveLength(2) + expect(pages.value[1]).toHaveLength(10) + expect(hasNextPage.value).toBe(true) + + // Fetch page 3 + fetchNextPage() + await waitForVueUpdate() + await waitForVueUpdate() + + expect(pages.value).toHaveLength(3) + // Page 3: 5 items, no more + expect(pages.value[2]).toHaveLength(5) + expect(data.value).toHaveLength(25) + expect(hasNextPage.value).toBe(false) + }) + + it(`should update pages when underlying data changes`, async () => { + const posts = createMockPosts(30) + const collection = createCollection( + mockSyncCollectionOptions({ + id: `live-updates-test-vue`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + const { pages, data, fetchNextPage, isReady } = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: 10, + getNextPageParam: (lastPage) => + lastPage.length === 10 ? lastPage.length : undefined, + }, + ) + + await waitForVueUpdate() + expect(isReady.value).toBe(true) + + // Fetch 2 pages + fetchNextPage() + await waitForVueUpdate() + await waitForVueUpdate() + + expect(pages.value).toHaveLength(2) + expect(data.value).toHaveLength(20) + + // Insert a new post with most recent timestamp + 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 waitForVueUpdate() + + // New post should be first AND structure should be maintained + expect(pages.value[0]![0]).toMatchObject({ + id: `new-1`, + title: `New Post`, + }) + + // Still showing 2 pages (20 items), but content has shifted + expect(pages.value).toHaveLength(2) + expect(data.value).toHaveLength(20) + expect(pages.value[0]).toHaveLength(10) + expect(pages.value[1]).toHaveLength(10) + }) + + it(`should re-execute query when dependencies change`, async () => { + const posts = createMockPosts(50) + const collection = createCollection( + mockSyncCollectionOptions({ + id: `deps-change-test-vue`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + const category = ref(`tech`) + + const { pages, fetchNextPage, isReady } = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .where(({ posts: p }) => eq(p.category, category.value)) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: 5, + getNextPageParam: (lastPage) => + lastPage.length === 5 ? lastPage.length : undefined, + }, + [category], + ) + + await waitForVueUpdate() + expect(isReady.value).toBe(true) + + // Fetch 2 pages of tech posts + fetchNextPage() + await waitForVueUpdate() + await waitForVueUpdate() + + expect(pages.value).toHaveLength(2) + + // Change category to life + category.value = `life` + await waitForVueUpdate() + await waitForVueUpdate() + + // Should reset to 1 page with life posts + expect(pages.value).toHaveLength(1) + + // All items should be life category + pages.value[0]!.forEach((post) => { + expect(post.category).toBe(`life`) + }) + }) + + it(`should accept pre-created live query collection`, async () => { + const posts = createMockPosts(50) + const collection = createCollection( + mockSyncCollectionOptions({ + id: `pre-created-collection-test-infinite-vue`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + // Create a live query collection beforehand + const liveQueryCollection = createLiveQueryCollection({ + query: (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + startSync: true, + }) + + const { pages, data, hasNextPage, isReady } = useLiveInfiniteQuery( + liveQueryCollection, + { + pageSize: 10, + getNextPageParam: (lastPage) => + lastPage.length === 10 ? lastPage.length : undefined, + }, + ) + + await waitForVueUpdate() + + expect(isReady.value).toBe(true) + expect(pages.value).toHaveLength(1) + expect(data.value).toHaveLength(10) + expect(hasNextPage.value).toBe(true) + }) + it(`should handle getter returning a collection`, async () => { + const posts = createMockPosts(20) + const collection = createCollection( + mockSyncCollectionOptions({ + id: `getter-collection-test-vue`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + const liveQueryCollection = createLiveQueryCollection({ + query: (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + startSync: true, + }) + + // Pass a getter function that returns the collection + const { pages, isReady } = useLiveInfiniteQuery(() => liveQueryCollection, { + pageSize: 10, + getNextPageParam: (lastPage) => + lastPage.length === 10 ? lastPage.length : undefined, + }) + + await waitForVueUpdate() + + expect(isReady.value).toBe(true) + expect(pages.value).toHaveLength(1) + }) + + it(`should handle query function returning a collection`, async () => { + const posts = createMockPosts(20) + const collection = createCollection( + mockSyncCollectionOptions({ + id: `fn-returning-collection-test-vue`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + const liveQueryCollection = createLiveQueryCollection({ + query: (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + startSync: true, + }) + + // Pass a function that accepts 'q' but returns a collection + const { pages, isReady } = useLiveInfiniteQuery( + (() => liveQueryCollection) as any, + { + pageSize: 10, + getNextPageParam: (lastPage) => + lastPage.length === 10 ? lastPage.length : undefined, + }, + ) + + await waitForVueUpdate() + + expect(isReady.value).toBe(true) + expect(pages.value).toHaveLength(1) + }) + + it(`should throw when pre-created collection is missing orderBy`, () => { + return new Promise((resolve, reject) => { + const posts = createMockPosts(10) + const collection = createCollection( + mockSyncCollectionOptions({ + id: `missing-orderby-captured`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + const liveQueryCollection = createLiveQueryCollection({ + query: (q: InitialQueryBuilder) => q.from({ posts: collection }), + startSync: true + }) + + const ChildComp = defineComponent({ + setup() { + useLiveInfiniteQuery(liveQueryCollection, { + pageSize: 5, + getNextPageParam: () => undefined + }) + return () => h('div') + } + }) + + const TestComp = defineComponent({ + setup() { + onErrorCaptured((err) => { + try { + expect(err.message).toContain('orderBy') + resolve() + } catch (e) { + reject(e) + } + return false + }) + + return () => h(ChildComp) + } + }) + + const div = document.createElement('div') + const app = createApp(TestComp) + app.mount(div) + }) + }) + + it(`should throw when passing a raw Collection directly`, () => { + return new Promise((resolve, reject) => { + const posts = createMockPosts(10) + const collection = createCollection( + mockSyncCollectionOptions({ + id: `raw-collection-error`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + const ChildComp = defineComponent({ + setup() { + useLiveInfiniteQuery(collection, { // Passing raw collection, not LiveQueryCollection + pageSize: 5, + getNextPageParam: () => undefined + }) + return () => h('div') + } + }) + + const TestComp = defineComponent({ + setup() { + onErrorCaptured((err) => { + try { + expect(err.message).toContain('orderBy') + resolve() + } catch (e) { + reject(e) + } + return false + }) + + return () => h(ChildComp) + } + }) + + const div = document.createElement('div') + const app = createApp(TestComp) + app.mount(div) + }) + }) + + + + it(`should reset pagination when input changes`, async () => { + const posts = createMockPosts(50) + const collection = createCollection( + mockSyncCollectionOptions({ + id: `reset-pagination-test-vue`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + const category = ref(`tech`) + const { pages, fetchNextPage, isReady } = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .where(({ posts: p }) => eq(p.category, category.value)) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: 5, + getNextPageParam: (lastPage) => + lastPage.length === 5 ? lastPage.length : undefined, + }, + [category], + ) + + await waitForVueUpdate() + expect(isReady.value).toBe(true) + + fetchNextPage() + await waitForVueUpdate() + expect(pages.value).toHaveLength(2) + + // Change input + category.value = `life` + await waitForVueUpdate() + + // Should reset to 1 page + expect(pages.value).toHaveLength(1) + }) + + it(`should reset pagination when switching collection instances (direct input)`, async () => { + const posts1 = createMockPosts(10) + const collection1 = createLiveQueryCollection({ + query: (q: InitialQueryBuilder) => q.from({ posts: createCollection(mockSyncCollectionOptions({ id: 'c1', initialData: posts1, getKey: p=>p.id })) }) + .orderBy(({ posts: p }: any) => p.createdAt, 'desc'), + startSync: true + }) + + const posts2 = createMockPosts(10) + const collection2 = createLiveQueryCollection({ + query: (q: InitialQueryBuilder) => q.from({ posts: createCollection(mockSyncCollectionOptions({ id: 'c2', initialData: posts2, getKey: p=>p.id })) }) + .orderBy(({ posts: p }: any) => p.createdAt, 'desc'), + startSync: true + }) + + const currentCollection = shallowRef(collection1) + + // Pass the Ref directly as the first argument + const { pages, fetchNextPage } = useLiveInfiniteQuery( + currentCollection, + { + pageSize: 2, + getNextPageParam: () => undefined, + } + ) + + await waitForVueUpdate() + expect(pages.value).toHaveLength(1) + + fetchNextPage() + await waitForVueUpdate() + expect(pages.value).toHaveLength(2) + + // Switch collection ref + currentCollection.value = collection2 + await waitForVueUpdate() + await waitForVueUpdate() + + // Should receive reset to page 1 + expect(pages.value).toHaveLength(1) + expect(pages.value[0]).toHaveLength(2) + }) + + it(`should skip window update if already correct`, async () => { + const posts = createMockPosts(10) + const collection = createCollection( + mockSyncCollectionOptions({ + id: `window-skip-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + const liveQueryCollection = createLiveQueryCollection({ + query: (q: InitialQueryBuilder) => q.from({ posts: collection }).orderBy(({ posts: p }) => p.createdAt, 'desc'), + startSync: true + }) + + // Spy on setWindow + let setWindowCallCount = 0 + const originalSetWindow = liveQueryCollection.utils.setWindow + liveQueryCollection.utils.setWindow = (opts) => { + setWindowCallCount++ + return originalSetWindow(opts) + } + + const { fetchNextPage } = useLiveInfiniteQuery(liveQueryCollection, { + pageSize: 5, + getNextPageParam: () => undefined + }) + + await waitForVueUpdate() + expect(setWindowCallCount).toBeGreaterThan(0) + const countAfterInit = setWindowCallCount + + // Force a reactivity update that DOESN'T change pagination + // e.g. unrelated dependency or just re-render if we could trigger it. + // Since we don't have unrelated deps in the hook usage here, + // we can rely on the fact that if we don't call fetchNextPage, it shouldn't call setWindow again + // even if we wait. + await waitForVueUpdate() + expect(setWindowCallCount).toBe(countAfterInit) + + // Verify fetching next page triggers it + fetchNextPage() + await waitForVueUpdate() + await waitForVueUpdate() + expect(setWindowCallCount).toBeGreaterThan(countAfterInit) + }) +})