Skip to content

Commit a19067e

Browse files
committed
feat(vue-db): implement useLiveInfiniteQuery for vue 3
1 parent dc9c019 commit a19067e

6 files changed

Lines changed: 1884 additions & 6 deletions

File tree

.changeset/vue-infinite-query.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
'@tanstack/vue-db': minor
3+
---
4+
5+
Add `useLiveInfiniteQuery` composable for infinite scrolling with live updates.
6+
7+
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.
8+
9+
**Key features:**
10+
11+
- **Automatic Live Updates**: Reactive integration with local collections using Vue 3 composables (ref, computed, watchEffect).
12+
- **Efficient Pagination**: Uses a dynamic window mechanism to track visible data without re-executing complex queries.
13+
- **Automatic Page Detection**: Includes a built-in peek-ahead strategy to detect if more pages are available without manual `getNextPageParam` logic.
14+
- **Flexible Rendering**: Provides both a flattened `data` ref and a structured `pages` ref.
15+
16+
**Example usage:**
17+
18+
```vue
19+
<script setup lang="ts">
20+
import { useLiveInfiniteQuery } from "@tanstack/vue-db";
21+
import { postsCollection } from "./db";
22+
23+
const { data, pages, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
24+
useLiveInfiniteQuery(
25+
(q) =>
26+
q
27+
.from({ posts: postsCollection })
28+
.orderBy(({ posts }) => posts.createdAt, "desc"),
29+
{
30+
pageSize: 20,
31+
}
32+
);
33+
</script>
34+
35+
<template>
36+
<div v-if="isLoading">Loading...</div>
37+
<div v-else>
38+
<template v-for="page in pages" :key="page">
39+
<PostCard v-for="post in page" :key="post.id" :post="post" />
40+
</template>
41+
42+
<button
43+
v-if="hasNextPage"
44+
:disabled="isFetchingNextPage"
45+
@click="fetchNextPage()"
46+
>
47+
{{ isFetchingNextPage ? 'Loading...' : 'Load More' }}
48+
</button>
49+
</div>
50+
</template>
51+
```
52+
53+
**Requirements:**
54+
55+
- The query must include an `.orderBy()` clause to support the underlying windowing mechanism.
56+
- Supports both offset-based and cursor-based sync implementations via the standard TanStack DB sync protocol.

packages/vue-db/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Re-export all public APIs
22
export * from './useLiveQuery'
3+
export * from './useLiveInfiniteQuery'
34

