@tanstack/react-db 0.4.1 → 0.5.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.
@@ -3,6 +3,7 @@
3
3
  import { useRef, useSyncExternalStore } from 'react'
4
4
  import {
5
5
  BaseQueryBuilder,
6
+ IR,
6
7
  UnhashableQueryIRError,
7
8
  createLiveQueryCollection,
8
9
  createLiveQueryObserver,
@@ -25,12 +26,16 @@ import type {
25
26
  InitialQueryBuilder,
26
27
  LiveQueryCollectionConfig,
27
28
  LiveQueryObserver,
29
+ LiveQueryPersistedStatus,
28
30
  NonSingleResult,
29
31
  QueryBuilder,
30
32
  SingleResult,
31
33
  } from '@tanstack/db'
32
34
 
33
35
  const DEFAULT_GC_TIME_MS = 1 // Live queries created by useLiveQuery are cleaned up immediately (0 disables GC)
36
+ // Suspense renders can be abandoned before React subscribes. Keep their
37
+ // collection briefly so a nearby retry can commit without starting over.
38
+ const DEFAULT_SUSPENSE_GC_TIME_MS = 5000
34
39
  const DERIVED_IDENTITY_SINGLE_RENDER_WARN_MS = 16
35
40
  const DERIVED_IDENTITY_RENDER_COUNT_WARN_THRESHOLD = 10
36
41
  const DERIVED_IDENTITY_TOTAL_WARN_MS = 50
@@ -39,6 +44,72 @@ const warnedDerivedIdentityCallsites = new Set<string>()
39
44
  const warnedUnhashableIdentityCallsites = new Set<string>()
40
45
  const unpreparedQueryValue = Symbol(`unpreparedQueryValue`)
41
46
 
47
+ type SuspenseCollection = Collection<object, string | number, {}>
48
+ type SuspenseCollectionEntry = {
49
+ collection: SuspenseCollection
50
+ removeStatusListeners: () => void
51
+ }
52
+
53
+ const unscopedSuspenseCollections = new Map<string, SuspenseCollectionEntry>()
54
+ const suspenseCollectionsByClient = new WeakMap<
55
+ DbClient,
56
+ Map<string, SuspenseCollectionEntry>
57
+ >()
58
+ const suspenseSourceIds = new WeakMap<object, number>()
59
+ let nextSuspenseSourceId = 0
60
+
61
+ function getSuspenseSourceId(source: object): number {
62
+ let id = suspenseSourceIds.get(source)
63
+ if (id === undefined) {
64
+ id = ++nextSuspenseSourceId
65
+ suspenseSourceIds.set(source, id)
66
+ }
67
+ return id
68
+ }
69
+
70
+ function getUnscopedSuspenseKey(
71
+ preparedValue: unknown,
72
+ queryHash: string,
73
+ ): string {
74
+ const query =
75
+ preparedValue instanceof BaseQueryBuilder
76
+ ? preparedValue
77
+ : preparedValue &&
78
+ typeof preparedValue === `object` &&
79
+ `query` in preparedValue &&
80
+ preparedValue.query instanceof BaseQueryBuilder
81
+ ? preparedValue.query
82
+ : undefined
83
+ if (!query) return queryHash
84
+ const sourceIds = IR.collectCollectionSources(query._getQuery()).map(
85
+ ({ collection }) => getSuspenseSourceId(collection),
86
+ )
87
+ return `${sourceIds.join(`,`)}:${queryHash}`
88
+ }
89
+
90
+ function getSuspenseCollections(
91
+ client: DbClient | undefined,
92
+ ): Map<string, SuspenseCollectionEntry> {
93
+ if (!client) return unscopedSuspenseCollections
94
+ let collections = suspenseCollectionsByClient.get(client)
95
+ if (!collections) {
96
+ collections = new Map()
97
+ suspenseCollectionsByClient.set(client, collections)
98
+ }
99
+ return collections
100
+ }
101
+
102
+ function releaseSuspenseCollection(
103
+ collections: Map<string, SuspenseCollectionEntry>,
104
+ queryHash: string,
105
+ collection: SuspenseCollection,
106
+ ): void {
107
+ const entry = collections.get(queryHash)
108
+ if (entry?.collection !== collection) return
109
+ collections.delete(queryHash)
110
+ entry.removeStatusListeners()
111
+ }
112
+
42
113
  export type DerivedIdentityProfiler = {
43
114
  renderCount: number
44
115
  totalMs: number
@@ -120,7 +191,16 @@ function getCurrentTime(): number {
120
191
 
121
192
  function getWarningCallsite(stackIndex: number): string {
122
193
  const stack = new Error().stack ?? `unknown`
123
- return stack.split(`\n`)[stackIndex]?.trim() ?? stack
194
+ const lines = stack.split(`\n`)
195
+ const userFrame = lines
196
+ .slice(1)
197
+ .find(
198
+ (line) =>
199
+ !line.includes(`useLiveQuery.ts`) &&
200
+ !line.includes(`useLiveSuspenseQuery.ts`) &&
201
+ !line.includes(`useLiveInfiniteQuery.ts`),
202
+ )
203
+ return userFrame?.trim() ?? lines[stackIndex]?.trim() ?? stack
124
204
  }
125
205
 
126
206
  function warnDerivedIdentityHotPath(
@@ -256,7 +336,10 @@ export function warnUnhashableDerivedIdentity(
256
336
  )
257
337
  }
258
338
 
259
- function createCollectionFromPreparedQuery(value: unknown) {
339
+ function createCollectionFromPreparedQuery(
340
+ value: unknown,
341
+ defaultGcTime = DEFAULT_GC_TIME_MS,
342
+ ) {
260
343
  if (value === undefined || value === null) {
261
344
  return null
262
345
  }
@@ -270,14 +353,14 @@ function createCollectionFromPreparedQuery(value: unknown) {
270
353
  return createLiveQueryCollection({
271
354
  query: value,
272
355
  startSync: true,
273
- gcTime: DEFAULT_GC_TIME_MS,
356
+ gcTime: defaultGcTime,
274
357
  })
275
358
  }
276
359
 
277
360
  if (typeof value === `object`) {
278
361
  return createLiveQueryCollection({
279
362
  startSync: true,
280
- gcTime: DEFAULT_GC_TIME_MS,
363
+ gcTime: defaultGcTime,
281
364
  ...(value as LiveQueryCollectionConfig<any>),
282
365
  })
283
366
  }
@@ -367,6 +450,9 @@ export function useLiveQuery<TContext extends Context>(
367
450
  status: CollectionStatus // Can't be disabled if always returns QueryBuilder
368
451
  isLoading: boolean
369
452
  isReady: boolean
453
+ persistedStatus: LiveQueryPersistedStatus
454
+ isPersistedReady: boolean
455
+ persistedError: unknown | undefined
370
456
  isIdle: boolean
371
457
  isError: boolean
372
458
  isCleanedUp: boolean
@@ -386,6 +472,9 @@ export function useLiveQuery<TContext extends Context>(
386
472
  status: UseLiveQueryStatus
387
473
  isLoading: boolean
388
474
  isReady: boolean
475
+ persistedStatus: LiveQueryPersistedStatus
476
+ isPersistedReady: boolean
477
+ persistedError: unknown | undefined
389
478
  isIdle: boolean
390
479
  isError: boolean
391
480
  isCleanedUp: boolean
@@ -405,6 +494,9 @@ export function useLiveQuery<TContext extends Context>(
405
494
  status: UseLiveQueryStatus
406
495
  isLoading: boolean
407
496
  isReady: boolean
497
+ persistedStatus: LiveQueryPersistedStatus
498
+ isPersistedReady: boolean
499
+ persistedError: unknown | undefined
408
500
  isIdle: boolean
409
501
  isError: boolean
410
502
  isCleanedUp: boolean
@@ -428,6 +520,9 @@ export function useLiveQuery<
428
520
  status: UseLiveQueryStatus
429
521
  isLoading: boolean
430
522
  isReady: boolean
523
+ persistedStatus: LiveQueryPersistedStatus
524
+ isPersistedReady: boolean
525
+ persistedError: unknown | undefined
431
526
  isIdle: boolean
432
527
  isError: boolean
433
528
  isCleanedUp: boolean
@@ -463,6 +558,9 @@ export function useLiveQuery<
463
558
  status: UseLiveQueryStatus
464
559
  isLoading: boolean
465
560
  isReady: boolean
561
+ persistedStatus: LiveQueryPersistedStatus
562
+ isPersistedReady: boolean
563
+ persistedError: unknown | undefined
466
564
  isIdle: boolean
467
565
  isError: boolean
468
566
  isCleanedUp: boolean
@@ -514,6 +612,9 @@ export function useLiveQuery<TContext extends Context>(
514
612
  status: CollectionStatus // Can't be disabled when query always returns a builder
515
613
  isLoading: boolean
516
614
  isReady: boolean
615
+ persistedStatus: LiveQueryPersistedStatus
616
+ isPersistedReady: boolean
617
+ persistedError: unknown | undefined
517
618
  isIdle: boolean
518
619
  isError: boolean
519
620
  isCleanedUp: boolean
@@ -530,6 +631,9 @@ export function useLiveQuery<TContext extends Context>(
530
631
  status: UseLiveQueryStatus
531
632
  isLoading: boolean
532
633
  isReady: boolean
634
+ persistedStatus: LiveQueryPersistedStatus
635
+ isPersistedReady: boolean
636
+ persistedError: unknown | undefined
533
637
  isIdle: boolean
534
638
  isError: boolean
535
639
  isCleanedUp: boolean
@@ -547,6 +651,9 @@ export function useLiveQuery<TContext extends Context>(
547
651
  status: CollectionStatus // Can't be disabled when query always returns a builder
548
652
  isLoading: boolean
549
653
  isReady: boolean
654
+ persistedStatus: LiveQueryPersistedStatus
655
+ isPersistedReady: boolean
656
+ persistedError: unknown | undefined
550
657
  isIdle: boolean
551
658
  isError: boolean
552
659
  isCleanedUp: boolean
@@ -564,6 +671,9 @@ export function useLiveQuery<TContext extends Context>(
564
671
  status: UseLiveQueryStatus
565
672
  isLoading: boolean
566
673
  isReady: boolean
674
+ persistedStatus: LiveQueryPersistedStatus
675
+ isPersistedReady: boolean
676
+ persistedError: unknown | undefined
567
677
  isIdle: boolean
568
678
  isError: boolean
569
679
  isCleanedUp: boolean
@@ -613,6 +723,9 @@ export function useLiveQuery<
613
723
  status: CollectionStatus // Can't be disabled for pre-created live query collections
614
724
  isLoading: boolean
615
725
  isReady: boolean
726
+ persistedStatus: LiveQueryPersistedStatus
727
+ isPersistedReady: boolean
728
+ persistedError: unknown | undefined
616
729
  isIdle: boolean
617
730
  isError: boolean
618
731
  isCleanedUp: boolean
@@ -633,6 +746,9 @@ export function useLiveQuery<
633
746
  status: CollectionStatus // Can't be disabled for pre-created live query collections
634
747
  isLoading: boolean
635
748
  isReady: boolean
749
+ persistedStatus: LiveQueryPersistedStatus
750
+ isPersistedReady: boolean
751
+ persistedError: unknown | undefined
636
752
  isIdle: boolean
637
753
  isError: boolean
638
754
  isCleanedUp: boolean
@@ -643,6 +759,22 @@ export function useLiveQuery<
643
759
  export function useLiveQuery(
644
760
  configOrQueryOrCollection: any,
645
761
  deps?: Array<unknown>,
762
+ ) {
763
+ return useLiveQueryImpl(configOrQueryOrCollection, deps)
764
+ }
765
+
766
+ /** @internal Shared implementation for the Suspense wrapper. */
767
+ export function useLiveQueryForSuspense(
768
+ configOrQueryOrCollection: any,
769
+ deps: Array<unknown> | undefined,
770
+ ) {
771
+ return useLiveQueryImpl(configOrQueryOrCollection, deps, true)
772
+ }
773
+
774
+ function useLiveQueryImpl(
775
+ configOrQueryOrCollection: any,
776
+ deps: Array<unknown> | undefined,
777
+ forSuspense = false,
646
778
  ) {
647
779
  const contextDbClient = useOptionalDbClient()
648
780
  // Check if it's already a collection
@@ -676,6 +808,7 @@ export function useLiveQuery(
676
808
  null,
677
809
  )
678
810
  const queryHashRef = useRef<string | undefined>(undefined)
811
+ const suspenseKeyRef = useRef<string | undefined>(undefined)
679
812
  const identityErrorRef = useRef<UnhashableQueryIRError | undefined>(undefined)
680
813
 
681
814
  const queryKey = !inputIsCollection
@@ -747,6 +880,47 @@ export function useLiveQuery(
747
880
  warnDeprecatedDepsArray()
748
881
  }
749
882
 
883
+ const canReuseSuspenseKey =
884
+ forSuspense &&
885
+ !inputIsCollection &&
886
+ queryHash !== undefined &&
887
+ !dbClient &&
888
+ collectionRef.current !== null &&
889
+ clientRef.current === dbClient &&
890
+ queryHashRef.current === queryHash &&
891
+ suspenseKeyRef.current !== undefined
892
+
893
+ if (
894
+ forSuspense &&
895
+ !inputIsCollection &&
896
+ queryHash &&
897
+ !dbClient &&
898
+ !canReuseSuspenseKey &&
899
+ preparedQueryValue === unpreparedQueryValue
900
+ ) {
901
+ preparedQueryValue = prepareQueryValue(
902
+ configOrQueryOrCollection,
903
+ dbClient,
904
+ deferredCollectionsRef.current,
905
+ )
906
+ }
907
+
908
+ const suspenseKey =
909
+ queryHash && !dbClient
910
+ ? canReuseSuspenseKey
911
+ ? suspenseKeyRef.current
912
+ : getUnscopedSuspenseKey(preparedQueryValue, queryHash)
913
+ : queryHash
914
+
915
+ const suspenseCollections =
916
+ forSuspense && !inputIsCollection && suspenseKey
917
+ ? getSuspenseCollections(dbClient)
918
+ : undefined
919
+ const suspenseEntry = suspenseKey
920
+ ? suspenseCollections?.get(suspenseKey)
921
+ : undefined
922
+ const suspenseCollection = suspenseEntry?.collection
923
+
750
924
  const identityChanged =
751
925
  depsRef.current === null ||
752
926
  (deps !== undefined
@@ -792,21 +966,59 @@ export function useLiveQuery(
792
966
  collectionRef.current = configOrQueryOrCollection
793
967
  configRef.current = configOrQueryOrCollection
794
968
  } else {
795
- if (preparedQueryValue === unpreparedQueryValue) {
796
- preparedQueryValue = prepareQueryValue(
797
- configOrQueryOrCollection,
798
- dbClient,
799
- deferredCollectionsRef.current,
800
- )
969
+ if (suspenseCollection) {
970
+ collectionRef.current = suspenseCollection
971
+ } else {
972
+ if (preparedQueryValue === unpreparedQueryValue) {
973
+ preparedQueryValue = prepareQueryValue(
974
+ configOrQueryOrCollection,
975
+ dbClient,
976
+ deferredCollectionsRef.current,
977
+ )
978
+ }
979
+ collectionRef.current = createCollectionFromPreparedQuery(
980
+ preparedQueryValue,
981
+ forSuspense ? DEFAULT_SUSPENSE_GC_TIME_MS : DEFAULT_GC_TIME_MS,
982
+ ) as SuspenseCollection | null
983
+ if (suspenseCollections && suspenseKey && collectionRef.current) {
984
+ const collection = collectionRef.current
985
+ const removeCleanupListener = collection.on(`status:cleaned-up`, () =>
986
+ releaseSuspenseCollection(
987
+ suspenseCollections,
988
+ suspenseKey,
989
+ collection,
990
+ ),
991
+ )
992
+ const removeErrorListener = collection.on(`status:error`, () => {
993
+ // Keep the failed collection through React's immediate retry so
994
+ // the hook can throw its actual load error to an ErrorBoundary.
995
+ // Retire it afterward so a later boundary reset starts fresh.
996
+ setTimeout(
997
+ () =>
998
+ releaseSuspenseCollection(
999
+ suspenseCollections,
1000
+ suspenseKey,
1001
+ collection,
1002
+ ),
1003
+ 0,
1004
+ )
1005
+ })
1006
+ const entry: SuspenseCollectionEntry = {
1007
+ collection,
1008
+ removeStatusListeners: () => {
1009
+ removeCleanupListener()
1010
+ removeErrorListener()
1011
+ },
1012
+ }
1013
+ suspenseCollections.set(suspenseKey, entry)
1014
+ }
801
1015
  }
802
- collectionRef.current = createCollectionFromPreparedQuery(
803
- preparedQueryValue,
804
- ) as Collection<object, string | number, {}>
805
1016
  configRef.current = configOrQueryOrCollection
806
1017
  depsRef.current = [...identityDeps]
807
1018
  }
808
1019
  clientRef.current = dbClient
809
1020
  queryHashRef.current = queryHash
1021
+ suspenseKeyRef.current = suspenseKey
810
1022
  identityErrorRef.current = identityError
811
1023
  }
812
1024
 
@@ -1,12 +1,13 @@
1
1
  'use client'
2
2
 
3
3
  import { useRef } from 'react'
4
- import { useLiveQuery } from './useLiveQuery'
4
+ import { useLiveQueryForSuspense } from './useLiveQuery'
5
5
  import { getLiveQueryResultInfo } from './live-query-internals'
6
6
  import type { UseLiveQueryConfig } from './useLiveQuery'
7
7
  import type {
8
8
  Collection,
9
9
  Context,
10
+ DbClient,
10
11
  GetResult,
11
12
  InferResultType,
12
13
  InitialQueryBuilder,
@@ -16,6 +17,46 @@ import type {
16
17
  SingleResult,
17
18
  } from '@tanstack/db'
18
19
 
20
+ // React can discard a render that suspends, including its refs. Keep the
21
+ // initial-render failure across retries, scoped by client because streamed
22
+ // preloads of the same collection can fail independently.
23
+ const initialRenderErrors = new WeakMap<
24
+ Collection<any, any, any>,
25
+ {
26
+ byClient: WeakMap<DbClient, { error: unknown; clientQuery?: object }>
27
+ unscoped?: { error: unknown; clientQuery?: object }
28
+ }
29
+ >()
30
+
31
+ function clearInitialRenderError(
32
+ collection: Collection<any, any, any>,
33
+ client: DbClient | undefined,
34
+ ) {
35
+ const entry = initialRenderErrors.get(collection)
36
+ if (!entry) return
37
+ if (client) entry.byClient.delete(client)
38
+ else delete entry.unscoped
39
+ }
40
+
41
+ function rememberInitialRenderError(
42
+ collection: Collection<any, any, any>,
43
+ client: DbClient | undefined,
44
+ error: unknown,
45
+ clientQuery?: object,
46
+ ) {
47
+ if (collection.status === `cleaned-up`) return
48
+ let entry = initialRenderErrors.get(collection)
49
+ if (!entry) {
50
+ entry = { byClient: new WeakMap() }
51
+ collection.once(`status:cleaned-up`, () => {
52
+ initialRenderErrors.delete(collection)
53
+ })
54
+ initialRenderErrors.set(collection, entry)
55
+ }
56
+ if (client) entry.byClient.set(client, { error, clientQuery })
57
+ else entry.unscoped = { error }
58
+ }
59
+
19
60
  /**
20
61
  * Create a live query with React Suspense support
21
62
  * @param queryFn - Query function that defines what data to fetch
@@ -173,19 +214,9 @@ export function useLiveSuspenseQuery(
173
214
  // Use useLiveQuery to handle collection management and reactivity
174
215
  const result =
175
216
  deps === undefined
176
- ? useLiveQuery(configOrQueryOrCollection)
177
- : useLiveQuery(configOrQueryOrCollection, deps)
178
- const queryInfo = getLiveQueryResultInfo(result)
179
-
180
- // Reset promise and ready state when query identity changes
181
- if (collectionRef.current !== result.collection) {
182
- promiseRef.current = null
183
- collectionRef.current = result.collection
184
- hasBeenReadyRef.current = false
185
- }
217
+ ? useLiveQueryForSuspense(configOrQueryOrCollection, undefined)
218
+ : useLiveQueryForSuspense(configOrQueryOrCollection, deps)
186
219
 
187
- // SUSPENSE LOGIC: Throw promise or error based on collection status
188
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
189
220
  if (!result.isEnabled) {
190
221
  // Suspense queries cannot be disabled - this matches TanStack Query's useSuspenseQuery behavior
191
222
  throw new Error(
@@ -197,30 +228,82 @@ export function useLiveSuspenseQuery(
197
228
  )
198
229
  }
199
230
 
231
+ const queryInfo = getLiveQueryResultInfo(result)
232
+
233
+ // Reset promise and ready state when query identity changes
234
+ if (collectionRef.current !== result.collection) {
235
+ promiseRef.current = null
236
+ collectionRef.current = result.collection
237
+ hasBeenReadyRef.current = false
238
+ }
239
+
240
+ // SUSPENSE LOGIC: Throw promise or error based on collection status
241
+
200
242
  const collectionStatus = result.collection.status
201
243
 
202
244
  // Track when we reach ready state
203
- if (result.isReady) {
245
+ if (result.isReady || queryInfo.observer.isInitialRenderReady()) {
204
246
  hasBeenReadyRef.current = true
205
247
  promiseRef.current = null
248
+ clearInitialRenderError(result.collection, queryInfo.client)
206
249
  }
207
250
 
208
251
  const observerError = queryInfo.observer.getError()
209
- if (observerError !== undefined && !hasBeenReadyRef.current) {
252
+ // A client request can reject with undefined, which getError() cannot
253
+ // distinguish from no error. The request status preserves that distinction.
254
+ const clientQuery =
255
+ queryInfo.client && queryInfo.queryHash
256
+ ? queryInfo.client._getLiveQuery(queryInfo.queryHash)
257
+ : undefined
258
+ // A configured persisted restore may still satisfy this render after the
259
+ // client query fails. Let the observer classify that failure below.
260
+ if (
261
+ !hasBeenReadyRef.current &&
262
+ (result.persistedStatus === `unavailable` ||
263
+ result.persistedStatus === `error`) &&
264
+ (observerError !== undefined || clientQuery?.status === `error`)
265
+ ) {
210
266
  promiseRef.current = null
211
- throw observerError
267
+ throw observerError === undefined ? clientQuery?.error : observerError
268
+ }
269
+
270
+ const errorEntry = initialRenderErrors.get(result.collection)
271
+ const initialRenderError = queryInfo.client
272
+ ? errorEntry?.byClient.get(queryInfo.client)
273
+ : errorEntry?.unscoped
274
+ // A replacement client query must not inherit the prior request's error.
275
+ if (
276
+ initialRenderError?.clientQuery !== undefined &&
277
+ initialRenderError.clientQuery !== clientQuery
278
+ ) {
279
+ clearInitialRenderError(result.collection, queryInfo.client)
280
+ } else if (initialRenderError && !hasBeenReadyRef.current) {
281
+ promiseRef.current = null
282
+ throw initialRenderError.error
212
283
  }
213
284
 
214
285
  // Only throw errors during initial load (before first ready)
215
286
  // After success, errors surface as stale data (matches TanStack Query behavior)
216
- if (collectionStatus === `error` && !hasBeenReadyRef.current) {
287
+ if (
288
+ collectionStatus === `error` &&
289
+ !hasBeenReadyRef.current &&
290
+ (result.persistedStatus === `unavailable` ||
291
+ result.persistedStatus === `error`)
292
+ ) {
217
293
  promiseRef.current = null
218
294
  // TODO: Once collections hold a reference to their last error object (#671),
219
295
  // we should rethrow that actual error instead of creating a generic message
220
296
  throw new Error(`Collection "${result.collection.id}" failed to load`)
221
297
  }
222
298
 
223
- if (!hasBeenReadyRef.current && (result.isLoading || result.isIdle)) {
299
+ if (
300
+ !hasBeenReadyRef.current &&
301
+ (result.isLoading ||
302
+ result.isIdle ||
303
+ result.isError ||
304
+ (collectionStatus === `error` &&
305
+ result.persistedStatus !== `unavailable`))
306
+ ) {
224
307
  if (queryInfo.client?._isSsrStreamingEnabled() && !queryInfo.queryHash) {
225
308
  const reason = queryInfo.identityError
226
309
  ? `${queryInfo.identityError.reason} at ${queryInfo.identityError.path}`
@@ -229,14 +312,40 @@ export function useLiveSuspenseQuery(
229
312
  `Cannot stream this live query during SSR because ${reason}. Provide an explicit serializable queryKey.`,
230
313
  )
231
314
  }
232
-
233
315
  // Create or reuse promise for current collection
234
316
  if (!promiseRef.current) {
235
- promiseRef.current = queryInfo.observer.preload()
317
+ const collection = result.collection
318
+ const client = queryInfo.client
319
+ const queryHash = queryInfo.queryHash
320
+ let active = true
321
+ const stopWatchingCleanup = collection.once(`status:cleaned-up`, () => {
322
+ active = false
323
+ })
324
+ let preload: Promise<void>
325
+ try {
326
+ preload = queryInfo.observer.preloadForInitialRender()
327
+ } catch (error) {
328
+ stopWatchingCleanup()
329
+ throw error
330
+ }
331
+ const preloadClientQuery =
332
+ client && queryHash ? client._getLiveQuery(queryHash) : undefined
333
+ promiseRef.current = preload
334
+ .catch((error: unknown) => {
335
+ const latestClientQuery =
336
+ client && queryHash ? client._getLiveQuery(queryHash) : undefined
337
+ if (active && latestClientQuery === preloadClientQuery)
338
+ rememberInitialRenderError(
339
+ collection,
340
+ client,
341
+ error,
342
+ preloadClientQuery,
343
+ )
344
+ throw error
345
+ })
346
+ .finally(stopWatchingCleanup)
236
347
  }
237
- // THROW PROMISE - React Suspense catches this (React 18+ required)
238
- // Note: We don't check React version here. In React <18, this will be caught
239
- // by an Error Boundary, which provides a reasonable failure mode.
348
+ // React Suspense catches this promise and retries after preload settles.
240
349
  throw promiseRef.current
241
350
  }
242
351