From 851851a6eab0f412c02b713d05df9b6fce5d9da3 Mon Sep 17 00:00:00 2001 From: Mike Harris Date: Thu, 31 Jul 2025 16:47:53 -0400 Subject: [PATCH 1/4] failing test for stable ref from useLiveQuery --- packages/react-db/tests/useLiveQuery.test.tsx | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/react-db/tests/useLiveQuery.test.tsx b/packages/react-db/tests/useLiveQuery.test.tsx index 9814dd07..bfaedfeb 100644 --- a/packages/react-db/tests/useLiveQuery.test.tsx +++ b/packages/react-db/tests/useLiveQuery.test.tsx @@ -114,6 +114,48 @@ describe(`Query Collections`, () => { }) }) + it(`should keep stable ref`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-persons`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + const { result, rerender } = renderHook(() => { + return useLiveQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + age: persons.age, + })) + ) + }) + + // Wait for collection to sync and state to update + await waitFor(() => { + expect(result.current.state.size).toBe(1) // Only John Smith (age 35) + }) + + const data1 = result.current.data + expect(result.current.data).toHaveLength(1) + + rerender() + + const data2 = result.current.data + + // Passes cause the underlying objects are stable + expect(data1).toEqual(data2) + expect(data1[0]).toBe(data2[0]) + + // Fails cause array isn't + expect(data1).toBe(data2) + }) + it(`should be able to query a collection with live updates`, async () => { const collection = createCollection( mockSyncCollectionOptions({ From 40b36d109acfff385ca926181980dcdd27c524b6 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Sat, 9 Aug 2025 10:40:37 +0100 Subject: [PATCH 2/4] refactor to have a stable ref for no changes --- packages/react-db/src/useLiveQuery.ts | 83 +++++++++++++++++---------- 1 file changed, 54 insertions(+), 29 deletions(-) diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 5aec07fb..e6ca0229 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -229,10 +229,8 @@ export function useLiveQuery( // Use refs to track version and memoized snapshot const versionRef = useRef(0) const snapshotRef = useRef<{ - state: Map - data: Array - collection: Collection - _version: number + collection: Collection + version: number } | null>(null) // Reset refs when collection changes @@ -248,6 +246,7 @@ export function useLiveQuery( if (!subscribeRef.current || needsNewCollection) { subscribeRef.current = (onStoreChange: () => void) => { const unsubscribe = collectionRef.current!.subscribeChanges(() => { + // Bump version on any change; getSnapshot will rebuild next time versionRef.current += 1 onStoreChange() }) @@ -260,9 +259,8 @@ export function useLiveQuery( // Create stable getSnapshot function using ref const getSnapshotRef = useRef< | (() => { - state: Map - data: Array - collection: Collection + collection: Collection + version: number }) | null >(null) @@ -271,20 +269,15 @@ export function useLiveQuery( const currentVersion = versionRef.current const currentCollection = collectionRef.current! - // If we don't have a snapshot or the version changed, create a new one + // Recreate snapshot object only if version/collection changed if ( !snapshotRef.current || - snapshotRef.current._version !== currentVersion + snapshotRef.current.version !== currentVersion || + snapshotRef.current.collection !== currentCollection ) { snapshotRef.current = { - get state() { - return new Map(currentCollection.entries()) - }, - get data() { - return Array.from(currentCollection.values()) - }, - collection: currentCollection, - _version: currentVersion, + collection: currentCollection as Collection, + version: currentVersion, } } @@ -298,17 +291,49 @@ export function useLiveQuery( getSnapshotRef.current ) - return { - state: snapshot.state, - data: snapshot.data, - collection: snapshot.collection, - status: snapshot.collection.status, - isLoading: - snapshot.collection.status === `loading` || - snapshot.collection.status === `initialCommit`, - isReady: snapshot.collection.status === `ready`, - isIdle: snapshot.collection.status === `idle`, - isError: snapshot.collection.status === `error`, - isCleanedUp: snapshot.collection.status === `cleaned-up`, + // Track last snapshot (from useSyncExternalStore) and the returned value separately + const returnedSnapshotRef = useRef<{ + collection: Collection + version: number + } | null>(null) + const returnedRef = useRef<{ + state: Map + data: Array + collection: Collection + status: CollectionStatus + isLoading: boolean + isReady: boolean + isIdle: boolean + isError: boolean + isCleanedUp: boolean + } | null>(null) + + // Rebuild returned object only when the snapshot changes (version or collection identity) + if ( + !returnedSnapshotRef.current || + returnedSnapshotRef.current.version !== snapshot.version || + returnedSnapshotRef.current.collection !== snapshot.collection + ) { + const state = new Map(snapshot.collection.entries()) + const data = Array.from(snapshot.collection.values()) + + returnedRef.current = { + state, + data, + collection: snapshot.collection, + status: snapshot.collection.status, + isLoading: + snapshot.collection.status === `loading` || + snapshot.collection.status === `initialCommit`, + isReady: snapshot.collection.status === `ready`, + isIdle: snapshot.collection.status === `idle`, + isError: snapshot.collection.status === `error`, + isCleanedUp: snapshot.collection.status === `cleaned-up`, + } + + // Remember the snapshot that produced this returned value + returnedSnapshotRef.current = snapshot } + + return returnedRef.current! } From 44e867d2c7f256c9989695506969c603cba7d853 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Sat, 9 Aug 2025 10:44:37 +0100 Subject: [PATCH 3/4] make state and data lazy --- packages/react-db/src/useLiveQuery.ts | 51 +++++++++++++++------------ 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index e6ca0229..a345d2de 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -187,9 +187,11 @@ export function useLiveQuery( typeof configOrQueryOrCollection.id === `string` // Use refs to cache collection and track dependencies - const collectionRef = useRef(null) + const collectionRef = useRef | null>( + null + ) const depsRef = useRef | null>(null) - const configRef = useRef(null) + const configRef = useRef(null) // Check if we need to create/recreate the collection const needsNewCollection = @@ -214,13 +216,13 @@ export function useLiveQuery( query: configOrQueryOrCollection, startSync: true, gcTime: 0, // Live queries created by useLiveQuery are cleaned up immediately - }) + }) as unknown as Collection } else { collectionRef.current = createLiveQueryCollection({ startSync: true, gcTime: 0, // Live queries created by useLiveQuery are cleaned up immediately ...configOrQueryOrCollection, - }) + }) as unknown as Collection } depsRef.current = [...deps] } @@ -229,7 +231,7 @@ export function useLiveQuery( // Use refs to track version and memoized snapshot const versionRef = useRef(0) const snapshotRef = useRef<{ - collection: Collection + collection: Collection version: number } | null>(null) @@ -259,7 +261,7 @@ export function useLiveQuery( // Create stable getSnapshot function using ref const getSnapshotRef = useRef< | (() => { - collection: Collection + collection: Collection version: number }) | null @@ -276,7 +278,7 @@ export function useLiveQuery( snapshotRef.current.collection !== currentCollection ) { snapshotRef.current = { - collection: currentCollection as Collection, + collection: currentCollection, version: currentVersion, } } @@ -293,20 +295,11 @@ export function useLiveQuery( // Track last snapshot (from useSyncExternalStore) and the returned value separately const returnedSnapshotRef = useRef<{ - collection: Collection + collection: Collection version: number } | null>(null) - const returnedRef = useRef<{ - state: Map - data: Array - collection: Collection - status: CollectionStatus - isLoading: boolean - isReady: boolean - isIdle: boolean - isError: boolean - isCleanedUp: boolean - } | null>(null) + // Keep implementation return loose to satisfy overload signatures + const returnedRef = useRef(null) // Rebuild returned object only when the snapshot changes (version or collection identity) if ( @@ -314,12 +307,24 @@ export function useLiveQuery( returnedSnapshotRef.current.version !== snapshot.version || returnedSnapshotRef.current.collection !== snapshot.collection ) { - const state = new Map(snapshot.collection.entries()) - const data = Array.from(snapshot.collection.values()) + // Capture a stable view of entries for this snapshot to avoid tearing + const entries = Array.from(snapshot.collection.entries()) + let stateCache: Map | null = null + let dataCache: Array | null = null returnedRef.current = { - state, - data, + get state() { + if (!stateCache) { + stateCache = new Map(entries) + } + return stateCache + }, + get data() { + if (!dataCache) { + dataCache = entries.map(([, value]) => value) + } + return dataCache + }, collection: snapshot.collection, status: snapshot.collection.status, isLoading: From 38a224c591cebfb19db0529a11a9bad42836256b Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Sat, 9 Aug 2025 11:02:52 +0100 Subject: [PATCH 4/4] changeset --- .changeset/ready-poems-fail.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/ready-poems-fail.md diff --git a/.changeset/ready-poems-fail.md b/.changeset/ready-poems-fail.md new file mode 100644 index 00000000..81dd96f0 --- /dev/null +++ b/.changeset/ready-poems-fail.md @@ -0,0 +1,5 @@ +--- +"@tanstack/react-db": patch +--- + +ensure that useLiveQuery returns a stable ref when there are no changes