45
// Re-export everything from @tanstack/db
56
export * from '@tanstack/db'
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import { computed, ref, toValue, watch, watchEffect } from 'vue'
2+
import { CollectionImpl } from '@tanstack/db'
3+
import { useLiveQuery } from './useLiveQuery'
4+
import type {
5+
Collection,
6+
CollectionStatus,
7+
Context,
8+
GetResult,
9+
InferResultType,
10+
InitialQueryBuilder,
11+
LiveQueryCollectionUtils,
12+
NonSingleResult,
13+
QueryBuilder,
14+
} from '@tanstack/db'
15+
import type { ComputedRef, MaybeRefOrGetter } from 'vue'
16+
17+
/**
18+
* Type guard to check if utils object has setWindow method (LiveQueryCollectionUtils)
19+
*/
20+
const isLiveQueryCollectionUtils = (
21+
utils: unknown,
22+
): utils is LiveQueryCollectionUtils => {
23+
return typeof (utils as any).setWindow === `function`
24+
}
25+
26+
export type UseLiveInfiniteQueryConfig<TContext extends Context> = {
27+
pageSize?: number
28+
initialPageParam?: number
29+
/**
30+
* @deprecated This callback is not used by the current implementation.
31+
* Pagination is determined internally via a peek-ahead strategy.
32+
* Provided for API compatibility with TanStack Query conventions.
33+
*/
34+
getNextPageParam?: (
35+
lastPage: Array<InferResultType<TContext>[number]>,
36+
allPages: Array<Array<InferResultType<TContext>[number]>>,
37+
lastPageParam: number,
38+
allPageParams: Array<number>,
39+
) => number | undefined
40+
}
41+
42+
export interface UseLiveInfiniteQueryReturn<TContext extends Context> {
43+
state: ComputedRef<Map<string | number, GetResult<TContext>>>
44+
data: ComputedRef<InferResultType<TContext>>
45+
collection: ComputedRef<Collection<GetResult<TContext>, string | number, {}> | null>
46+
status: ComputedRef<CollectionStatus>
47+
isLoading: ComputedRef<boolean>
48+
isReady: ComputedRef<boolean>
49+
isIdle: ComputedRef<boolean>
50+
isError: ComputedRef<boolean>
51+
isCleanedUp: ComputedRef<boolean>
52+
pages: ComputedRef<Array<Array<InferResultType<TContext>[number]>>>
53+
pageParams: ComputedRef<Array<number>>
54+
fetchNextPage: () => void
55+
hasNextPage: ComputedRef<boolean>
56+
isFetchingNextPage: ComputedRef<boolean>
57+
}
58+
59+
// Overload for query function
60+
export function useLiveInfiniteQuery<TContext extends Context>(
61+
queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>,
62+
config: UseLiveInfiniteQueryConfig<TContext>,
63+
deps?: Array<MaybeRefOrGetter<unknown>>,
64+
): UseLiveInfiniteQueryReturn<TContext>
65+
66+
// Overload for pre-created collection (non-single result)
67+
export function useLiveInfiniteQuery<
68+
TResult extends object,
69+
TKey extends string | number,
70+
TUtils extends Record<string, any>,
71+
>(
72+
liveQueryCollection: MaybeRefOrGetter<
73+
Collection<TResult, TKey, TUtils> & NonSingleResult
74+
>,
75+
config: UseLiveInfiniteQueryConfig<any>,
76+
): UseLiveInfiniteQueryReturn<any>
77+
78+
// Implementation
79+
export function useLiveInfiniteQuery<TContext extends Context>(
80+
queryFnOrCollection: any,
81+
config: UseLiveInfiniteQueryConfig<TContext>,
82+
deps: Array<MaybeRefOrGetter<unknown>> = [],
83+
): UseLiveInfiniteQueryReturn<TContext> {
84+
const pageSize = config.pageSize || 20
85+
const initialPageParam = config.initialPageParam ?? 0
86+
87+
// Detect if input is a collection (or ref to collection) vs query function
88+
// NOTE: Don't call toValue on functions - toValue treats functions as getters
89+
const isCollectionInput =
90+
typeof queryFnOrCollection !== `function` &&
91+
toValue(queryFnOrCollection) instanceof CollectionImpl
92+
93+
if (!isCollectionInput && typeof queryFnOrCollection !== `function`) {
94+
throw new Error(
95+
`useLiveInfiniteQuery: First argument must be either a pre-created live query collection (CollectionImpl) ` +
96+
`or a query function. Received: ${typeof queryFnOrCollection}`,
97+
)
98+
}
99+
100+
const loadedPageCount = ref(1)
101+
const isFetchingNextPage = ref(false)
102+
let hasValidatedCollection = false
103+
104+
// Delegate to useLiveQuery for the underlying subscription
105+
// For query functions, add peek-ahead limit (+1) for hasNextPage detection
106+
const queryResult = isCollectionInput
107+
? useLiveQuery(queryFnOrCollection)
108+
: useLiveQuery(
109+
(q: any) =>
110+
queryFnOrCollection(q)
111+
.limit(pageSize + 1)
112+
.offset(0),
113+
deps,
114+
)
115+
116+
// Reset pagination when collection instance changes (deps change, collection swap, etc.)
117+
watch(queryResult.collection, () => {
118+
loadedPageCount.value = 1
119+
hasValidatedCollection = false
120+
})
121+
122+
// Adjust window when pagination state changes
123+
watchEffect((onInvalidate) => {
124+
const currentCollection = queryResult.collection.value
125+
if (!currentCollection) return
126+
127+
if (!isCollectionInput && !queryResult.isReady.value) return
128+
129+
const utils = (currentCollection as any).utils
130+
const expectedOffset = 0
131+
const expectedLimit = loadedPageCount.value * pageSize + 1 // +1 for peek ahead
132+
133+
if (!isLiveQueryCollectionUtils(utils)) {
134+
if (isCollectionInput) {
135+
throw new Error(
136+
`useLiveInfiniteQuery: Pre-created live query collection must have an orderBy clause for infinite pagination to work. ` +
137+
`Please add .orderBy() to your createLiveQueryCollection query.`,
138+
)
139+
}
140+
return
141+
}
142+
143+
// For pre-created collections, validate window on first check
144+
if (isCollectionInput && !hasValidatedCollection) {
145+
const currentWindow = utils.getWindow()
146+
if (
147+
currentWindow &&
148+
(currentWindow.offset !== expectedOffset ||
149+
currentWindow.limit !== expectedLimit)
150+
) {
151+
console.warn(
152+
`useLiveInfiniteQuery: Pre-created collection has window {offset: ${currentWindow.offset}, limit: ${currentWindow.limit}} ` +
153+
`but hook expects {offset: ${expectedOffset}, limit: ${expectedLimit}}. Adjusting window now.`,
154+
)
155+
}
156+
hasValidatedCollection = true
157+
}
158+
159+
let cancelled = false
160+
const result = utils.setWindow({
161+
offset: expectedOffset,
162+
limit: expectedLimit,
163+
})
164+
165+
if (result !== true) {
166+
isFetchingNextPage.value = true
167+
result
168+
.catch((error: unknown) => {
169+
if (!cancelled)
170+
console.error(`useLiveInfiniteQuery: setWindow failed:`, error)
171+
})
172+
.finally(() => {
173+
if (!cancelled) isFetchingNextPage.value = false
174+
})
175+
} else {
176+
isFetchingNextPage.value = false
177+
}
178+
179+
onInvalidate(() => {
180+
cancelled = true
181+
})
182+
})
183+
184+
// Derive pages, pageParams, hasNextPage, and flat data from query results
185+
const paginatedData = computed(() => {
186+
const rawData = queryResult.data.value
187+
const dataArray = (
188+
Array.isArray(rawData) ? rawData : []
189+
) as InferResultType<TContext>
190+
const totalItemsRequested = loadedPageCount.value * pageSize
191+
192+
const hasMore = dataArray.length > totalItemsRequested
193+
194+
const pagesResult: Array<Array<InferResultType<TContext>[number]>> = []
195+
const pageParamsResult: Array<number> = []
196+
197+
for (let i = 0; i < loadedPageCount.value; i++) {
198+
const pageData = dataArray.slice(i * pageSize, (i + 1) * pageSize)
199+
pagesResult.push(pageData)
200+
pageParamsResult.push(initialPageParam + i)
201+
}
202+
203+
const flatDataResult = dataArray.slice(
204+
0,
205+
totalItemsRequested,
206+
) as InferResultType<TContext>
207+
208+
return {
209+
pages: pagesResult,
210+
pageParams: pageParamsResult,
211+
hasNextPage: hasMore,
212+
flatData: flatDataResult,
213+
}
214+
})
215+
216+
const fetchNextPage = () => {
217+
if (!paginatedData.value.hasNextPage || isFetchingNextPage.value) return
218+
loadedPageCount.value++
219+
}
220+
221+
return {
222+
state: queryResult.state,
223+
data: computed(() => paginatedData.value.flatData),
224+
collection: queryResult.collection,
225+
status: queryResult.status,
226+
isLoading: queryResult.isLoading,
227+
isReady: queryResult.isReady,
228+
isIdle: queryResult.isIdle,
229+
isError: queryResult.isError,
230+
isCleanedUp: queryResult.isCleanedUp,
231+
pages: computed(() => paginatedData.value.pages),
232+
pageParams: computed(() => paginatedData.value.pageParams),
233+
fetchNextPage,
234+
hasNextPage: computed(() => paginatedData.value.hasNextPage),
235+
isFetchingNextPage: computed(() => isFetchingNextPage.value),
236+
} as unknown as UseLiveInfiniteQueryReturn<TContext>
237+
}

