@tanstack/query-db-collection 1.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/query.ts CHANGED
@@ -621,6 +621,23 @@ export function queryCollectionOptions(
621
621
  // queryKey → QueryObserver's unsubscribe function
622
622
  const unsubscribes = new Map<string, () => void>()
623
623
 
624
+ // queryKey → reference count (how many loadSubset calls are active)
625
+ // Reference counting for QueryObserver lifecycle management
626
+ // =========================================================
627
+ // Tracks how many live query subscriptions are using each QueryObserver.
628
+ // Multiple live queries with identical predicates share the same QueryObserver for efficiency.
629
+ //
630
+ // Lifecycle:
631
+ // - Increment: when createQueryFromOpts creates or reuses an observer
632
+ // - Decrement: when subscription.unsubscribe() passes predicates to collection._sync.unloadSubset()
633
+ // - Reset: when cleanupQuery() is triggered by TanStack Query's cache GC
634
+ //
635
+ // When refcount reaches 0, unloadSubset():
636
+ // 1. Computes the same queryKey from the predicates
637
+ // 2. Uses existing machinery (queryToRows map) to find rows that query loaded
638
+ // 3. Decrements refcount and GCs rows where count reaches 0
639
+ const queryRefCounts = new Map<string, number>()
640
+
624
641
  // Helper function to add a row to the internal state
625
642
  const addRow = (rowKey: string | number, hashedQueryKey: string) => {
626
643
  const rowToQueriesSet = rowToQueries.get(rowKey) || new Set()
@@ -651,29 +668,44 @@ export function queryCollectionOptions(
651
668
  // Track whether sync has been started
652
669
  let syncStarted = false
653
670
 
654
- const createQueryFromOpts = (
655
- opts: LoadSubsetOptions = {},
656
- queryFunction: typeof queryFn = queryFn
657
- ): true | Promise<void> => {
658
- // Push the predicates down to the queryKey and queryFn
659
- let key: QueryKey
671
+ /**
672
+ * Generate a consistent query key from LoadSubsetOptions.
673
+ * CRITICAL: Must use identical logic in both createQueryFromOpts and unloadSubset
674
+ * so that refcount increment/decrement operations target the same hashedQueryKey.
675
+ * Inconsistent keys would cause refcount leaks and prevent proper cleanup.
676
+ */
677
+ const generateQueryKeyFromOptions = (opts: LoadSubsetOptions): QueryKey => {
660
678
  if (typeof queryKey === `function`) {
661
679
  // Function-based queryKey: use it to build the key from opts
662
- key = queryKey(opts)
680
+ return queryKey(opts)
663
681
  } else if (syncMode === `on-demand`) {
664
682
  // Static queryKey in on-demand mode: automatically append serialized predicates
665
683
  // to create separate cache entries for different predicate combinations
666
684
  const serialized = serializeLoadSubsetOptions(opts)
667
- key = serialized !== undefined ? [...queryKey, serialized] : queryKey
685
+ return serialized !== undefined ? [...queryKey, serialized] : queryKey
668
686
  } else {
669
687
  // Static queryKey in eager mode: use as-is
670
- key = queryKey
688
+ return queryKey
671
689
  }
690
+ }
691
+
692
+ const createQueryFromOpts = (
693
+ opts: LoadSubsetOptions = {},
694
+ queryFunction: typeof queryFn = queryFn
695
+ ): true | Promise<void> => {
696
+ // Generate key using common function
697
+ const key = generateQueryKeyFromOptions(opts)
672
698
  const hashedQueryKey = hashKey(key)
673
699
  const extendedMeta = { ...meta, loadSubsetOptions: opts }
674
700
 
675
701
  if (state.observers.has(hashedQueryKey)) {
676
702
  // We already have a query for this queryKey
703
+ // Increment reference count since another consumer is using this observer
704
+ queryRefCounts.set(
705
+ hashedQueryKey,
706
+ (queryRefCounts.get(hashedQueryKey) || 0) + 1
707
+ )
708
+
677
709
  // Get the current result and return based on its state
678
710
  const observer = state.observers.get(hashedQueryKey)!
679
711
  const currentResult = observer.getCurrentResult()
@@ -732,6 +764,12 @@ export function queryCollectionOptions(
732
764
  hashToQueryKey.set(hashedQueryKey, key)
733
765
  state.observers.set(hashedQueryKey, localObserver)
734
766
 
767
+ // Increment reference count for this query
768
+ queryRefCounts.set(
769
+ hashedQueryKey,
770
+ (queryRefCounts.get(hashedQueryKey) || 0) + 1
771
+ )
772
+
735
773
  // Create a promise that resolves when the query result is first available
736
774
  const readyPromise = new Promise<void>((resolve, reject) => {
737
775
  const unsubscribe = localObserver.subscribe((result) => {
@@ -751,13 +789,6 @@ export function queryCollectionOptions(
751
789
  subscribeToQuery(localObserver, hashedQueryKey)
752
790
  }
753
791
 
754
- // Tell tanstack query to GC the query when the subscription is unsubscribed
755
- // The subscription is unsubscribed when the live query is GCed.
756
- const subscription = opts.subscription
757
- subscription?.once(`unsubscribed`, () => {
758
- queryClient.removeQueries({ queryKey: key, exact: true })
759
- })
760
-
761
792
  return readyPromise
762
793
  }
763
794
 
@@ -874,10 +905,17 @@ export function queryCollectionOptions(
874
905
  hashedQueryKey: string
875
906
  ) => {
876
907
  if (!isSubscribed(hashedQueryKey)) {
877
- const queryKey = hashToQueryKey.get(hashedQueryKey)!
878
- const handleQueryResult = makeQueryResultHandler(queryKey)
908
+ const cachedQueryKey = hashToQueryKey.get(hashedQueryKey)!
909
+ const handleQueryResult = makeQueryResultHandler(cachedQueryKey)
879
910
  const unsubscribeFn = observer.subscribe(handleQueryResult)
880
911
  unsubscribes.set(hashedQueryKey, unsubscribeFn)
912
+
913
+ // Process the current result immediately if available
914
+ // This ensures data is synced when resubscribing to a query with cached data
915
+ const currentResult = observer.getCurrentResult()
916
+ if (currentResult.isSuccess || currentResult.isError) {
917
+ handleQueryResult(currentResult)
918
+ }
881
919
  }
882
920
  }
883
921
 
@@ -927,73 +965,181 @@ export function queryCollectionOptions(
927
965
 
928
966
  // Ensure we process any existing query data (QueryObserver doesn't invoke its callback automatically with initial state)
929
967
  state.observers.forEach((observer, hashedQueryKey) => {
930
- const queryKey = hashToQueryKey.get(hashedQueryKey)!
931
- const handleQueryResult = makeQueryResultHandler(queryKey)
968
+ const cachedQueryKey = hashToQueryKey.get(hashedQueryKey)!
969
+ const handleQueryResult = makeQueryResultHandler(cachedQueryKey)
932
970
  handleQueryResult(observer.getCurrentResult())
933
971
  })
934
972
 
935
- // Subscribe to the query client's cache to handle queries that are GCed by tanstack query
936
- const unsubscribeQueryCache = queryClient
937
- .getQueryCache()
938
- .subscribe((event) => {
939
- const hashedKey = event.query.queryHash
940
- if (event.type === `removed`) {
941
- cleanupQuery(hashedKey)
942
- }
943
- })
944
-
945
- function cleanupQuery(hashedQueryKey: string) {
946
- // Unsubscribe from the query's observer
973
+ /**
974
+ * Perform row-level cleanup and remove all tracking for a query.
975
+ * Callers are responsible for ensuring the query is safe to cleanup.
976
+ */
977
+ const cleanupQueryInternal = (hashedQueryKey: string) => {
947
978
  unsubscribes.get(hashedQueryKey)?.()
979
+ unsubscribes.delete(hashedQueryKey)
948
980
 
949
- // Get all the rows that are in the result of this query
950
981
  const rowKeys = queryToRows.get(hashedQueryKey) ?? new Set()
982
+ const rowsToDelete: Array<any> = []
951
983
 
952
- // Remove the query from these rows
953
984
  rowKeys.forEach((rowKey) => {
954
- const queries = rowToQueries.get(rowKey) // set of queries that reference this row
955
- if (queries && queries.size > 0) {
956
- queries.delete(hashedQueryKey)
957
- if (queries.size === 0) {
958
- // Reference count dropped to 0, we can GC the row
959
- rowToQueries.delete(rowKey)
960
-
961
- if (collection.has(rowKey)) {
962
- begin()
963
- write({ type: `delete`, value: collection.get(rowKey) })
964
- commit()
965
- }
985
+ const queries = rowToQueries.get(rowKey)
986
+
987
+ if (!queries) {
988
+ return
989
+ }
990
+
991
+ queries.delete(hashedQueryKey)
992
+
993
+ if (queries.size === 0) {
994
+ rowToQueries.delete(rowKey)
995
+
996
+ if (collection.has(rowKey)) {
997
+ rowsToDelete.push(collection.get(rowKey))
966
998
  }
967
999
  }
968
1000
  })
969
1001
 
970
- // Remove the query from the internal state
971
- unsubscribes.delete(hashedQueryKey)
1002
+ if (rowsToDelete.length > 0) {
1003
+ begin()
1004
+ rowsToDelete.forEach((row) => {
1005
+ write({ type: `delete`, value: row })
1006
+ })
1007
+ commit()
1008
+ }
1009
+
972
1010
  state.observers.delete(hashedQueryKey)
973
1011
  queryToRows.delete(hashedQueryKey)
974
1012
  hashToQueryKey.delete(hashedQueryKey)
1013
+ queryRefCounts.delete(hashedQueryKey)
975
1014
  }
976
1015
 
1016
+ /**
1017
+ * Attempt to cleanup a query when it appears unused.
1018
+ * Respects refcounts and invalidateQueries cycles via hasListeners().
1019
+ */
1020
+ const cleanupQueryIfIdle = (hashedQueryKey: string) => {
1021
+ const refcount = queryRefCounts.get(hashedQueryKey) || 0
1022
+ const observer = state.observers.get(hashedQueryKey)
1023
+
1024
+ if (refcount <= 0) {
1025
+ // Drop our subscription so hasListeners reflects only active consumers
1026
+ unsubscribes.get(hashedQueryKey)?.()
1027
+ unsubscribes.delete(hashedQueryKey)
1028
+ }
1029
+
1030
+ const hasListeners = observer?.hasListeners() ?? false
1031
+
1032
+ if (hasListeners) {
1033
+ // During invalidateQueries, TanStack Query keeps internal listeners alive.
1034
+ // Leave refcount at 0 but keep observer so it can resubscribe.
1035
+ queryRefCounts.set(hashedQueryKey, 0)
1036
+ return
1037
+ }
1038
+
1039
+ // No listeners means the query is truly idle.
1040
+ // Even if refcount > 0, we treat hasListeners as authoritative to prevent leaks.
1041
+ // This can happen if subscriptions are GC'd without calling unloadSubset.
1042
+ if (refcount > 0) {
1043
+ console.warn(
1044
+ `[cleanupQueryIfIdle] Invariant violation: refcount=${refcount} but no listeners. Cleaning up to prevent leak.`,
1045
+ { hashedQueryKey }
1046
+ )
1047
+ }
1048
+
1049
+ cleanupQueryInternal(hashedQueryKey)
1050
+ }
1051
+
1052
+ /**
1053
+ * Force cleanup used by explicit collection cleanup.
1054
+ * Ignores refcounts/hasListeners and removes everything.
1055
+ */
1056
+ const forceCleanupQuery = (hashedQueryKey: string) => {
1057
+ cleanupQueryInternal(hashedQueryKey)
1058
+ }
1059
+
1060
+ // Subscribe to the query client's cache to handle queries that are GCed by tanstack query
1061
+ const unsubscribeQueryCache = queryClient
1062
+ .getQueryCache()
1063
+ .subscribe((event) => {
1064
+ const hashedKey = event.query.queryHash
1065
+ if (event.type === `removed`) {
1066
+ // Only cleanup if this is OUR query (we track it)
1067
+ if (hashToQueryKey.has(hashedKey)) {
1068
+ // TanStack Query GC'd this query after gcTime expired.
1069
+ // Use the guarded cleanup path to avoid deleting rows for active queries.
1070
+ cleanupQueryIfIdle(hashedKey)
1071
+ }
1072
+ }
1073
+ })
1074
+
977
1075
  const cleanup = async () => {
978
1076
  unsubscribeFromCollectionEvents()
979
1077
  unsubscribeFromQueries()
980
1078
 
981
- const queryKeys = [...hashToQueryKey.values()]
1079
+ const allQueryKeys = [...hashToQueryKey.values()]
1080
+ const allHashedKeys = [...state.observers.keys()]
982
1081
 
983
- hashToQueryKey.clear()
984
- queryToRows.clear()
985
- rowToQueries.clear()
986
- state.observers.clear()
1082
+ // Force cleanup all queries (explicit cleanup path)
1083
+ // This ignores hasListeners and always cleans up
1084
+ for (const hashedKey of allHashedKeys) {
1085
+ forceCleanupQuery(hashedKey)
1086
+ }
1087
+
1088
+ // Unsubscribe from cache events (cleanup already happened above)
987
1089
  unsubscribeQueryCache()
988
1090
 
1091
+ // Remove queries from TanStack Query cache
989
1092
  await Promise.all(
990
- queryKeys.map(async (queryKey) => {
991
- await queryClient.cancelQueries({ queryKey })
992
- queryClient.removeQueries({ queryKey })
1093
+ allQueryKeys.map(async (qKey) => {
1094
+ await queryClient.cancelQueries({ queryKey: qKey, exact: true })
1095
+ queryClient.removeQueries({ queryKey: qKey, exact: true })
993
1096
  })
994
1097
  )
995
1098
  }
996
1099
 
1100
+ /**
1101
+ * Unload a query subset - the subscription-based cleanup path (on-demand mode).
1102
+ *
1103
+ * Called when a live query subscription unsubscribes (via collection._sync.unloadSubset()).
1104
+ *
1105
+ * Flow:
1106
+ * 1. Receives the same predicates that were passed to loadSubset
1107
+ * 2. Computes the queryKey using generateQueryKeyFromOptions (same logic as loadSubset)
1108
+ * 3. Decrements refcount
1109
+ * 4. If refcount reaches 0:
1110
+ * - Checks hasListeners() to detect invalidateQueries cycles
1111
+ * - If hasListeners is true: resets refcount (TanStack Query keeping observer alive)
1112
+ * - If hasListeners is false: calls forceCleanupQuery() to perform row-level GC
1113
+ *
1114
+ * The hasListeners() check prevents premature cleanup during invalidateQueries:
1115
+ * - invalidateQueries causes temporary unsubscribe/resubscribe
1116
+ * - During unsubscribe, our refcount drops to 0
1117
+ * - But observer.hasListeners() is still true (TanStack Query's internal listeners)
1118
+ * - We skip cleanup and reset refcount, allowing resubscribe to succeed
1119
+ *
1120
+ * We don't cancel in-flight requests. Unsubscribing from the observer is sufficient
1121
+ * to prevent late-arriving data from being processed. The request completes and is cached
1122
+ * by TanStack Query, allowing quick remounts to restore data without refetching.
1123
+ */
1124
+ const unloadSubset = (options: LoadSubsetOptions) => {
1125
+ // 1. Same predicates → 2. Same queryKey
1126
+ const key = generateQueryKeyFromOptions(options)
1127
+ const hashedQueryKey = hashKey(key)
1128
+
1129
+ // 3. Decrement refcount
1130
+ const currentCount = queryRefCounts.get(hashedQueryKey) || 0
1131
+ const newCount = currentCount - 1
1132
+
1133
+ // Update refcount
1134
+ if (newCount <= 0) {
1135
+ queryRefCounts.set(hashedQueryKey, 0)
1136
+ cleanupQueryIfIdle(hashedQueryKey)
1137
+ } else {
1138
+ // Still have other references, just decrement
1139
+ queryRefCounts.set(hashedQueryKey, newCount)
1140
+ }
1141
+ }
1142
+
997
1143
  // Create deduplicated loadSubset wrapper for non-eager modes
998
1144
  // This prevents redundant snapshot requests when multiple concurrent
999
1145
  // live queries request overlapping or subset predicates
@@ -1002,6 +1148,7 @@ export function queryCollectionOptions(
1002
1148
 
1003
1149
  return {
1004
1150
  loadSubset: loadSubsetDedupe,
1151
+ unloadSubset: syncMode === `eager` ? undefined : unloadSubset,
1005
1152
  cleanup,
1006
1153
  }
1007
1154
  }
@@ -1025,9 +1172,9 @@ export function queryCollectionOptions(
1025
1172
  * @returns Promise that resolves when the refetch is complete, with QueryObserverResult
1026
1173
  */
1027
1174
  const refetch: RefetchFn = async (opts) => {
1028
- const queryKeys = [...hashToQueryKey.values()]
1029
- const refetchPromises = queryKeys.map((queryKey) => {
1030
- const queryObserver = state.observers.get(hashKey(queryKey))!
1175
+ const allQueryKeys = [...hashToQueryKey.values()]
1176
+ const refetchPromises = allQueryKeys.map((qKey) => {
1177
+ const queryObserver = state.observers.get(hashKey(qKey))!
1031
1178
  return queryObserver.refetch({
1032
1179
  throwOnError: opts?.throwOnError,
1033
1180
  })