@tanstack/db 0.5.17 → 0.5.18

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.
@@ -566,6 +566,21 @@ export class CollectionConfigBuilder<
566
566
  },
567
567
  )
568
568
 
569
+ // Listen for loadingSubset changes on the live query collection BEFORE subscribing.
570
+ // This ensures we don't miss the event if subset loading completes synchronously.
571
+ // When isLoadingSubset becomes false, we may need to mark the collection as ready
572
+ // (if all source collections are already ready but we were waiting for subset load to complete)
573
+ const loadingSubsetUnsubscribe = config.collection.on(
574
+ `loadingSubset:change`,
575
+ (event) => {
576
+ if (!event.isLoadingSubset) {
577
+ // Subset loading finished, check if we can now mark ready
578
+ this.updateLiveQueryStatus(config)
579
+ }
580
+ },
581
+ )
582
+ syncState.unsubscribeCallbacks.add(loadingSubsetUnsubscribe)
583
+
569
584
  const loadSubsetDataCallbacks = this.subscribeToAllCollections(
570
585
  config,
571
586
  fullSyncState,
@@ -793,8 +808,14 @@ export class CollectionConfigBuilder<
793
808
  return
794
809
  }
795
810
 
796
- // Mark ready when all source collections are ready
797
- if (this.allCollectionsReady()) {
811
+ // Mark ready when all source collections are ready AND
812
+ // the live query collection is not loading subset data.
813
+ // This prevents marking the live query ready before its data is loaded
814
+ // (fixes issue where useLiveQuery returns isReady=true with empty data)
815
+ if (
816
+ this.allCollectionsReady() &&
817
+ !this.liveQueryCollection?.isLoadingSubset
818
+ ) {
798
819
  markReady()
799
820
  }
800
821
  }
@@ -5,7 +5,10 @@ import {
5
5
  } from '../compiler/expressions.js'
6
6
  import type { MultiSetArray, RootStreamBuilder } from '@tanstack/db-ivm'
7
7
  import type { Collection } from '../../collection/index.js'
8
- import type { ChangeMessage } from '../../types.js'
8
+ import type {
9
+ ChangeMessage,
10
+ SubscriptionStatusChangeEvent,
11
+ } from '../../types.js'
9
12
  import type { Context, GetResult } from '../builder/types.js'
10
13
  import type { BasicExpression } from '../ir.js'
11
14
  import type { OrderByOptimizationInfo } from '../compiler/order-by.js'
@@ -53,26 +56,10 @@ export class CollectionSubscriber<
53
56
  }
54
57
 
55
58
  private subscribeToChanges(whereExpression?: BasicExpression<boolean>) {
56
- let subscription: CollectionSubscription
57
59
  const orderByInfo = this.getOrderByInfo()
58
- if (orderByInfo) {
59
- subscription = this.subscribeToOrderedChanges(
60
- whereExpression,
61
- orderByInfo,
62
- )
63
- } else {
64
- // If the source alias is lazy then we should not include the initial state
65
- const includeInitialState = !this.collectionConfigBuilder.isLazyAlias(
66
- this.alias,
67
- )
68
-
69
- subscription = this.subscribeToMatchingChanges(
70
- whereExpression,
71
- includeInitialState,
72
- )
73
- }
74
60
 
75
- const trackLoadPromise = () => {
61
+ // Track load promises using subscription from the event (avoids circular dependency)
62
+ const trackLoadPromise = (subscription: CollectionSubscription) => {
76
63
  // Guard against duplicate transitions
77
64
  if (!this.subscriptionLoadingPromises.has(subscription)) {
78
65
  let resolve: () => void
@@ -89,16 +76,12 @@ export class CollectionSubscriber<
89
76
  }
90
77
  }
91
78
 
92
- // It can be that we are not yet subscribed when the first `loadSubset` call happens (i.e. the initial query).
93
- // So we also check the status here and if it's `loadingSubset` then we track the load promise
94
- if (subscription.status === `loadingSubset`) {
95
- trackLoadPromise()
96
- }
97
-
98
- // Subscribe to subscription status changes to propagate loading state
99
- const statusUnsubscribe = subscription.on(`status:change`, (event) => {
79
+ // Status change handler - passed to subscribeChanges so it's registered
80
+ // BEFORE any snapshot is requested, preventing race conditions
81
+ const onStatusChange = (event: SubscriptionStatusChangeEvent) => {
82
+ const subscription = event.subscription as CollectionSubscription
100
83
  if (event.status === `loadingSubset`) {
101
- trackLoadPromise()
84
+ trackLoadPromise(subscription)
102
85
  } else {
103
86
  // status is 'ready'
104
87
  const deferred = this.subscriptionLoadingPromises.get(subscription)
@@ -108,7 +91,34 @@ export class CollectionSubscriber<
108
91
  deferred.resolve()
109
92
  }
110
93
  }
111
- })
94
+ }
95
+
96
+ // Create subscription with onStatusChange - listener is registered before any async work
97
+ let subscription: CollectionSubscription
98
+ if (orderByInfo) {
99
+ subscription = this.subscribeToOrderedChanges(
100
+ whereExpression,
101
+ orderByInfo,
102
+ onStatusChange,
103
+ )
104
+ } else {
105
+ // If the source alias is lazy then we should not include the initial state
106
+ const includeInitialState = !this.collectionConfigBuilder.isLazyAlias(
107
+ this.alias,
108
+ )
109
+
110
+ subscription = this.subscribeToMatchingChanges(
111
+ whereExpression,
112
+ includeInitialState,
113
+ onStatusChange,
114
+ )
115
+ }
116
+
117
+ // Check current status after subscribing - if status is 'loadingSubset', track it.
118
+ // The onStatusChange listener will catch the transition to 'ready'.
119
+ if (subscription.status === `loadingSubset`) {
120
+ trackLoadPromise(subscription)
121
+ }
112
122
 
113
123
  const unsubscribe = () => {
114
124
  // If subscription has a pending promise, resolve it before unsubscribing
@@ -119,7 +129,6 @@ export class CollectionSubscriber<
119
129
  deferred.resolve()
120
130
  }
121
131
 
122
- statusUnsubscribe()
123
132
  subscription.unsubscribe()
124
133
  }
