@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/dist/cjs/query.cjs +99 -42
- package/dist/cjs/query.cjs.map +1 -1
- package/dist/esm/query.js +99 -42
- package/dist/esm/query.js.map +1 -1
- package/package.json +2 -2
- package/src/query.ts +206 -59
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
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
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
|
-
|
|
685
|
+
return serialized !== undefined ? [...queryKey, serialized] : queryKey
|
|
668
686
|
} else {
|
|
669
687
|
// Static queryKey in eager mode: use as-is
|
|
670
|
-
|
|
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
|
|
878
|
-
const handleQueryResult = makeQueryResultHandler(
|
|
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
|
|
931
|
-
const handleQueryResult = makeQueryResultHandler(
|
|
968
|
+
const cachedQueryKey = hashToQueryKey.get(hashedQueryKey)!
|
|
969
|
+
const handleQueryResult = makeQueryResultHandler(cachedQueryKey)
|
|
932
970
|
handleQueryResult(observer.getCurrentResult())
|
|
933
971
|
})
|
|
934
972
|
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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)
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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
|
-
|
|
971
|
-
|
|
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
|
|
1079
|
+
const allQueryKeys = [...hashToQueryKey.values()]
|
|
1080
|
+
const allHashedKeys = [...state.observers.keys()]
|
|
982
1081
|
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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
|
-
|
|
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
|
|
1029
|
-
const refetchPromises =
|
|
1030
|
-
const queryObserver = state.observers.get(hashKey(
|
|
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
|
})
|