packages/vue-db/src/useLiveQuery.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import type { ComputedRef, MaybeRefOrGetter } from 'vue'
4040
export interface UseLiveQueryReturn<TContext extends Context> {
4141
state: ComputedRef<Map<string | number, GetResult<TContext>>>
4242
data: ComputedRef<InferResultType<TContext>>
43-
collection: ComputedRef<Collection<GetResult<TContext>, string | number, {}>>
43+
collection: ComputedRef<Collection<GetResult<TContext>, string | number, {}> | null>
4444
status: ComputedRef<CollectionStatus>
4545
isLoading: ComputedRef<boolean>
4646
isReady: ComputedRef<boolean>
@@ -56,7 +56,7 @@ export interface UseLiveQueryReturnWithCollection<
5656
> {
5757
state: ComputedRef<Map<TKey, T>>
5858
data: ComputedRef<Array<T>>
59-
collection: ComputedRef<Collection<T, TKey, TUtils>>
59+
collection: ComputedRef<Collection<T, TKey, TUtils> | null>
6060
status: ComputedRef<CollectionStatus>
6161
isLoading: ComputedRef<boolean>
6262
isReady: ComputedRef<boolean>
@@ -72,7 +72,7 @@ export interface UseLiveQueryReturnWithSingleResultCollection<
7272
> {
7373
state: ComputedRef<Map<TKey, T>>
7474
data: ComputedRef<T | undefined>
75-
collection: ComputedRef<Collection<T, TKey, TUtils> & SingleResult>
75+
collection: ComputedRef<(Collection<T, TKey, TUtils> & SingleResult) | null>
7676
status: ComputedRef<CollectionStatus>
7777
isLoading: ComputedRef<boolean>
7878
isReady: ComputedRef<boolean>

0 commit comments

Comments
 (0)