125
134
  // currentSyncState is always defined when subscribe() is called
@@ -179,22 +188,22 @@ export class CollectionSubscriber<
179
188
 
180
189
  private subscribeToMatchingChanges(
181
190
  whereExpression: BasicExpression<boolean> | undefined,
182
- includeInitialState: boolean = false,
183
- ) {
191
+ includeInitialState: boolean,
192
+ onStatusChange: (event: SubscriptionStatusChangeEvent) => void,
193
+ ): CollectionSubscription {
184
194
  const sendChanges = (
185
195
  changes: Array<ChangeMessage<any, string | number>>,
186
196
  ) => {
187
197
  this.sendChangesToPipeline(changes)
188
198
  }
189
199
 
190
- // Only pass includeInitialState when true. When it's false, we leave it
191
- // undefined so that user subscriptions with explicit `includeInitialState: false`
192
- // can be distinguished from internal lazy-loading subscriptions.
193
- // If we pass `false`, changes.ts would call markAllStateAsSeen() which
194
- // disables filtering - but internal subscriptions still need filtering.
200
+ // Create subscription with onStatusChange - listener is registered before snapshot
201
+ // Note: For non-ordered queries (no limit/offset), we use trackLoadSubsetPromise: false
202
+ // which is the default behavior in subscribeChanges
195
203
  const subscription = this.collection.subscribeChanges(sendChanges, {
196
204
  ...(includeInitialState && { includeInitialState }),
197
205
  whereExpression,
206
+ onStatusChange,
198
207
  })
199
208
 
200
209
  return subscription
@@ -203,22 +212,31 @@ export class CollectionSubscriber<
203
212
  private subscribeToOrderedChanges(
204
213
  whereExpression: BasicExpression<boolean> | undefined,
205
214
  orderByInfo: OrderByOptimizationInfo,
206
- ) {
215
+ onStatusChange: (event: SubscriptionStatusChangeEvent) => void,
216
+ ): CollectionSubscription {
207
217
  const { orderBy, offset, limit, index } = orderByInfo
208
218
 
219
+ // Use a holder to forward-reference subscription in the callback
220
+ const subscriptionHolder: { current?: CollectionSubscription } = {}
221
+
209
222
  const sendChangesInRange = (
210
223
  changes: Iterable<ChangeMessage<any, string | number>>,
211
224
  ) => {
212
225
  // Split live updates into a delete of the old value and an insert of the new value
213
226
  const splittedChanges = splitUpdates(changes)
214
- this.sendChangesToPipelineWithTracking(splittedChanges, subscription)
227
+ this.sendChangesToPipelineWithTracking(
228
+ splittedChanges,
229
+ subscriptionHolder.current!,
230
+ )
215
231
  }
216
232
 
217
- // Subscribe to changes and only send changes that are smaller than the biggest value we've sent so far
218
- // values that are bigger don't need to be sent because they can't affect the topK
233
+ // Subscribe to changes with onStatusChange - listener is registered before any snapshot
234
+ // values bigger than what we've sent don't need to be sent because they can't affect the topK
219
235
  const subscription = this.collection.subscribeChanges(sendChangesInRange, {
220
236
  whereExpression,
237
+ onStatusChange,
221
238
  })
239
+ subscriptionHolder.current = subscription
222
240
 
223
241
  // Listen for truncate events to reset cursor tracking state and sentToD2Keys
224
242
  // This ensures that after a must-refetch/truncate, we don't use stale cursor data
@@ -236,6 +254,7 @@ export class CollectionSubscriber<
236
254
  // Normalize the orderBy clauses such that the references are relative to the collection
237
255
  const normalizedOrderBy = normalizeOrderByPaths(orderBy, this.alias)
238
256
 
257
+ // Trigger the snapshot request - onStatusChange listener is already registered
239
258
  if (index) {
240
259
  // We have an index on the first orderBy column - use lazy loading optimization
241
260
  // This works for both single-column and multi-column orderBy:
package/src/types.ts CHANGED
@@ -798,6 +798,12 @@ export interface SubscribeChangesOptions<
798
798
  where?: (row: SingleRowRefProxy<T>) => any
799
799
  /** Pre-compiled expression for filtering changes */
800
800
  whereExpression?: BasicExpression<boolean>
801
+ /**
802
+ * Listener for subscription status changes.
803
+ * Registered BEFORE any snapshot is requested, ensuring no status transitions are missed.
804
+ * @internal
805
+ */
806
+ onStatusChange?: (event: SubscriptionStatusChangeEvent) => void
801
807
  }
802
808
 
803
809
  export interface SubscribeChangesSnapshotOptions<