Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/brown-otters-grab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@tanstack/react-db": patch
"@tanstack/db": patch
---

Add support for pre-created live query collections in useLiveInfiniteQuery, enabling router loader patterns where live queries can be created, preloaded, and passed to components.
20 changes: 20 additions & 0 deletions packages/db/src/query/live/collection-config-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ export type LiveQueryCollectionUtils = UtilsRecord & {
* @returns `true` if no subset loading was triggered, or `Promise<void>` that resolves when the subset has been loaded
*/
setWindow: (options: WindowOptions) => true | Promise<void>
/**
* Gets the current window (offset and limit) for an ordered query.
*
* @returns The current window settings, or `undefined` if the query is not windowed
*/
getWindow: () => { offset: number; limit: number } | undefined
}

type PendingGraphRun = {
Expand Down Expand Up @@ -93,6 +99,7 @@ export class CollectionConfigBuilder<
public liveQueryCollection?: Collection<TResult, any, any>

private windowFn: ((options: WindowOptions) => void) | undefined
private currentWindow: WindowOptions | undefined

private maybeRunGraphFn: (() => void) | undefined

Expand Down Expand Up @@ -187,6 +194,7 @@ export class CollectionConfigBuilder<
getRunCount: this.getRunCount.bind(this),
getBuilder: () => this,
setWindow: this.setWindow.bind(this),
getWindow: this.getWindow.bind(this),
},
}
}
Expand All @@ -196,6 +204,7 @@ export class CollectionConfigBuilder<
throw new SetWindowRequiresOrderByError()
}

this.currentWindow = options
this.windowFn(options)
this.maybeRunGraphFn?.()

Expand All @@ -219,6 +228,17 @@ export class CollectionConfigBuilder<
return true
}

getWindow(): { offset: number; limit: number } | undefined {
// Only return window if this is a windowed query (has orderBy and windowFn)
if (!this.windowFn || !this.currentWindow) {
return undefined
}
return {
offset: this.currentWindow.offset ?? 0,
limit: this.currentWindow.limit ?? 0,
}
}

/**
* Resolves a collection alias to its collection ID.
*
Expand Down
174 changes: 146 additions & 28 deletions packages/react-db/src/useLiveInfiniteQuery.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { CollectionImpl } from "@tanstack/db"
import { useLiveQuery } from "./useLiveQuery"
import type {
Collection,
Context,
InferResultType,
InitialQueryBuilder,
LiveQueryCollectionUtils,
NonSingleResult,
QueryBuilder,
} from "@tanstack/db"

Expand Down Expand Up @@ -82,61 +85,176 @@ export type UseLiveInfiniteQueryReturn<TContext extends Context> = Omit<
* },
* [category]
* )
*
* @example
* // Router loader pattern with pre-created collection
* // In loader:
* const postsQuery = createLiveQueryCollection({
* query: (q) => q
* .from({ posts: postsCollection })
* .orderBy(({ posts }) => posts.createdAt, 'desc')
* .limit(20)
* })
* await postsQuery.preload()
* return { postsQuery }
*
* // In component:
* const { postsQuery } = useLoaderData()
* const { data, fetchNextPage, hasNextPage } = useLiveInfiniteQuery(
* postsQuery,
* {
* pageSize: 20,
* getNextPageParam: (lastPage) => lastPage.length === 20 ? lastPage.length : undefined
* }
* )
*/

// Overload for pre-created collection (non-single result)
export function useLiveInfiniteQuery<
TResult extends object,
TKey extends string | number,
TUtils extends Record<string, any>,
>(
liveQueryCollection: Collection<TResult, TKey, TUtils> & NonSingleResult,
config: UseLiveInfiniteQueryConfig<any>
): UseLiveInfiniteQueryReturn<any>

// Overload for query function
export function useLiveInfiniteQuery<TContext extends Context>(
queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>,
config: UseLiveInfiniteQueryConfig<TContext>,
deps?: Array<unknown>
): UseLiveInfiniteQueryReturn<TContext>

