@tanstack/react-db 0.4.1 → 0.5.0
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/useLiveInfiniteQuery.cjs +3 -0
- package/dist/cjs/useLiveInfiniteQuery.cjs.map +1 -1
- package/dist/cjs/useLiveInfiniteQuery.d.cts +23 -2
- package/dist/cjs/useLiveQuery.cjs +108 -13
- package/dist/cjs/useLiveQuery.cjs.map +1 -1
- package/dist/cjs/useLiveQuery.d.cts +36 -1
- package/dist/cjs/useLiveSuspenseQuery.cjs +67 -12
- package/dist/cjs/useLiveSuspenseQuery.cjs.map +1 -1
- package/dist/esm/useLiveInfiniteQuery.d.ts +23 -2
- package/dist/esm/useLiveInfiniteQuery.js +3 -0
- package/dist/esm/useLiveInfiniteQuery.js.map +1 -1
- package/dist/esm/useLiveQuery.d.ts +36 -1
- package/dist/esm/useLiveQuery.js +109 -14
- package/dist/esm/useLiveQuery.js.map +1 -1
- package/dist/esm/useLiveSuspenseQuery.js +68 -13
- package/dist/esm/useLiveSuspenseQuery.js.map +1 -1
- package/package.json +3 -5
- package/src/useLiveInfiniteQuery.ts +32 -1
- package/src/useLiveQuery.ts +225 -13
- package/src/useLiveSuspenseQuery.ts +132 -23
package/src/useLiveQuery.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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:
|
|
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:
|
|
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 (
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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 {
|
|
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
|
-
?
|
|
177
|
-
:
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|