@tanstack/react-db 0.5.12 → 1.0.0-rc.1

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.
Files changed (40) hide show
  1. package/dist/cjs/live-query-internals.cjs.map +1 -1
  2. package/dist/cjs/live-query-internals.d.cts +3 -1
  3. package/dist/cjs/source-id-bindings.cjs +9 -42
  4. package/dist/cjs/source-id-bindings.cjs.map +1 -1
  5. package/dist/cjs/source-id-bindings.d.cts +6 -17
  6. package/dist/cjs/useLiveInfiniteQuery.cjs +93 -173
  7. package/dist/cjs/useLiveInfiniteQuery.cjs.map +1 -1
  8. package/dist/cjs/useLiveInfiniteQuery.d.cts +3 -14
  9. package/dist/cjs/useLiveQuery.cjs +187 -224
  10. package/dist/cjs/useLiveQuery.cjs.map +1 -1
  11. package/dist/cjs/useLiveQuery.d.cts +37 -136
  12. package/dist/cjs/useLiveSuspenseQuery.cjs +71 -64
  13. package/dist/cjs/useLiveSuspenseQuery.cjs.map +1 -1
  14. package/dist/cjs/useLiveSuspenseQuery.d.cts +10 -9
  15. package/dist/cjs/usePacedMutations.cjs.map +1 -1
  16. package/dist/cjs/usePacedMutations.d.cts +1 -1
  17. package/dist/esm/live-query-internals.d.ts +3 -1
  18. package/dist/esm/live-query-internals.js.map +1 -1
  19. package/dist/esm/source-id-bindings.d.ts +6 -17
  20. package/dist/esm/source-id-bindings.js +9 -40
  21. package/dist/esm/source-id-bindings.js.map +1 -1
  22. package/dist/esm/useLiveInfiniteQuery.d.ts +3 -14
  23. package/dist/esm/useLiveInfiniteQuery.js +95 -175
  24. package/dist/esm/useLiveInfiniteQuery.js.map +1 -1
  25. package/dist/esm/useLiveQuery.d.ts +37 -136
  26. package/dist/esm/useLiveQuery.js +189 -226
  27. package/dist/esm/useLiveQuery.js.map +1 -1
  28. package/dist/esm/useLiveSuspenseQuery.d.ts +10 -9
  29. package/dist/esm/useLiveSuspenseQuery.js +71 -64
  30. package/dist/esm/useLiveSuspenseQuery.js.map +1 -1
  31. package/dist/esm/usePacedMutations.d.ts +1 -1
  32. package/dist/esm/usePacedMutations.js.map +1 -1
  33. package/package.json +3 -3
  34. package/skills/react-db/SKILL.md +24 -15
  35. package/src/live-query-internals.ts +3 -0
  36. package/src/source-id-bindings.ts +16 -62
  37. package/src/useLiveInfiniteQuery.ts +138 -296
  38. package/src/useLiveQuery.ts +329 -495
  39. package/src/useLiveSuspenseQuery.ts +122 -132
  40. package/src/usePacedMutations.ts +1 -1
@@ -4,22 +4,25 @@ import { useRef, useSyncExternalStore } from 'react'
4
4
  import {
5
5
  UnhashableQueryIRError,
6
6
  createLiveQueryObserver,
7
+ createLiveQuerySourceIdClaims,
7
8
  deepEquals,
8
- getPreparedLiveQueryIdentity,
9
+ getLiveQueryKey,
10
+ getSourceObjectToken,
9
11
  getStableValueHash,
12
+ identifyLiveQuery,
10
13
  isCollection,
11
14
  prepareLiveQueryValue,
15
+ rejectLiveQueryInput,
16
+ releaseDeferredOnReclaim,
12
17
  resolveLiveQueryValue,
18
+ resumeDeferredLiveQueryCollections,
13
19
  } from '@tanstack/db'
14
20
  import { useOptionalDbClient } from './DbProvider'
15
21
  import { setLiveQueryResultInfo } from './live-query-internals'
16
22
  import { shouldWarnInDevelopment } from './development'
17
23
  import {
18
- claimSourceIds,
19
- createSourceIdBindings,
20
24
  getPreparedSources,
21
- getSourceObjectToken,
22
- resumeSyncStarts,
25
+ getUnboundDescriptorReporter,
23
26
  } from './source-id-bindings'
24
27
  import type {
25
28
  Collection,
@@ -31,9 +34,13 @@ import type {
31
34
  InferResultType,
32
35
  InitialQueryBuilder,
33
36
  LiveQueryCollectionConfig,
37
+ LiveQueryIdentityScope,
38
+ LiveQueryKey,
34
39
  LiveQueryObserver,
35
- LiveQueryPersistedStatus,
40
+ LiveQuerySourceIdClaims,
41
+ LiveQueryStatusFlags,
36
42
  NonSingleResult,
43
+ PooledLiveQueries,
37
44
  QueryBuilder,
38
45
  SingleResult,
39
46
  } from '@tanstack/db'
@@ -45,9 +52,7 @@ const DEFAULT_SUSPENSE_GC_TIME_MS = 5000
45
52
  const DERIVED_IDENTITY_SINGLE_RENDER_WARN_MS = 16
46
53
  const DERIVED_IDENTITY_RENDER_COUNT_WARN_THRESHOLD = 10
47
54
  const DERIVED_IDENTITY_TOTAL_WARN_MS = 50
48
- const warnedDepsCallsites = new Set<string>()
49
55
  const warnedDerivedIdentityCallsites = new Set<string>()
50
- const warnedUnhashableIdentityCallsites = new Set<string>()
51
56
  const unpreparedQueryValue = Symbol(`unpreparedQueryValue`)
52
57
 
53
58
  type SuspenseCollection = Collection<object, string | number, {}>
@@ -106,7 +111,7 @@ export type DerivedIdentityProfiler = {
106
111
  }
107
112
 
108
113
  export type UseLiveQueryStatus = CollectionStatus | `disabled`
109
- export type LiveQueryKey = ReadonlyArray<unknown>
114
+ export type { LiveQueryKey }
110
115
  type UseLiveQueryConfigOptions<TContext extends Context> = Omit<
111
116
  LiveQueryCollectionConfig<TContext>,
112
117
  `query`
@@ -119,6 +124,11 @@ type UseLiveQueryConfigOptions<TContext extends Context> = Omit<
119
124
  queryKey?: LiveQueryKey
120
125
  /** Override the nearest DbProvider for this query. */
121
126
  client?: DbClient
127
+ /**
128
+ * Pass `pooledLiveQueries` to share one partition among equality-filtered
129
+ * queries of one shape. Suspense and DbClient queries never pool.
130
+ */
131
+ pool?: PooledLiveQueries
122
132
  }
