Skip to content

Commit 69238e8

Browse files
committed
refactor: Refactor useLiveSuspenseQuery to wrap useLiveQuery
Simplified implementation by reusing useLiveQuery internally instead of duplicating all collection management logic. This follows the same pattern as TanStack Query's useBaseQuery. Changes: - useLiveSuspenseQuery now wraps useLiveQuery and adds Suspense logic - Reduced code from ~350 lines to ~165 lines by eliminating duplication - Only difference is the Suspense logic (throwing promises/errors) - All tests still pass Benefits: - Easier to maintain - changes to collection logic happen in one place - Consistent behavior between useLiveQuery and useLiveSuspenseQuery - Cleaner separation of concerns Also fixed lint errors: - Remove unused imports (vi, useState) - Fix variable shadowing in test 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 36b5c06 commit 69238e8

File tree

2 files changed

+25
-211
lines changed

2 files changed

+25
-211
lines changed
Lines changed: 21 additions & 208 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
1-
import { useRef, useSyncExternalStore } from "react"
2-
import {
3-
BaseQueryBuilder,
4-
CollectionImpl,
5-
createLiveQueryCollection,
6-
} from "@tanstack/db"
1+
import { useRef } from "react"
2+
import { useLiveQuery } from "./useLiveQuery"
73
import type {
84
Collection,
9-
CollectionConfigSingleRowOption,
105
Context,
116
GetResult,
127
InferResultType,
@@ -17,8 +12,6 @@ import type {
1712
SingleResult,
1813
} from "@tanstack/db"
1914

20-
const DEFAULT_GC_TIME_MS = 1 // Live queries created by useLiveSuspenseQuery are cleaned up immediately (0 disables GC)
21-
2215
/**
2316
* Create a live query with React Suspense support
2417
* @param queryFn - Query function that defines what data to fetch
@@ -125,228 +118,48 @@ export function useLiveSuspenseQuery<
125118
collection: Collection<TResult, TKey, TUtils> & SingleResult
126119
}
127120

128-
// Implementation - uses function overloads to infer the actual collection type
121+
// Implementation - uses useLiveQuery internally and adds Suspense logic
129122
export function useLiveSuspenseQuery(
130123
configOrQueryOrCollection: any,
131124
deps: Array<unknown> = []
132125
) {
133-
// Check if it's already a collection by checking for specific collection methods
134-
const isCollection =
135-
configOrQueryOrCollection &&
136-
typeof configOrQueryOrCollection === `object` &&
137-
typeof configOrQueryOrCollection.subscribeChanges === `function` &&
138-
typeof configOrQueryOrCollection.startSyncImmediate === `function` &&
139-
typeof configOrQueryOrCollection.id === `string`
140-
141-
// Use refs to cache collection and track dependencies
142-
const collectionRef = useRef<Collection<object, string | number, {}> | null>(
143-
null
144-
)
145-
const depsRef = useRef<Array<unknown> | null>(null)
146-
const configRef = useRef<unknown>(null)
147126
const promiseRef = useRef<Promise<void> | null>(null)
148127

149-
// Use refs to track version and memoized snapshot
150-
const versionRef = useRef(0)
151-
const snapshotRef = useRef<{
152-
collection: Collection<object, string | number, {}>
153-
version: number
154-
} | null>(null)
155-
156-
// Check if we need to create/recreate the collection
157-
const needsNewCollection =
158-
!collectionRef.current ||
159-
(isCollection && configRef.current !== configOrQueryOrCollection) ||
160-
(!isCollection &&
161-
(depsRef.current === null ||
162-
depsRef.current.length !== deps.length ||
163-
depsRef.current.some((dep, i) => dep !== deps[i])))
164-
165-
if (needsNewCollection) {
166-
// Reset promise for new collection
167-
promiseRef.current = null
168-
169-
if (isCollection) {
170-
// It's already a collection, ensure sync is started for React hooks
171-
configOrQueryOrCollection.startSyncImmediate()
172-
collectionRef.current = configOrQueryOrCollection
173-
configRef.current = configOrQueryOrCollection
174-
} else {
175-
// Handle different callback return types
176-
if (typeof configOrQueryOrCollection === `function`) {
177-
// Call the function with a query builder to see what it returns
178-
const queryBuilder = new BaseQueryBuilder() as InitialQueryBuilder
179-
const result = configOrQueryOrCollection(queryBuilder)
128+
// Use useLiveQuery to handle collection management and reactivity
129+
const result = useLiveQuery(configOrQueryOrCollection, deps)
180130

181-
if (result === undefined || result === null) {
182-
// Suspense queries cannot be disabled - throw error
183-
throw new Error(
184-
`useLiveSuspenseQuery does not support returning undefined/null from query function. Use useLiveQuery instead for conditional queries.`
185-
)
186-
} else if (result instanceof CollectionImpl) {
187-
// Callback returned a Collection instance - use it directly
188-
result.startSyncImmediate()
189-
collectionRef.current = result
190-
} else if (result instanceof BaseQueryBuilder) {
191-
// Callback returned QueryBuilder - create live query collection using the original callback
192-
collectionRef.current = createLiveQueryCollection({
193-
query: configOrQueryOrCollection,
194-
startSync: true,
195-
gcTime: DEFAULT_GC_TIME_MS,
196-
})
197-
} else if (result && typeof result === `object`) {
198-
// Assume it's a LiveQueryCollectionConfig
199-
collectionRef.current = createLiveQueryCollection({
200-
startSync: true,
201-
gcTime: DEFAULT_GC_TIME_MS,
202-
...result,
203-
})
204-
} else {
205-
// Unexpected return type
206-
throw new Error(
207-
`useLiveSuspenseQuery callback must return a QueryBuilder, LiveQueryCollectionConfig, or Collection. Got: ${typeof result}`
208-
)
209-
}
210-
depsRef.current = [...deps]
211-
} else {
212-
// Config object
213-
collectionRef.current = createLiveQueryCollection({
214-
startSync: true,
215-
gcTime: DEFAULT_GC_TIME_MS,
216-
...configOrQueryOrCollection,
217-
})
218-
depsRef.current = [...deps]
219-
}
220-
}
221-
}
222-
223-
// Reset refs when collection changes
224-
if (needsNewCollection) {
225-
versionRef.current = 0
226-
snapshotRef.current = null
131+
// SUSPENSE LOGIC: Throw promise or error based on collection status
132+
if (result.status === `disabled`) {
133+
// Suspense queries cannot be disabled - throw error
134+
throw new Error(
135+
`useLiveSuspenseQuery does not support disabled queries. Use useLiveQuery instead for conditional queries.`
136+
)
227137
}
228138

229-
const collection = collectionRef.current!
230-
231-
// SUSPENSE LOGIC: Throw promise or error based on collection status
232-
if (collection.status === `error`) {
139+
if (result.status === `error`) {
233140
// Clear promise and throw error to Error Boundary
234141
promiseRef.current = null
235-
throw new Error(`Collection "${collection.id}" failed to load`)
142+
throw new Error(`Collection "${result.collection.id}" failed to load`)
236143
}
237144

238-
if (collection.status === `loading` || collection.status === `idle`) {
145+
if (result.status === `loading` || result.status === `idle`) {
239146
// Create or reuse promise
240147
if (!promiseRef.current) {
241-
promiseRef.current = collection.preload()
148+
promiseRef.current = result.collection.preload()
242149
}
243150
// THROW PROMISE - React Suspense catches this (React 18+ compatible)
244151
throw promiseRef.current
245152
}
246153

247154
// Collection is ready - clear promise
248-
if (collection.status === `ready`) {
155+
if (result.status === `ready`) {
249156
promiseRef.current = null
250157
}
251158

252-
// Create stable subscribe function using ref
253-
const subscribeRef = useRef<
254-
((onStoreChange: () => void) => () => void) | null
255-
>(null)
256-
if (!subscribeRef.current || needsNewCollection) {
257-
subscribeRef.current = (onStoreChange: () => void) => {
258-
const subscription = collection.subscribeChanges(() => {
259-
// Bump version on any change; getSnapshot will rebuild next time
260-
versionRef.current += 1
261-
onStoreChange()
262-
})
263-
// Collection is ready, trigger initial snapshot
264-
if (collection.status === `ready`) {
265-
versionRef.current += 1
266-
onStoreChange()
267-
}
268-
return () => {
269-
subscription.unsubscribe()
270-
}
271-
}
159+
// Return data without status/loading flags (handled by Suspense/ErrorBoundary)
160+
return {
161+
state: result.state,
162+
data: result.data,
163+
collection: result.collection,
272164
}
273-
274-
// Create stable getSnapshot function using ref
275-
const getSnapshotRef = useRef<
276-
| (() => {
277-
collection: Collection<object, string | number, {}>
278-
version: number
279-
})
280-
| null
281-
>(null)
282-
if (!getSnapshotRef.current || needsNewCollection) {
283-
getSnapshotRef.current = () => {
284-
const currentVersion = versionRef.current
285-
const currentCollection = collection
286-
287-
// Recreate snapshot object only if version/collection changed
288-
if (
289-
!snapshotRef.current ||
290-
snapshotRef.current.version !== currentVersion ||
291-
snapshotRef.current.collection !== currentCollection
292-
) {
293-
snapshotRef.current = {
294-
collection: currentCollection,
295-
version: currentVersion,
296-
}
297-
}
298-
299-
return snapshotRef.current
300-
}
301-
}
302-
303-
// Use useSyncExternalStore to subscribe to collection changes
304-
const snapshot = useSyncExternalStore(
305-
subscribeRef.current,
306-
getSnapshotRef.current
307-
)
308-
309-
// Track last snapshot (from useSyncExternalStore) and the returned value separately
310-
const returnedSnapshotRef = useRef<{
311-
collection: Collection<object, string | number, {}>
312-
version: number
313-
} | null>(null)
314-
// Keep implementation return loose to satisfy overload signatures
315-
const returnedRef = useRef<any>(null)
316-
317-
// Rebuild returned object only when the snapshot changes (version or collection identity)
318-
if (
319-
!returnedSnapshotRef.current ||
320-
returnedSnapshotRef.current.version !== snapshot.version ||
321-
returnedSnapshotRef.current.collection !== snapshot.collection
322-
) {
323-
// Capture a stable view of entries for this snapshot to avoid tearing
324-
const entries = Array.from(snapshot.collection.entries())
325-
const config: CollectionConfigSingleRowOption<any, any, any> =
326-
snapshot.collection.config
327-
const singleResult = config.singleResult
328-
let stateCache: Map<string | number, unknown> | null = null
329-
let dataCache: Array<unknown> | null = null
330-
331-
returnedRef.current = {
332-
get state() {
333-
if (!stateCache) {
334-
stateCache = new Map(entries)
335-
}
336-
return stateCache
337-
},
338-
get data() {
339-
if (!dataCache) {
340-
dataCache = entries.map(([, value]) => value)
341-
}
342-
return singleResult ? dataCache[0] : dataCache
343-
},
344-
collection: snapshot.collection,
345-
}
346-
347-
// Remember the snapshot that produced this returned value
348-
returnedSnapshotRef.current = snapshot
349-
}
350-
351-
return returnedRef.current!
352165
}

packages/react-db/tests/useLiveSuspenseQuery.test.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1-
import { describe, expect, it, vi } from "vitest"
1+
import { describe, expect, it } from "vitest"
22
import { renderHook, waitFor } from "@testing-library/react"
33
import {
44
createCollection,
55
createLiveQueryCollection,
66
eq,
77
gt,
88
} from "@tanstack/db"
9-
import { Suspense, useState, type ReactNode } from "react"
9+
import { Suspense } from "react"
1010
import { useLiveSuspenseQuery } from "../src/useLiveSuspenseQuery"
1111
import { mockSyncCollectionOptions } from "../../db/tests/utils"
12+
import type { ReactNode } from "react"
1213

1314
type Person = {
1415
id: string
@@ -348,7 +349,7 @@ describe(`useLiveSuspenseQuery`, () => {
348349
const johnDoe = useLiveSuspenseQuery((q) =>
349350
q
350351
.from({ persons: personsCollection })
351-
.where(({ persons }) => eq(persons.id, `1`))
352+
.where(({ persons: p }) => eq(p.id, `1`))
352353
.findOne()
353354
)
354355
return { persons, johnDoe }

0 commit comments

Comments
 (0)