Skip to content

Commit 7b36a3f

Browse files
ensure that react useLiveQuery returns a stable ref when there are no changes (#388)
Co-authored-by: Mike Harris <[email protected]>
1 parent 049d8a5 commit 7b36a3f

File tree

3 files changed

+109
-32
lines changed

3 files changed

+109
-32
lines changed

.changeset/ready-poems-fail.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tanstack/react-db": patch
3+
---
4+
5+
ensure that useLiveQuery returns a stable ref when there are no changes

packages/react-db/src/useLiveQuery.ts

Lines changed: 62 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -187,9 +187,11 @@ export function useLiveQuery(
187187
typeof configOrQueryOrCollection.id === `string`
188188

189189
// Use refs to cache collection and track dependencies
190-
const collectionRef = useRef<any>(null)
190+
const collectionRef = useRef<Collection<object, string | number, {}> | null>(
191+
null
192+
)
191193
const depsRef = useRef<Array<unknown> | null>(null)
192-
const configRef = useRef<any>(null)
194+
const configRef = useRef<unknown>(null)
193195

194196
// Check if we need to create/recreate the collection
195197
const needsNewCollection =
@@ -214,13 +216,13 @@ export function useLiveQuery(
214216
query: configOrQueryOrCollection,
215217
startSync: true,
216218
gcTime: 0, // Live queries created by useLiveQuery are cleaned up immediately
217-
})
219+
}) as unknown as Collection<object, string | number, {}>
218220
} else {
219221
collectionRef.current = createLiveQueryCollection({
220222
startSync: true,
221223
gcTime: 0, // Live queries created by useLiveQuery are cleaned up immediately
222224
...configOrQueryOrCollection,
223-
})
225+
}) as unknown as Collection<object, string | number, {}>
224226
}
225227
depsRef.current = [...deps]
226228
}
@@ -229,10 +231,8 @@ export function useLiveQuery(
229231
// Use refs to track version and memoized snapshot
230232
const versionRef = useRef(0)
231233
const snapshotRef = useRef<{
232-
state: Map<any, any>
233-
data: Array<any>
234-
collection: Collection<any, any, any>
235-
_version: number
234+
collection: Collection<object, string | number, {}>
235+
version: number
236236
} | null>(null)
237237

238238
// Reset refs when collection changes
@@ -248,6 +248,7 @@ export function useLiveQuery(
248248
if (!subscribeRef.current || needsNewCollection) {
249249
subscribeRef.current = (onStoreChange: () => void) => {
250250
const unsubscribe = collectionRef.current!.subscribeChanges(() => {
251+
// Bump version on any change; getSnapshot will rebuild next time
251252
versionRef.current += 1
252253
onStoreChange()
253254
})
@@ -260,9 +261,8 @@ export function useLiveQuery(
260261
// Create stable getSnapshot function using ref
261262
const getSnapshotRef = useRef<
262263
| (() => {
263-
state: Map<any, any>
264-
data: Array<any>
265-
collection: Collection<any, any, any>
264+
collection: Collection<object, string | number, {}>
265+
version: number
266266
})
267267
| null
268268
>(null)
@@ -271,20 +271,15 @@ export function useLiveQuery(
271271
const currentVersion = versionRef.current
272272
const currentCollection = collectionRef.current!
273273

274-
// If we don't have a snapshot or the version changed, create a new one
274+
// Recreate snapshot object only if version/collection changed
275275
if (
276276
!snapshotRef.current ||
277-
snapshotRef.current._version !== currentVersion
277+
snapshotRef.current.version !== currentVersion ||
278+
snapshotRef.current.collection !== currentCollection
278279
) {
279280
snapshotRef.current = {
280-
get state() {
281-
return new Map(currentCollection.entries())
282-
},
283-
get data() {
284-
return Array.from(currentCollection.values())
285-
},
286281
collection: currentCollection,
287-
_version: currentVersion,
282+
version: currentVersion,
288283
}
289284
}
290285

@@ -298,17 +293,52 @@ export function useLiveQuery(
298293
getSnapshotRef.current
299294
)
300295

301-
return {
302-
state: snapshot.state,
303-
data: snapshot.data,
304-
collection: snapshot.collection,
305-
status: snapshot.collection.status,
306-
isLoading:
307-
snapshot.collection.status === `loading` ||
308-
snapshot.collection.status === `initialCommit`,
309-
isReady: snapshot.collection.status === `ready`,
310-
isIdle: snapshot.collection.status === `idle`,
311-
isError: snapshot.collection.status === `error`,
312-
isCleanedUp: snapshot.collection.status === `cleaned-up`,
296+
// Track last snapshot (from useSyncExternalStore) and the returned value separately
297+
const returnedSnapshotRef = useRef<{
298+
collection: Collection<object, string | number, {}>
299+
version: number
300+
} | null>(null)
301+
// Keep implementation return loose to satisfy overload signatures
302+
const returnedRef = useRef<any>(null)
303+
304+
// Rebuild returned object only when the snapshot changes (version or collection identity)
305+
if (
306+
!returnedSnapshotRef.current ||
307+
returnedSnapshotRef.current.version !== snapshot.version ||
308+
returnedSnapshotRef.current.collection !== snapshot.collection
309+
) {
310+
// Capture a stable view of entries for this snapshot to avoid tearing
311+
const entries = Array.from(snapshot.collection.entries())
312+
let stateCache: Map<string | number, unknown> | null = null
313+
let dataCache: Array<unknown> | null = null
314+
315+
returnedRef.current = {
316+
get state() {
317+
if (!stateCache) {
318+
stateCache = new Map(entries)
319+
}
320+
return stateCache
321+
},
322+
get data() {
323+
if (!dataCache) {
324+
dataCache = entries.map(([, value]) => value)
325+
}
326+
return dataCache
327+
},
328+
collection: snapshot.collection,
329+
status: snapshot.collection.status,
330+
isLoading:
331+
snapshot.collection.status === `loading` ||
332+
snapshot.collection.status === `initialCommit`,
333+
isReady: snapshot.collection.status === `ready`,
334+
isIdle: snapshot.collection.status === `idle`,
335+
isError: snapshot.collection.status === `error`,
336+
isCleanedUp: snapshot.collection.status === `cleaned-up`,
337+
}
338+
339+
// Remember the snapshot that produced this returned value
340+
returnedSnapshotRef.current = snapshot
313341
}
342+
343+
return returnedRef.current!
314344
}

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,48 @@ describe(`Query Collections`, () => {
114114
})
115115
})
116116

117+
it(`should keep stable ref`, async () => {
118+
const collection = createCollection(
119+
mockSyncCollectionOptions<Person>({
120+
id: `test-persons`,
121+
getKey: (person: Person) => person.id,
122+
initialData: initialPersons,
123+
})
124+
)
125+
126+
const { result, rerender } = renderHook(() => {
127+
return useLiveQuery((q) =>
128+
q
129+
.from({ persons: collection })
130+
.where(({ persons }) => gt(persons.age, 30))
131+
.select(({ persons }) => ({
132+
id: persons.id,
133+
name: persons.name,
134+
age: persons.age,
135+
}))
136+
)
137+
})
138+
139+
// Wait for collection to sync and state to update
140+
await waitFor(() => {
141+
expect(result.current.state.size).toBe(1) // Only John Smith (age 35)
142+
})
143+
144+
const data1 = result.current.data
145+
expect(result.current.data).toHaveLength(1)
146+
147+
rerender()
148+
149+
const data2 = result.current.data
150+
151+
// Passes cause the underlying objects are stable
152+
expect(data1).toEqual(data2)
153+
expect(data1[0]).toBe(data2[0])
154+
155+
// Fails cause array isn't
156+
expect(data1).toBe(data2)
157+
})
158+
117159
it(`should be able to query a collection with live updates`, async () => {
118160
const collection = createCollection(
119161
mockSyncCollectionOptions<Person>({

0 commit comments

Comments
 (0)