123
133
 
124
134
  type ConfiguredQueryBuilder<TContext extends Context> = Extract<
@@ -139,27 +149,6 @@ export type ConditionalUseLiveQueryConfig<TContext extends Context> =
139
149
  ) => ConfiguredQueryBuilder<TContext> | undefined | null)
140
150
  }
141
151
 
142
- export function warnDeprecatedDepsArray(
143
- hookName: `useLiveQuery` | `useLiveInfiniteQuery` = `useLiveQuery`,
144
- ): void {
145
- if (!shouldWarnInDevelopment(`TANSTACK_DB_DISABLE_DEPRECATION_WARNINGS`)) {
146
- return
147
- }
148
-
149
- const callsite = getWarningCallsite(4)
150
- if (warnedDepsCallsites.has(callsite)) {
151
- return
152
- }
153
- warnedDepsCallsites.add(callsite)
154
- const replacement =
155
- hookName === `useLiveQuery`
156
- ? `useLiveQuery({ query })`
157
- : `useLiveInfiniteQuery(query, { queryKey })`
158
- console.warn(
159
- `[${hookName}] The dependency-array form is deprecated and will be removed in 1.0. Use ${replacement} instead. Provide queryKey only for functional/opaque queries or to avoid deriving identity from structured query IR on render.`,
160
- )
161
- }
162
-
163
152
  function getCurrentTime(): number {
164
153
  return typeof performance !== `undefined` &&
165
154
  typeof performance.now === `function`
@@ -187,7 +176,10 @@ function warnDerivedIdentityHotPath(
187
176
  ): void {
188
177
  if (
189
178
  profiler.warned ||
190
- !shouldWarnInDevelopment(`TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`)
179
+ !(
180
+ shouldWarnInDevelopment(`TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`) &&
181
+ process.env.NODE_ENV !== `production`
182
+ )
191
183
  ) {
192
184
  return
193
185
  }
@@ -221,14 +213,6 @@ function warnDerivedIdentityHotPath(
221
213
  )
222
214
  }
223
215
 
224
- function getExplicitQueryKey(value: unknown): LiveQueryKey | undefined {
225
- return value &&
226
- typeof value === `object` &&
227
- Array.isArray((value as { queryKey?: unknown }).queryKey)
228
- ? (value as { queryKey: LiveQueryKey }).queryKey
229
- : undefined
230
- }
231
-
232
216
  function getExplicitDbClient(value: unknown): DbClient | undefined {
233
217
  return value &&
234
218
  typeof value === `object` &&
@@ -246,43 +230,52 @@ export function prepareQueryValue(
246
230
  return prepareLiveQueryValue(value, dbClient, deferredCollections)
247
231
  }
248
232
 
249
- type DerivedQueryPreparation =
250
- | {
251
- status: `hashable`
252
- value: unknown
253
- identityDeps: Array<unknown>
254
- }
255
- | {
256
- status: `unhashable`
257
- value: unknown
258
- error: UnhashableQueryIRError
259
- }
233
+ type DerivedQueryPreparation = {
234
+ value: unknown
235
+ identityDeps: Array<unknown>
236
+ }
237
+
238
+ /** How a hook checks a prepared query whose identity it compares. */
239
+ export function getIdentityScope(
240
+ claims: LiveQuerySourceIdClaims,
241
+ dbClient: DbClient | undefined,
242
+ hookName: string,
243
+ ): LiveQueryIdentityScope {
244
+ return {
245
+ claims,
246
+ client: dbClient,
247
+ bindingName: hookName,
248
+ onUnboundDescriptor: getUnboundDescriptorReporter(dbClient, hookName),
249
+ }
250
+ }
260
251
 
252
+ /**
253
+ * Prepare a query without `queryKey`, derive its identity, and claim its
254
+ * source Collection IDs. A query whose IR has no stable identity throws
255
+ * `UnhashableQueryIRError`. The hook's render boundary releases the sync
256
+ * starts a rejected render deferred.
257
+ */
261
258
  export function prepareDerivedQuery(
262
259
  value: unknown,
263
- dbClient: DbClient | undefined,
264
260
  profiler: DerivedIdentityProfiler,
261
+ scope: LiveQueryIdentityScope,
265
262
  deferredCollections: Set<CollectionImpl<any, string | number, any, any, any>>,
266
263
  ): DerivedQueryPreparation {
267
- const shouldProfile = shouldWarnInDevelopment(
268
- `TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`,
269
- )
264
+ const shouldProfile =
265
+ shouldWarnInDevelopment(`TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`) &&
266
+ process.env.NODE_ENV !== `production`
270
267
  const start = shouldProfile ? getCurrentTime() : 0
271
- const preparedValue = prepareQueryValue(value, dbClient, deferredCollections)
268
+ const preparedValue = prepareQueryValue(
269
+ value,
270
+ scope.client,
271
+ deferredCollections,
272
+ )
272
273
 
273
274
  try {
274
- const identity = getPreparedLiveQueryIdentity(preparedValue)
275
275
  return {
276
- status: `hashable`,
277
276
  value: preparedValue,
278
- identityDeps: [`derived`, identity],
279
- }
280
- } catch (error) {
281
- if (error instanceof UnhashableQueryIRError) {
282
- return { status: `unhashable`, value: preparedValue, error }
277
+ identityDeps: [`derived`, identifyLiveQuery(preparedValue, scope)],
283
278
  }
284
-
285
- throw error
286
279
  } finally {
287
280
  if (shouldProfile) {
288
281
  const durationMs = getCurrentTime() - start
@@ -294,30 +287,9 @@ export function prepareDerivedQuery(
294
287
  }
295
288
  }
296
289
 
297
- export function warnUnhashableDerivedIdentity(
298
- error: UnhashableQueryIRError,
299
- ): void {
300
- if (!shouldWarnInDevelopment(`TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`)) {
301
- return
302
- }
303
-
304
- const callsite = getWarningCallsite(4)
305
- if (warnedUnhashableIdentityCallsites.has(callsite)) {
306
- return
307
- }
308
- warnedUnhashableIdentityCallsites.add(callsite)
309
-
310
- console.warn(
311
- `[useLiveQuery] This query cannot derive a stable identity because ${error.reason} at ${error.path}. ` +
312
- `It will keep the legacy mount-stable behavior for now. Add queryKey: [...] to make captured values reactive. ` +
313
- `Unhashable queries without queryKey will throw in 1.0.`,
314
- )
315
- }
316
-
317
290
  /**
318
291
  * Create a live query using a query function.
319
292
  * @param queryFn - Query function that defines what data to fetch
320
- * @param deps - Deprecated array of dependencies that trigger query re-execution when changed
321
293
  * @returns Object with reactive data, state, and status information
322
294
  * @example
323
295
  * // Prefer config object syntax
@@ -370,12 +342,12 @@ export function warnUnhashableDerivedIdentity(
370
342
  *
371
343
  * @example
372
344
  * // Handle loading and error states
373
- * const { data, isLoading, isError, status } = useLiveQuery({
345
+ * const { data, isLoading, isLoadingError, status } = useLiveQuery({
374
346
  * query: (q) => q.from({ todos: todoCollection })
375
347
  * })
376
348
  *
377
349
  * if (isLoading) return <div>Loading...</div>
378
- * if (isError) return <div>Error: {status}</div>
350
+ * if (isLoadingError) return <div>Error: {status}</div>
379
351
  *
380
352
  * return (
381
353
  * <ul>
@@ -386,66 +358,39 @@ export function warnUnhashableDerivedIdentity(
386
358
  // Overload 1: Accept query function that always returns QueryBuilder
387
359
  export function useLiveQuery<TContext extends Context>(
388
360
  queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>,
389
- deps?: Array<unknown>,
390
361
  ): {
391
362
  state: Map<string | number, GetResult<TContext>>
392
363
  data: InferResultType<TContext>
393
364
  collection: Collection<GetResult<TContext>, string | number, {}>
394
365
  status: CollectionStatus // Can't be disabled if always returns QueryBuilder
395
- isLoading: boolean
396
- isReady: boolean
397
- persistedStatus: LiveQueryPersistedStatus
398
- isPersistedReady: boolean
399
- persistedError: unknown | undefined
400
- isIdle: boolean
401
- isError: boolean
402
- isCleanedUp: boolean
403
366
  isEnabled: true // Always true if always returns QueryBuilder
404
- }
367
+ } & LiveQueryStatusFlags
405
368
 
406
369
  // Overload 2: Accept query function that can return undefined/null
407
370
  export function useLiveQuery<TContext extends Context>(
408
371
  queryFn: (
409
372
  q: InitialQueryBuilder,
410
373
  ) => QueryBuilder<TContext> | undefined | null,
411
- deps?: Array<unknown>,
412
374
  ): {
413
375
  state: Map<string | number, GetResult<TContext>> | undefined
414
376
  data: InferResultType<TContext> | undefined
415
377
  collection: Collection<GetResult<TContext>, string | number, {}> | undefined
416
378
  status: UseLiveQueryStatus
417
- isLoading: boolean
418
- isReady: boolean
419
- persistedStatus: LiveQueryPersistedStatus
420
- isPersistedReady: boolean
421
- persistedError: unknown | undefined
422
- isIdle: boolean
423
- isError: boolean
424
- isCleanedUp: boolean
425
379
  isEnabled: boolean
426
- }
380
+ } & LiveQueryStatusFlags
427
381
 
428
382
  // Overload 3: Accept query function that can return LiveQueryCollectionConfig
429
383
  export function useLiveQuery<TContext extends Context>(
430
384
  queryFn: (
431
385
  q: InitialQueryBuilder,
432
386
  ) => LiveQueryCollectionConfig<TContext> | undefined | null,
433
- deps?: Array<unknown>,
434
387
  ): {
435
388
  state: Map<string | number, GetResult<TContext>> | undefined
436
389
  data: InferResultType<TContext> | undefined
437
390
  collection: Collection<GetResult<TContext>, string | number, {}> | undefined
438
391
  status: UseLiveQueryStatus
439
- isLoading: boolean
440
- isReady: boolean
441
- persistedStatus: LiveQueryPersistedStatus
442
- isPersistedReady: boolean
443
- persistedError: unknown | undefined
444
- isIdle: boolean
445
- isError: boolean
446
- isCleanedUp: boolean
447
392
  isEnabled: boolean
448
- }
393
+ } & LiveQueryStatusFlags
449
394
 
450
395
  // Overload 4: Accept query function that can return Collection
451
396
  export function useLiveQuery<
@@ -456,22 +401,13 @@ export function useLiveQuery<
456
401
  queryFn: (
457
402
  q: InitialQueryBuilder,
458
403
  ) => Collection<TResult, TKey, TUtils> | undefined | null,
459
- deps?: Array<unknown>,
460
404
  ): {
461
405
  state: Map<TKey, TResult> | undefined
462
406
  data: Array<TResult> | undefined
463
407
  collection: Collection<TResult, TKey, TUtils> | undefined
464
408
  status: UseLiveQueryStatus
465
- isLoading: boolean
466
- isReady: boolean
467
- persistedStatus: LiveQueryPersistedStatus
468
- isPersistedReady: boolean
469
- persistedError: unknown | undefined
470
- isIdle: boolean
471
- isError: boolean
472
- isCleanedUp: boolean
473
409
  isEnabled: boolean
474
- }
410
+ } & LiveQueryStatusFlags
475
411
 
476
412
  // Overload 5: Accept query function that can return all types
477
413
  export function useLiveQuery<
@@ -488,7 +424,6 @@ export function useLiveQuery<
488
424
  | Collection<TResult, TKey, TUtils>
489
425
  | undefined
490
426
  | null,
491
- deps?: Array<unknown>,
492
427
  ): {
493
428
  state:
494
429
  Map<string | number, GetResult<TContext>> | Map<TKey, TResult> | undefined
@@ -498,21 +433,12 @@ export function useLiveQuery<
498
433
  | Collection<TResult, TKey, TUtils>
499
434
  | undefined
500
435
  status: UseLiveQueryStatus
501
- isLoading: boolean
502
- isReady: boolean
503
- persistedStatus: LiveQueryPersistedStatus
504
- isPersistedReady: boolean
505
- persistedError: unknown | undefined
506
- isIdle: boolean
507
- isError: boolean
508
- isCleanedUp: boolean
509
436
  isEnabled: boolean
510
- }
437
+ } & LiveQueryStatusFlags
511
438
 
512
439
  /**
513
440
  * Create a live query using configuration object
514
441
  * @param config - Configuration object with query and options
515
- * @param deps - Deprecated array of dependencies that trigger query re-execution when changed
516
442
  * @returns Object with reactive data, state, and status information
517
443
  * @example
518
444
  * // Basic config object usage
@@ -534,12 +460,12 @@ export function useLiveQuery<
534
460
  *
535
461
  * @example
536
462
  * // Handle all states uniformly
537
- * const { data, isLoading, isReady, isError } = useLiveQuery({
463
+ * const { data, isLoading, isReady, isLoadingError } = useLiveQuery({
538
464
  * query: (q) => q.from({ items: itemCollection })
539
465
  * })
540
466
  *
541
467
  * if (isLoading) return <div>Loading...</div>
542
- * if (isError) return <div>Something went wrong</div>
468
+ * if (isLoadingError) return <div>Something went wrong</div>
543
469
  * if (!isReady) return <div>Preparing...</div>
544
470
  *
545
471
  * return <div>{data.length} items loaded</div>
@@ -552,16 +478,8 @@ export function useLiveQuery<TContext extends Context>(
552
478
  data: InferResultType<TContext>
553
479
  collection: Collection<GetResult<TContext>, string | number, {}>
554
480
  status: CollectionStatus // Can't be disabled when query always returns a builder
555
- isLoading: boolean
556
- isReady: boolean
557
- persistedStatus: LiveQueryPersistedStatus
558
- isPersistedReady: boolean
559
- persistedError: unknown | undefined
560
- isIdle: boolean
561
- isError: boolean
562
- isCleanedUp: boolean
563
481
  isEnabled: true // Always true when query always returns a builder
564
- }
482
+ } & LiveQueryStatusFlags
565
483
 
566
484
  // Overload 7: Accept config object with a query that can return undefined/null
567
485
  export function useLiveQuery<TContext extends Context>(
@@ -571,56 +489,8 @@ export function useLiveQuery<TContext extends Context>(
571
489
  data: InferResultType<TContext> | undefined
572
490
  collection: Collection<GetResult<TContext>, string | number, {}> | undefined
573
491
  status: UseLiveQueryStatus
574
- isLoading: boolean
575
- isReady: boolean
576
- persistedStatus: LiveQueryPersistedStatus
577
- isPersistedReady: boolean
578
- persistedError: unknown | undefined
579
- isIdle: boolean
580
- isError: boolean
581
- isCleanedUp: boolean
582
- isEnabled: boolean
583
- }
584
-
585
- // Overload 8: Accept config object with legacy deps
586
- export function useLiveQuery<TContext extends Context>(
587
- config: LiveQueryCollectionConfig<TContext>,
588
- deps?: Array<unknown>,
589
- ): {
590
- state: Map<string | number, GetResult<TContext>>
591
- data: InferResultType<TContext>
592
- collection: Collection<GetResult<TContext>, string | number, {}>
593
- status: CollectionStatus // Can't be disabled when query always returns a builder
594
- isLoading: boolean
595
- isReady: boolean
596
- persistedStatus: LiveQueryPersistedStatus
597
- isPersistedReady: boolean
598
- persistedError: unknown | undefined
599
- isIdle: boolean
600
- isError: boolean
601
- isCleanedUp: boolean
602
- isEnabled: true // Always true when query always returns a builder
603
- }
604
-
605
- // Overload 9: Accept config object with legacy deps and a query that can return undefined/null
606
- export function useLiveQuery<TContext extends Context>(
607
- config: ConditionalUseLiveQueryConfig<TContext>,
608
- deps: Array<unknown>,
609
- ): {
610
- state: Map<string | number, GetResult<TContext>> | undefined
611
- data: InferResultType<TContext> | undefined
612
- collection: Collection<GetResult<TContext>, string | number, {}> | undefined
613
- status: UseLiveQueryStatus
614
- isLoading: boolean
615
- isReady: boolean
616
- persistedStatus: LiveQueryPersistedStatus
617
- isPersistedReady: boolean
618
- persistedError: unknown | undefined
619
- isIdle: boolean
620
- isError: boolean
621
- isCleanedUp: boolean
622
492
  isEnabled: boolean
623
- }
493
+ } & LiveQueryStatusFlags
624
494
 
625
495
  /**
626
496
  * Subscribe to an existing live query collection
@@ -644,10 +514,10 @@ export function useLiveQuery<TContext extends Context>(
644
514
  *
645
515
  * @example
646
516
  * // Handle states consistently
647
- * const { data, isLoading, isError } = useLiveQuery(sharedCollection)
517
+ * const { data, isLoading, isLoadingError } = useLiveQuery(sharedCollection)
648
518
  *
649
519
  * if (isLoading) return <div>Loading...</div>
650
- * if (isError) return <div>Error loading data</div>
520
+ * if (isLoadingError) return <div>Error loading data</div>
651
521
  *
652
522
  * return <div>{data.map(item => <Item key={item.id} {...item} />)}</div>
653
523
  */
@@ -663,16 +533,8 @@ export function useLiveQuery<
663
533
  data: Array<TResult>
664
534
  collection: Collection<TResult, TKey, TUtils>
665
535
  status: CollectionStatus // Can't be disabled for pre-created live query collections
666
- isLoading: boolean
667
- isReady: boolean
668
- persistedStatus: LiveQueryPersistedStatus
669
- isPersistedReady: boolean
670
- persistedError: unknown | undefined
671
- isIdle: boolean
672
- isError: boolean
673
- isCleanedUp: boolean
674
536
  isEnabled: true // Always true for pre-created live query collections
675
- }
537
+ } & LiveQueryStatusFlags
676
538
 
677
539
  // Overload 10: Accept pre-created live query collection with singleResult: true
678
540
  export function useLiveQuery<
@@ -686,31 +548,17 @@ export function useLiveQuery<
686
548
  data: TResult | undefined
687
549
  collection: Collection<TResult, TKey, TUtils> & SingleResult
688
550
  status: CollectionStatus // Can't be disabled for pre-created live query collections
689
- isLoading: boolean
690
- isReady: boolean
691
- persistedStatus: LiveQueryPersistedStatus
692
- isPersistedReady: boolean
693
- persistedError: unknown | undefined
694
- isIdle: boolean
695
- isError: boolean
696
- isCleanedUp: boolean
697
551
  isEnabled: true // Always true for pre-created live query collections
698
- }
552
+ } & LiveQueryStatusFlags
699
553
 