// Implementation
export function useLiveInfiniteQuery<TContext extends Context>(
queryFnOrCollection: any,
config: UseLiveInfiniteQueryConfig<TContext>,
deps: Array<unknown> = []
): UseLiveInfiniteQueryReturn<TContext> {
const pageSize = config.pageSize || 20
const initialPageParam = config.initialPageParam ?? 0

// Detect if input is a collection or query function
const isCollection = queryFnOrCollection instanceof CollectionImpl

// Validate input type
if (!isCollection && typeof queryFnOrCollection !== `function`) {
throw new Error(
`useLiveInfiniteQuery: First argument must be either a pre-created live query collection (CollectionImpl) ` +
`or a query function. Received: ${typeof queryFnOrCollection}`
)
}

// Track how many pages have been loaded
const [loadedPageCount, setLoadedPageCount] = useState(1)
const [isFetchingNextPage, setIsFetchingNextPage] = useState(false)

// Stringify deps for comparison
// Track collection instance and whether we've validated it (only for pre-created collections)
const collectionRef = useRef(isCollection ? queryFnOrCollection : null)
const hasValidatedCollectionRef = useRef(false)

// Track deps for query functions (stringify for comparison)
const depsKey = JSON.stringify(deps)
const prevDepsKeyRef = useRef(depsKey)

// Reset page count when dependencies change
// Reset pagination when inputs change
useEffect(() => {
if (prevDepsKeyRef.current !== depsKey) {
let shouldReset = false

if (isCollection) {
// Reset if collection instance changed
if (collectionRef.current !== queryFnOrCollection) {
collectionRef.current = queryFnOrCollection
hasValidatedCollectionRef.current = false
shouldReset = true
}
} else {
// Reset if deps changed (for query functions)
if (prevDepsKeyRef.current !== depsKey) {
prevDepsKeyRef.current = depsKey
shouldReset = true
}
}

if (shouldReset) {
setLoadedPageCount(1)
prevDepsKeyRef.current = depsKey
}
}, [depsKey])
}, [isCollection, queryFnOrCollection, depsKey])

// Create a live query with initial limit and offset
// The query function is wrapped to add limit/offset to the query
const queryResult = useLiveQuery(
(q) => queryFn(q).limit(pageSize).offset(0),
deps
)

// Update the window when loadedPageCount changes
// We fetch one extra item to peek if there's a next page
// Either pass collection directly or wrap query function
const queryResult = isCollection
? useLiveQuery(queryFnOrCollection)
: useLiveQuery(
(q) => queryFnOrCollection(q).limit(pageSize).offset(0),
deps
)

// Adjust window when pagination changes
useEffect(() => {
const newLimit = loadedPageCount * pageSize + 1 // +1 to peek ahead
const utils = queryResult.collection.utils
// setWindow is available on live query collections with orderBy
if (isLiveQueryCollectionUtils(utils)) {
const result = utils.setWindow({ offset: 0, limit: newLimit })
// setWindow returns true if data is immediately available, or Promise<void> if loading
if (result !== true) {
setIsFetchingNextPage(true)
result.then(() => {
setIsFetchingNextPage(false)
})
} else {
setIsFetchingNextPage(false)
const expectedOffset = 0
const expectedLimit = loadedPageCount * pageSize + 1 // +1 for peek ahead

// Check if collection has orderBy (required for setWindow)
if (!isLiveQueryCollectionUtils(utils)) {
// For pre-created collections, throw an error if no orderBy
if (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
}

// For pre-created collections, validate window on first check
if (isCollection && !hasValidatedCollectionRef.current) {
const currentWindow = utils.getWindow()
if (
currentWindow &&
(currentWindow.offset !== expectedOffset ||
currentWindow.limit !== expectedLimit)
) {
console.warn(
`useLiveInfiniteQuery: Pre-created collection has window {offset: ${currentWindow.offset}, limit: ${currentWindow.limit}} ` +
`but hook expects {offset: ${expectedOffset}, limit: ${expectedLimit}}. Adjusting window now.`
)
}
hasValidatedCollectionRef.current = true
}
}, [loadedPageCount, pageSize, queryResult.collection])

// For query functions, wait until collection is ready
if (!isCollection && !queryResult.isReady) return

// Adjust the window
const result = utils.setWindow({
offset: expectedOffset,
limit: expectedLimit,
})

if (result !== true) {
setIsFetchingNextPage(true)
result.then(() => {
setIsFetchingNextPage(false)
})
} else {
setIsFetchingNextPage(false)
}
}, [
isCollection,
queryResult.collection,
queryResult.isReady,
loadedPageCount,
pageSize,
])

// Split the data array into pages and determine if there's a next page
const { pages, pageParams, hasNextPage, flatData } = useMemo(() => {
const dataArray = queryResult.data as InferResultType<TContext>
const dataArray = (
Array.isArray(queryResult.data) ? queryResult.data : []
) as InferResultType<TContext>
const totalItemsRequested = loadedPageCount * pageSize

// Check if we have more data than requested (the peek ahead item)
Expand Down Expand Up @@ -181,5 +299,5 @@ export function useLiveInfiniteQuery<TContext extends Context>(
fetchNextPage,
hasNextPage,
isFetchingNextPage,
}
} as UseLiveInfiniteQueryReturn<TContext>
}
Loading
Loading