From 5e530269e08f79e78954be7ef45818b457fcb55b Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Thu, 3 Jul 2025 09:09:33 +0100 Subject: [PATCH 1/2] use useSyncExternalStore for useLiveQuery --- .../db/src/query/live-query-collection.ts | 6 + packages/react-db/src/useLiveQuery.ts | 130 ++++++++++++++---- 2 files changed, 106 insertions(+), 30 deletions(-) diff --git a/packages/db/src/query/live-query-collection.ts b/packages/db/src/query/live-query-collection.ts index 12415e5b..2321e6ca 100644 --- a/packages/db/src/query/live-query-collection.ts +++ b/packages/db/src/query/live-query-collection.ts @@ -79,6 +79,11 @@ export interface LiveQueryCollectionConfig< * Start sync / the query immediately */ startSync?: boolean + + /** + * GC time for the collection + */ + gcTime?: number } /** @@ -322,6 +327,7 @@ export function liveQueryCollectionOptions< config.getKey || ((item) => resultKeys.get(item) as string | number), sync, compare, + gcTime: config.gcTime || 5000, // 5 seconds by default for live queries schema: config.schema, onInsert: config.onInsert, onUpdate: config.onUpdate, diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 09591f9a..31e70c31 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react" +import { useRef, useSyncExternalStore } from "react" import { createLiveQueryCollection } from "@tanstack/db" import type { Collection, @@ -55,30 +55,47 @@ export function useLiveQuery( typeof configOrQueryOrCollection.startSyncImmediate === `function` && typeof configOrQueryOrCollection.id === `string` - const collection = useMemo( - () => { - if (isCollection) { - // It's already a collection, ensure sync is started for React hooks - configOrQueryOrCollection.startSyncImmediate() - return configOrQueryOrCollection - } + // Use refs to cache collection and track dependencies + const collectionRef = useRef(null) + const depsRef = useRef | null>(null) + const configRef = useRef(null) + + // Check if we need to create/recreate the collection + const needsNewCollection = + !collectionRef.current || + (isCollection && configRef.current !== configOrQueryOrCollection) || + (!isCollection && + (depsRef.current === null || + depsRef.current.length !== deps.length || + depsRef.current.some((dep, i) => dep !== deps[i]))) + if (needsNewCollection) { + if (isCollection) { + // It's already a collection, ensure sync is started for React hooks + configOrQueryOrCollection.startSyncImmediate() + collectionRef.current = configOrQueryOrCollection + configRef.current = configOrQueryOrCollection + } else { // Original logic for creating collections // Ensure we always start sync for React hooks if (typeof configOrQueryOrCollection === `function`) { - return createLiveQueryCollection({ + collectionRef.current = createLiveQueryCollection({ query: configOrQueryOrCollection, startSync: true, + gcTime: 0, // Live queries created by useLiveQuery are cleaned up immediately }) } else { - return createLiveQueryCollection({ - ...configOrQueryOrCollection, + collectionRef.current = createLiveQueryCollection({ startSync: true, + gcTime: 0, // Live queries created by useLiveQuery are cleaned up immediately + ...configOrQueryOrCollection, }) } - }, - isCollection ? [configOrQueryOrCollection] : [...deps] - ) + depsRef.current = [...deps] + } + } + + const collection = collectionRef.current! // Infer types from the actual collection type CollectionType = @@ -88,25 +105,78 @@ export function useLiveQuery( ? K : string | number - // Use a simple counter to force re-renders when collection changes - const [, forceUpdate] = useState(0) + // Use refs to track version and memoized snapshot + const versionRef = useRef(0) + const snapshotRef = useRef<{ + state: Map + data: Array + collection: typeof collection + _version: number + } | null>(null) - useEffect(() => { - // Subscribe to changes and force re-render - const unsubscribe = collection.subscribeChanges(() => { - forceUpdate((prev) => prev + 1) - }) + // Create stable subscribe function using ref + const subscribeRef = useRef< + ((onStoreChange: () => void) => () => void) | null + >(null) + if (!subscribeRef.current || needsNewCollection) { + subscribeRef.current = (onStoreChange: () => void) => { + const unsubscribe = collection.subscribeChanges(() => { + versionRef.current += 1 + onStoreChange() + }) + return () => { + unsubscribe() + } + } + } + + // Create stable getSnapshot function using ref + const getSnapshotRef = useRef< + | (() => { + state: Map + data: Array + collection: typeof collection + }) + | null + >(null) + if (!getSnapshotRef.current || needsNewCollection) { + getSnapshotRef.current = (): { + state: Map + data: Array + collection: typeof collection + } => { + const currentVersion = versionRef.current + + // If we don't have a snapshot or the version changed, create a new one + if ( + !snapshotRef.current || + snapshotRef.current._version !== currentVersion + ) { + snapshotRef.current = { + get state(): Map { + return new Map(collection.entries()) + }, + get data(): Array { + return Array.from(collection.values()) + }, + collection, + _version: currentVersion, + } + } - return unsubscribe - }, [collection]) + return snapshotRef.current + } + } + + // Use useSyncExternalStore to subscribe to collection changes + const snapshot = useSyncExternalStore( + subscribeRef.current, + getSnapshotRef.current + ) return { - get state(): Map { - return new Map(collection.entries()) - }, - get data(): Array { - return Array.from(collection.values()) - }, - collection, + state: snapshot.state, + data: snapshot.data, + collection: snapshot.collection, } } From 8075f8afee76120fbd5e84f027fc8b914c375187 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Thu, 3 Jul 2025 09:54:51 +0100 Subject: [PATCH 2/2] fixes --- packages/react-db/src/useLiveQuery.ts | 47 ++++++++++++--------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 31e70c31..b079beaf 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -95,32 +95,28 @@ export function useLiveQuery( } } - const collection = collectionRef.current! - - // Infer types from the actual collection - type CollectionType = - typeof collection extends Collection ? T : never - type KeyType = - typeof collection extends Collection - ? K - : string | number - // Use refs to track version and memoized snapshot const versionRef = useRef(0) const snapshotRef = useRef<{ - state: Map - data: Array - collection: typeof collection + state: Map + data: Array + collection: Collection _version: number } | null>(null) + // Reset refs when collection changes + if (needsNewCollection) { + versionRef.current = 0 + snapshotRef.current = null + } + // Create stable subscribe function using ref const subscribeRef = useRef< ((onStoreChange: () => void) => () => void) | null >(null) if (!subscribeRef.current || needsNewCollection) { subscribeRef.current = (onStoreChange: () => void) => { - const unsubscribe = collection.subscribeChanges(() => { + const unsubscribe = collectionRef.current!.subscribeChanges(() => { versionRef.current += 1 onStoreChange() }) @@ -133,19 +129,16 @@ export function useLiveQuery( // Create stable getSnapshot function using ref const getSnapshotRef = useRef< | (() => { - state: Map - data: Array - collection: typeof collection + state: Map + data: Array + collection: Collection }) | null >(null) if (!getSnapshotRef.current || needsNewCollection) { - getSnapshotRef.current = (): { - state: Map - data: Array - collection: typeof collection - } => { + getSnapshotRef.current = () => { const currentVersion = versionRef.current + const currentCollection = collectionRef.current! // If we don't have a snapshot or the version changed, create a new one if ( @@ -153,13 +146,13 @@ export function useLiveQuery( snapshotRef.current._version !== currentVersion ) { snapshotRef.current = { - get state(): Map { - return new Map(collection.entries()) + get state() { + return new Map(currentCollection.entries()) }, - get data(): Array { - return Array.from(collection.values()) + get data() { + return Array.from(currentCollection.values()) }, - collection, + collection: currentCollection, _version: currentVersion, } }