700
554
  // Implementation - use function overloads to infer the actual collection type
701
- export function useLiveQuery(
702
- configOrQueryOrCollection: any,
703
- deps?: Array<unknown>,
704
- ) {
705
- return useLiveQueryImpl(configOrQueryOrCollection, deps)
555
+ export function useLiveQuery(configOrQueryOrCollection: any) {
556
+ return useLiveQueryImpl(configOrQueryOrCollection)
706
557
  }
707
558
 
708
559
  /** @internal Shared implementation for the Suspense wrapper. */
709
- export function useLiveQueryForSuspense(
710
- configOrQueryOrCollection: any,
711
- deps: Array<unknown> | undefined,
712
- ) {
713
- return useLiveQueryImpl(configOrQueryOrCollection, deps, true)
560
+ export function useLiveQueryForSuspense(configOrQueryOrCollection: any) {
561
+ return useLiveQueryImpl(configOrQueryOrCollection, true)
714
562
  }
715
563
 
716
564
  // What one hook instance keeps across renders. It lives in a single ref
@@ -718,10 +566,9 @@ export function useLiveQueryForSuspense(
718
566
  function createHookInstance(dbClient: DbClient | undefined) {
719
567
  return {
720
568
  collection: null as Collection<object, string | number, {}> | null,
721
- deps: null as Array<unknown> | null,
569
+ identityDeps: null as Array<unknown> | null,
722
570
  config: null as unknown,
723
571
  client: dbClient,
724
- legacyUnhashableIdentity: [`legacy-unhashable`] as Array<unknown>,
725
572
  derivedIdentityProfiler: {
726
573
  renderCount: 0,
727
574
  totalMs: 0,
@@ -731,7 +578,8 @@ function createHookInstance(dbClient: DbClient | undefined) {
731
578
  deferredCollections: new Set<
732
579
  CollectionImpl<any, string | number, any, any, any>
733
580
  >(),
734
- sourceIds: createSourceIdBindings(),
581
+ sourceIds: createLiveQuerySourceIdClaims(),
582
+ cancelReclaimRelease: undefined as (() => void) | undefined,
735
583
  observer: null as LiveQueryObserver<object, string | number> | null,
736
584
  queryHash: undefined as string | undefined,
737
585
  suspenseKey: undefined as string | undefined,
@@ -740,193 +588,147 @@ function createHookInstance(dbClient: DbClient | undefined) {
740
588
  }
741
589
  }
742
590
 
743
- function useLiveQueryImpl(
744
- configOrQueryOrCollection: any,
745
- deps: Array<unknown> | undefined,
746
- forSuspense = false,
747
- ) {
591
+ function useLiveQueryImpl(configOrQueryOrCollection: any, forSuspense = false) {
748
592
  const contextDbClient = useOptionalDbClient()
749
593
  // Check if it's already a collection
750
594
  const inputIsCollection = isCollection(configOrQueryOrCollection)
751
595
  const dbClient = inputIsCollection
752
596
  ? contextDbClient
753
597
  : (getExplicitDbClient(configOrQueryOrCollection) ?? contextDbClient)
754
- const resolvedDeps = deps ?? []
755
598
 
756
599
  const instanceRef = useRef<ReturnType<typeof createHookInstance> | null>(null)
757
600
  const instance = (instanceRef.current ??= createHookInstance(dbClient))
758
- const resumeDeferredCollections = () =>
759
- resumeSyncStarts(instance.deferredCollections)
760
-
761
- const queryKey = !inputIsCollection
762
- ? getExplicitQueryKey(configOrQueryOrCollection)
763
- : undefined
764
- let preparedQueryValue: unknown | typeof unpreparedQueryValue =
765
- unpreparedQueryValue
766
- let identityDeps: ReadonlyArray<unknown>
767
- let streamIdentity: unknown = undefined
768
- let identityError: UnhashableQueryIRError | undefined
601
+ // A commit releases the starts itself, so it drops the reclaim listener.
602
+ const resumeDeferredCollections = () => {
603
+ instance.cancelReclaimRelease?.()
604
+ instance.cancelReclaimRelease = undefined
605
+ resumeDeferredLiveQueryCollections(instance.deferredCollections)
606
+ }
769
607
 
770
- if (queryKey) {
771
- identityDeps = queryKey
772
- streamIdentity = [`queryKey`, queryKey]
773
- } else if (deps !== undefined) {
774
- identityDeps = resolvedDeps
775
- // Deps decide reuse. Only hydration and Suspense read the query hash; the
776
- // development warning about unhashable queries still derives it.
777
- if (
778
- dbClient ||
779
- forSuspense ||
780
- shouldWarnInDevelopment(`TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`)
781
- )
782
- try {
783
- preparedQueryValue = prepareQueryValue(
784
- configOrQueryOrCollection,
785
- dbClient,
786
- instance.deferredCollections,
787
- )
788
- streamIdentity = [
789
- `deps`,
790
- resolvedDeps,
791
- getPreparedLiveQueryIdentity(preparedQueryValue),
792
- ]
793
- } catch (error) {
794
- if (!(error instanceof UnhashableQueryIRError)) throw error
795
- warnUnhashableDerivedIdentity(error)
796
- identityError = error
797
- }
798
- } else if (inputIsCollection) {
799
- identityDeps = []
800
- streamIdentity = [`collection`, configOrQueryOrCollection.id]
801
- } else {
802
- const preparation = prepareDerivedQuery(
803
- configOrQueryOrCollection,
804
- dbClient,
805
- instance.derivedIdentityProfiler,
806
- instance.deferredCollections,
807
- )
808
- preparedQueryValue = preparation.value
809
- if (preparation.status === `hashable`) {
608
+ // The render boundary: a render that throws anywhere after preparation
609
+ // never commits, so it releases the sync starts it deferred here.
610
+ try {
611
+ const queryKey = !inputIsCollection
612
+ ? getLiveQueryKey(configOrQueryOrCollection)
613
+ : undefined
614
+ let preparedQueryValue: unknown | typeof unpreparedQueryValue =
615
+ unpreparedQueryValue
616
+ let identityDeps: ReadonlyArray<unknown>
617
+ let streamIdentity: unknown = undefined
618
+ let identityError: UnhashableQueryIRError | undefined
619
+
620
+ if (queryKey) {
621
+ identityDeps = queryKey
622
+ streamIdentity = [`queryKey`, queryKey]
623
+ } else if (inputIsCollection) {
624
+ identityDeps = []
625
+ streamIdentity = [`collection`, configOrQueryOrCollection.id]
626
+ } else {
627
+ const preparation = prepareDerivedQuery(
628
+ configOrQueryOrCollection,
629
+ instance.derivedIdentityProfiler,
630
+ getIdentityScope(instance.sourceIds, dbClient, `useLiveQuery`),
631
+ instance.deferredCollections,
632
+ )
633
+ preparedQueryValue = preparation.value
810
634
  identityDeps = preparation.identityDeps
811
635
  streamIdentity = preparation.identityDeps
812
- } else {
813
- warnUnhashableDerivedIdentity(preparation.error)
814
- identityDeps = instance.legacyUnhashableIdentity
815
- identityError = preparation.error
816
636
  }
817
- }
818
637
 
819
- let queryHash: string | undefined
820
- if (streamIdentity !== undefined) {
821
- try {
822
- queryHash = getStableValueHash(streamIdentity, `queryKey`)
823
- } catch (error) {
824
- if (error instanceof UnhashableQueryIRError) {
825
- if (queryKey !== undefined) throw error
826
- identityError = error
827
- } else {
828
- throw error
638
+ let queryHash: string | undefined
639
+ if (streamIdentity !== undefined) {
640
+ try {
641
+ queryHash = getStableValueHash(streamIdentity, `queryKey`)
642
+ } catch (error) {
643
+ if (error instanceof UnhashableQueryIRError) {
644
+ if (queryKey !== undefined) throw error
645
+ identityError = error
646
+ } else {
647
+ throw error
648
+ }
829
649
  }
830
650
  }
831
- }
832
-
833
- if (deps !== undefined) {
834
- warnDeprecatedDepsArray()
835
- }
836
-
837
- if (
838
- queryKey === undefined &&
839
- deps === undefined &&
840
- preparedQueryValue !== unpreparedQueryValue
841
- ) {
842
- claimSourceIds(
843
- instance.sourceIds,
844
- preparedQueryValue,
845
- dbClient,
846
- `useLiveQuery`,
847
- resumeDeferredCollections,
848
- )
849
- }
850
651
 
851
- const canReuseSuspenseKey =
852
- forSuspense &&
853
- !inputIsCollection &&
854
- queryHash !== undefined &&
855
- instance.collection !== null &&
856
- instance.client === dbClient &&
857
- instance.queryHash === queryHash &&
858
- instance.suspenseKey !== undefined
652
+ const canReuseSuspenseKey =
653
+ forSuspense &&
654
+ !inputIsCollection &&
655
+ queryHash !== undefined &&
656
+ instance.collection !== null &&
657
+ instance.client === dbClient &&
658
+ instance.queryHash === queryHash &&
659
+ instance.suspenseKey !== undefined
859
660
 
860
- if (
861
- forSuspense &&
862
- !inputIsCollection &&
863
- queryHash &&
864
- !canReuseSuspenseKey &&
865
- preparedQueryValue === unpreparedQueryValue
866
- ) {
867
- preparedQueryValue = prepareQueryValue(
868
- configOrQueryOrCollection,
869
- dbClient,
870
- instance.deferredCollections,
871
- )
872
- }
873
-
874
- const suspenseKey =
875
- queryHash && forSuspense && !inputIsCollection
876
- ? canReuseSuspenseKey
877
- ? instance.suspenseKey
878
- : getSourceQualifiedSuspenseKey(preparedQueryValue, queryHash, dbClient)
879
- : queryHash
661
+ if (
662
+ forSuspense &&
663
+ !inputIsCollection &&
664
+ queryHash &&
665
+ !canReuseSuspenseKey &&
666
+ preparedQueryValue === unpreparedQueryValue
667
+ ) {
668
+ preparedQueryValue = prepareQueryValue(
669
+ configOrQueryOrCollection,
670
+ dbClient,
671
+ instance.deferredCollections,
672
+ )
673
+ }
880
674
 
881
- const suspenseCollections =
882
- forSuspense && !inputIsCollection && suspenseKey
883
- ? getSuspenseCollections(dbClient)
675
+ const suspenseKey =
676
+ queryHash && forSuspense && !inputIsCollection
677
+ ? canReuseSuspenseKey
678
+ ? instance.suspenseKey
679
+ : getSourceQualifiedSuspenseKey(
680
+ preparedQueryValue,
681
+ queryHash,
682
+ dbClient,
683
+ )
684
+ : queryHash
685
+
686
+ const suspenseCollections =
687
+ forSuspense && !inputIsCollection && suspenseKey
688
+ ? getSuspenseCollections(dbClient)
689
+ : undefined
690
+ const suspenseEntry = suspenseKey
691
+ ? suspenseCollections?.get(suspenseKey)
884
692
  : undefined
885
- const suspenseEntry = suspenseKey
886
- ? suspenseCollections?.get(suspenseKey)
887
- : undefined
888
- const suspenseCollection = suspenseEntry?.collection
889
-
890
- const identityChanged =
891
- instance.deps === null ||
892
- (deps !== undefined
893
- ? instance.deps.length !== identityDeps.length ||
894
- instance.deps.some((dep, index) => dep !== identityDeps[index])
895
- : !deepEquals(instance.deps, identityDeps))
896
-
897
- // Check if we need to create/recreate the collection
898
- const needsNewCollection =
899
- !instance.collection ||
900
- (inputIsCollection && instance.config !== configOrQueryOrCollection) ||
901
- (!inputIsCollection && (instance.client !== dbClient || identityChanged))
902
-
903
- if (needsNewCollection) {
904
- if (inputIsCollection) {
905
- // Warn when passing a collection directly with on-demand sync mode
906
- // In on-demand mode, data is only loaded when queries with predicates request it
907
- // Passing the collection directly doesn't provide any predicates, so no data loads
908
- const syncMode = (
909
- configOrQueryOrCollection as { config?: { syncMode?: string } }
910
- ).config?.syncMode
911
- if (
912
- syncMode === `on-demand` &&
913
- shouldWarnInDevelopment(`TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`)
914
- ) {
915
- console.warn(
916
- `[useLiveQuery] Warning: Passing a collection with syncMode "on-demand" directly to useLiveQuery ` +
917
- `will not load any data. In on-demand mode, data is only loaded when queries with predicates request it.\n\n` +
918
- `Instead, use a query builder function:\n` +
919
- ` const { data } = useLiveQuery({ query: (q) => q.from({ c: myCollection }).select(({ c }) => c) })\n\n` +
920
- `Or switch to syncMode "eager" if you want all data to sync automatically.`,
921
- )
922
- }
923
- // It's already a collection, ensure sync is started for React hooks
924
- configOrQueryOrCollection.startSyncImmediate()
925
- instance.collection = configOrQueryOrCollection
926
- instance.config = configOrQueryOrCollection
927
- } else {
928
- if (suspenseCollection) {
929
- instance.collection = suspenseCollection
693
+ const suspenseCollection = suspenseEntry?.collection
694
+
695
+ const identityChanged =
696
+ instance.identityDeps === null ||
697
+ !deepEquals(instance.identityDeps, identityDeps)
698
+
699
+ // Check if we need to create/recreate the collection
700
+ const needsNewCollection =
701
+ !instance.collection ||
702
+ (inputIsCollection && instance.config !== configOrQueryOrCollection) ||
703
+ (!inputIsCollection && (instance.client !== dbClient || identityChanged))
704
+
705
+ if (needsNewCollection) {
706
+ if (inputIsCollection) {
707
+ // Warn when passing a collection directly with on-demand sync mode
708
+ // In on-demand mode, data is only loaded when queries with predicates request it
709
+ // Passing the collection directly doesn't provide any predicates, so no data loads
710
+ const syncMode = (
711
+ configOrQueryOrCollection as { config?: { syncMode?: string } }
712
+ ).config?.syncMode
713
+ if (
714
+ syncMode === `on-demand` &&
715
+ shouldWarnInDevelopment(
716
+ `TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`,
717
+ ) &&
718
+ process.env.NODE_ENV !== `production`
719
+ ) {
720
+ console.warn(
721
+ `[useLiveQuery] Warning: Passing a collection with syncMode "on-demand" directly to useLiveQuery ` +
722
+ `will not load any data. In on-demand mode, data is only loaded when queries with predicates request it.\n\n` +
723
+ `Instead, use a query builder function:\n` +
724
+ ` const { data } = useLiveQuery({ query: (q) => q.from({ c: myCollection }).select(({ c }) => c) })\n\n` +
725
+ `Or switch to syncMode "eager" if you want all data to sync automatically.`,
726
+ )
727
+ }
728
+ // It's already a collection, ensure sync is started for React hooks
729
+ configOrQueryOrCollection.startSyncImmediate()
730
+ instance.collection = configOrQueryOrCollection
731
+ instance.config = configOrQueryOrCollection
930
732
  } else {
931
733
  if (preparedQueryValue === unpreparedQueryValue) {
932
734
  preparedQueryValue = prepareQueryValue(
@@ -935,117 +737,149 @@ function useLiveQueryImpl(
935
737
  instance.deferredCollections,
936
738
  )
937
739
  }
938
- if (!dbClient) {
939
- getPreparedSources(preparedQueryValue, dbClient, `useLiveQuery`)
940
- }
941
- instance.collection = resolveLiveQueryValue(preparedQueryValue, {
942
- gcTime: forSuspense
943
- ? DEFAULT_SUSPENSE_GC_TIME_MS
944
- : DEFAULT_GC_TIME_MS,
945
- // Hydration and Suspense key the live-query Collection by identity.
946
- pool: !forSuspense && !dbClient,
947
- // Without a DbClient, Suspense starts it through a preload below.
948
- startSync: !forSuspense || dbClient !== undefined,
949
- }) as SuspenseCollection | null
950
- if (suspenseCollections && suspenseKey && instance.collection) {
951
- const collection = instance.collection
952
- const removeCleanupListener = collection.on(`status:cleaned-up`, () =>
953
- releaseSuspenseCollection(
954
- suspenseCollections,
955
- suspenseKey,
956
- collection,
957
- ),
958
- )
959
- const removeErrorListener = collection.on(`status:error`, () => {
960
- // Keep the failed collection through React's immediate retry so
961
- // the hook can throw its actual load error to an ErrorBoundary.
962
- // Retire it afterward so a later boundary reset starts fresh.
963
- setTimeout(
740
+
741
+ if (suspenseCollection) {
742
+ instance.collection = suspenseCollection
743
+ } else {
744
+ // A keyed query claims nothing, so it reports an unbound descriptor
745
+ // here; the other forms reported it when they claimed.
746
+ if (!dbClient && queryKey !== undefined) {
747
+ getPreparedSources(preparedQueryValue, dbClient, `useLiveQuery`)
748
+ }
749
+ instance.collection = resolveLiveQueryValue(preparedQueryValue, {
750
+ gcTime: forSuspense
751
+ ? DEFAULT_SUSPENSE_GC_TIME_MS
752
+ : DEFAULT_GC_TIME_MS,
753
+ // Hydration and Suspense key the live-query Collection by identity,
754
+ // so they never pool, even when the config passes a pool.
755
+ pool: forSuspense || dbClient ? false : undefined,
756
+ // Without a DbClient, Suspense starts it through a preload below.
757
+ startSync: !forSuspense || dbClient !== undefined,
758
+ }) as SuspenseCollection | null
759
+ // A render that never commits releases its deferred sync starts
760
+ // when garbage collection reclaims the live query it built. A
761
+ // Collection the callback returned is not this render's to reclaim.
762
+ if (instance.collection && !isCollection(preparedQueryValue)) {
763
+ instance.cancelReclaimRelease?.()
764
+ instance.cancelReclaimRelease = releaseDeferredOnReclaim(
765
+ instance.collection,
766
+ instance.deferredCollections,
767
+ )
768
+ }
769
+ if (suspenseCollections && suspenseKey && instance.collection) {
770
+ const collection = instance.collection
771
+ const removeCleanupListener = collection.on(
772
+ `status:cleaned-up`,
964
773
  () =>
965
774
  releaseSuspenseCollection(
966
775
  suspenseCollections,
967
776
  suspenseKey,
968
777
  collection,
969
778
  ),
970
- 0,
971
779
  )
972
- })
973
- const entry: SuspenseCollectionEntry = {
974
- collection,
975
- removeStatusListeners: () => {
976
- removeCleanupListener()
977
- removeErrorListener()
978
- },
780
+ // Keep a failed or crashed collection through React's immediate
781
+ // retry so the hook can throw its actual error to an ErrorBoundary.
782
+ // Retire it afterward so a later boundary reset starts fresh.
783
+ const retireAfterRetry = () =>
784
+ setTimeout(
785
+ () =>
786
+ releaseSuspenseCollection(
787
+ suspenseCollections,
788
+ suspenseKey,
789
+ collection,
790
+ ),
791
+ 0,
792
+ )
793
+ const removeErrorListener = collection.on(
794
+ `status:error`,
795
+ retireAfterRetry,
796
+ )
797
+ const removeCrashListener = collection.on(
798
+ `status:crashed`,
799
+ retireAfterRetry,
800
+ )
801
+ const entry: SuspenseCollectionEntry = {
802
+ collection,
803
+ removeStatusListeners: () => {
804
+ removeCleanupListener()
805
+ removeErrorListener()
806
+ removeCrashListener()
807
+ },
808
+ }
809
+ suspenseCollections.set(suspenseKey, entry)
979
810
  }
980
- suspenseCollections.set(suspenseKey, entry)
981
811
  }
812
+ instance.config = configOrQueryOrCollection
813
+ instance.identityDeps = [...identityDeps]
982
814
  }
983
- instance.config = configOrQueryOrCollection
984
- instance.deps = [...identityDeps]
815
+ instance.client = dbClient
816
+ instance.queryHash = queryHash
817
+ instance.suspenseKey = suspenseKey
818
+ instance.identityError = identityError
985
819
  }
986
- instance.client = dbClient
987
- instance.queryHash = queryHash
988
- instance.suspenseKey = suspenseKey
989
- instance.identityError = identityError
990
- }
991
820
 
992
- // Recreate the observer when the underlying collection changes. The observer
993
- // is not disposed explicitly here or on unmount: `useSyncExternalStore`
994
- // unsubscribes it when the subscribe changes or the component unmounts, which
995
- // detaches the collection subscription; the observer is then GC'd. (An unmount
996
- // effect that disposed it would misfire under StrictMode/offscreen effect
997
- // replay, leaving a disposed observer in the ref.)
998
- if (needsNewCollection) {
999
- // Defer the initial notify: useSyncExternalStore must not be notified
1000
- // synchronously during subscribe.
1001
- // Wholesale mode: React re-reads getSnapshot() on notify, keeps the
1002
- // hook's pre-observer loading policy, and — because wholesale delivers
1003
- // nothing synchronously during subscribe — never notifies
1004
- // useSyncExternalStore inside its own subscribe call.
1005
- instance.observer = createLiveQueryObserver(instance.collection, {
1006
- mode: `wholesale`,
1007
- client: dbClient,
1008
- queryHash: instance.queryHash,
1009
- onPreload: resumeDeferredCollections,
1010
- })
1011
- }
1012
- const observer = instance.observer!
1013
- // A Suspense render asks for its data. Preloading starts the sync run, so a
1014
- // source that already holds the rows makes it ready before the snapshot is
1015
- // read instead of suspending once. useLiveSuspenseQuery surfaces failures.
1016
- // A DbClient defers source starts past render and owns its streamed query.
1017
- if (
1018
- forSuspense &&
1019
- !dbClient &&
1020
- (instance.collection?.status === `idle` ||
1021
- instance.collection?.status === `cleaned-up`)
1022
- ) {
1023
- observer.preloadForInitialRender().catch(() => {})
1024
- }
821
+ // Recreate the observer when the underlying collection changes. The observer
822
+ // is not disposed explicitly here or on unmount: `useSyncExternalStore`
823
+ // unsubscribes it when the subscribe changes or the component unmounts, which
824
+ // detaches the collection subscription; the observer is then GC'd. (An unmount
825
+ // effect that disposed it would misfire under StrictMode/offscreen effect
826
+ // replay, leaving a disposed observer in the ref.)
827
+ if (needsNewCollection) {
828
+ // Defer the initial notify: useSyncExternalStore must not be notified
829
+ // synchronously during subscribe.
830
+ // Wholesale mode: React re-reads getSnapshot() on notify, keeps the
831
+ // hook's pre-observer loading policy, and — because wholesale delivers
832
+ // nothing synchronously during subscribe — never notifies
833
+ // useSyncExternalStore inside its own subscribe call.
834
+ instance.observer = createLiveQueryObserver(instance.collection, {
835
+ mode: `wholesale`,
836
+ client: dbClient,
837
+ queryHash: instance.queryHash,
838
+ onPreload: resumeDeferredCollections,
839
+ suspendUntilReady: forSuspense,
840
+ })
841
+ }
842
+ const observer = instance.observer!
843
+ // A Suspense render asks for its data. Preloading starts the sync run, so a
844
+ // source that already holds the rows makes it ready before the snapshot is
845
+ // read instead of suspending once. useLiveSuspenseQuery surfaces failures.
846
+ // A DbClient defers source starts past render and owns its streamed query.
847
+ if (
848
+ forSuspense &&
849
+ !dbClient &&
850
+ (instance.collection?.status === `idle` ||
851
+ instance.collection?.status === `cleaned-up`)
852
+ ) {
853
+ observer.preload().catch(() => {})
854
+ }
1025
855
 
1026
- // Stable subscribe bound to the current observer; the observer owns the
1027
- // subscription, ready-race, and disposal.
1028
- if (!instance.subscribe || needsNewCollection) {
1029
- instance.subscribe = (onStoreChange: () => void) => {
1030
- const unsubscribe = observer.subscribe(onStoreChange)
1031
- resumeDeferredCollections()
1032
- return unsubscribe
856
+ // Stable subscribe bound to the current observer; the observer owns the
857
+ // subscription, ready-race, and disposal.
858
+ if (!instance.subscribe || needsNewCollection) {
859
+ instance.subscribe = (onStoreChange: () => void) => {
860
+ const unsubscribe = observer.subscribe(onStoreChange)
861
+ resumeDeferredCollections()
862
+ return unsubscribe
863
+ }
1033
864
  }
1034
- }
1035
865
 
1036
- const returned = useSyncExternalStore(
1037
- instance.subscribe,
1038
- () => observer.getSnapshot(),
1039
- () => observer.getServerSnapshot(),
1040
- )
1041
- // Only useLiveSuspenseQuery reads this, and it costs a define per render.
1042
- if (forSuspense) {
1043
- setLiveQueryResultInfo(returned, {
1044
- client: dbClient,
1045
- queryHash: instance.queryHash,
1046
- identityError: instance.identityError,
1047
- observer,
1048
- })
866
+ const returned = useSyncExternalStore(
867
+ instance.subscribe,
868
+ () => observer.getSnapshot(),
869
+ () => observer.getServerSnapshot(forSuspense),
870
+ )
871
+ // Only useLiveSuspenseQuery reads this, and it costs a define per render.
872
+ if (forSuspense) {
873
+ setLiveQueryResultInfo(returned, {
874
+ client: dbClient,
875
+ queryHash: instance.queryHash,
876
+ identityError: instance.identityError,
877
+ observer,
878
+ deferredCollections: instance.deferredCollections,
879
+ })
880
+ }
881
+ return returned as any
882
+ } catch (error) {
883
+ return rejectLiveQueryInput(instance.deferredCollections, error)
1049
884
  }
1050
- return returned as any
1051
885
  }