@tanstack/react-db 0.5.5 → 0.5.8

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.
@@ -9,17 +9,24 @@ import {
9
9
  fetchNextLiveQueryWindowPage,
10
10
  getLiveQueryWindowCollectionWarning,
11
11
  getLiveQueryWindowInputKind,
12
+ liveQueryWindowMatches,
12
13
  normalizeLiveQueryWindowPageSize,
13
14
  resolveLiveQueryWindowInput,
14
15
  shouldPreserveLiveQueryWindowPageCount,
15
16
  } from '@tanstack/db'
16
17
  import { useOptionalDbClient } from './DbProvider'
18
+ import {
19
+ claimSourceIds,
20
+ createSourceIdBindings,
21
+ resumeSyncStarts,
22
+ } from './source-id-bindings'
17
23
  import {
18
24
  prepareDerivedQuery,
19
25
  prepareQueryValue,
20
26
  warnDeprecatedDepsArray,
21
27
  warnUnhashableDerivedIdentity,
22
28
  } from './useLiveQuery'
29
+ import type { SourceIdBindings } from './source-id-bindings'
23
30
  import type {
24
31
  DerivedIdentityProfiler,
25
32
  LiveQueryKey,
@@ -119,6 +126,30 @@ type InfiniteQueryRenderState = {
119
126
  >
120
127
  }
121
128
 
129
+ type RenderedWindowCollection = {
130
+ client: DbClient | undefined
131
+ dependencies: Array<unknown>
132
+ collection: Collection<any, any, any>
133
+ deferredCollections: Set<
134
+ CollectionImplType<any, string | number, any, any, any>
135
+ >
136
+ }
137
+
138
+ /**
139
+ * Whether a recorded query identity matches the current one. Legacy deps
140
+ * compare by reference. Derived identities and query keys compare by structure.
141
+ */
142
+ function queryIdentityMatches(
143
+ comparison: { changed: boolean; structurallyEqual: boolean },
144
+ sameClient: boolean,
145
+ usesLegacyDeps: boolean,
146
+ ): boolean {
147
+ return (
148
+ sameClient &&
149
+ (usesLegacyDeps ? !comparison.changed : comparison.structurallyEqual)
150
+ )
151
+ }
152
+
122
153
  /**
123
154
  * Create an infinite query using a query function with live updates.
124
155
  *
@@ -169,6 +200,13 @@ export function useLiveInfiniteQuery<TContext extends Context>(
169
200
 
170
201
  const committedRef = useRef<InfiniteQueryRenderState | null>(null)
171
202
  const committed = committedRef.current
203
+ // Like useLiveQuery's instance memo, keep the window collection built by the
204
+ // latest render. A second render before commit, such as a StrictMode double
205
+ // render, reuses it instead of starting another one. Only the collection is
206
+ // kept. Each render still builds its own controller and page count from the
207
+ // committed state, which an uncommitted render never changes.
208
+ const renderedCollectionRef = useRef<RenderedWindowCollection | null>(null)
209
+ const sourceIdsRef = useRef<SourceIdBindings | null>(null)
172
210
  const inputKind = inputIsCollection ? `collection` : `query`
173
211
  const derivedIdentityProfilerRef = useRef<DerivedIdentityProfiler>({
174
212
  renderCount: 0,
@@ -212,6 +250,13 @@ export function useLiveInfiniteQuery<TContext extends Context>(
212
250
  deferredCollections,
213
251
  )
214
252
  preparedQueryValue = preparation.value
253
+ claimSourceIds(
254
+ (sourceIdsRef.current ??= createSourceIdBindings()),
255
+ preparation.value,
256
+ dbClient,
257
+ `useLiveInfiniteQuery`,
258
+ () => resumeSyncStarts(deferredCollections),
259
+ )
215
260
  if (preparation.status === `hashable`) {
216
261
  identityDeps = preparation.identityDeps
217
262
  } else {
@@ -230,10 +275,7 @@ export function useLiveInfiniteQuery<TContext extends Context>(
230
275
  const sameClient = committed?.client === dbClient
231
276
  const dependenciesChanged =
232
277
  !inputIsCollection &&
233
- (!sameClient ||
234
- (usesLegacyDeps
235
- ? dependencyComparison.changed
236
- : !dependencyComparison.structurallyEqual))
278
+ !queryIdentityMatches(dependencyComparison, sameClient, usesLegacyDeps)
237
279
  const dependenciesStructurallyEqual =
238
280
  usesLegacyDeps && sameClient && dependencyComparison.structurallyEqual
239
281
  const needsNewCollection =
@@ -252,6 +294,26 @@ export function useLiveInfiniteQuery<TContext extends Context>(
252
294
  if (needsNewController) {
253
295
  let collection = committed?.collection
254
296
  let warning: string | null = null
297
+ let startInRender = false
298
+ let suppliedCollection = false
299
+ let createdCollection = false
300
+
301
+ const canPreservePageCount = shouldPreserveLiveQueryWindowPageCount({
302
+ hasPreviousController: committed !== null,
303
+ previousInputKind: committed?.inputKind,
304
+ inputKind,
305
+ sameCollection:
306
+ inputIsCollection && committed?.inputCollection === queryFnOrCollection,
307
+ dependenciesChanged,
308
+ dependenciesStructurallyEqual,
309
+ pageShapeChanged,
310
+ })
311
+ const previousPageCount = committed
312
+ ? Math.max(1, committed.controller.getSnapshot().pages.length)
313
+ : 1
314
+ const initialPageCount = canPreservePageCount ? previousPageCount : 1
315
+ // The peek-ahead window for every retained page.
316
+ const requiredLimit = initialPageCount * pageSize + 1
255
317
 
256
318
  if (needsNewCollection) {
257
319
  let inputValue = queryFnOrCollection
@@ -265,19 +327,50 @@ export function useLiveInfiniteQuery<TContext extends Context>(
265
327
  }
266
328
  inputValue = () => preparedQueryValue
267
329
  }
268
- const input = resolveLiveQueryWindowInput<TContext>(inputValue)
269
- if (input.kind === `collection`) {
270
- collection = input.collection
330
+ const rendered = renderedCollectionRef.current
331
+ if (
332
+ !inputIsCollection &&
333
+ rendered !== null &&
334
+ queryIdentityMatches(
335
+ compareLiveQueryWindowDependencies(
336
+ rendered.dependencies,
337
+ identityDeps,
338
+ ),
339
+ rendered.client === dbClient,
340
+ usesLegacyDeps,
341
+ ) &&
342
+ rendered.collection.status !== `cleaned-up` &&
343
+ rendered.collection.status !== `error` &&
344
+ liveQueryWindowMatches(rendered.collection, requiredLimit)
345
+ ) {
346
+ // An earlier render built this collection and nothing has committed
347
+ // since. Its window is exactly the retained pages, so its first
348
+ // rows are correct. Sources it deferred still resume at commit. A
349
+ // collection in terminal error, such as after its source restarted,
350
+ // cannot serve the query again, so a new one replaces it.
351
+ collection = rendered.collection
352
+ for (const deferred of rendered.deferredCollections) {
353
+ deferredCollections.add(deferred)
354
+ }
271
355
  } else {
272
- // Wrap the query with the first page's peek-ahead window; the controller
273
- // grows the limit from here via setWindow.
274
- collection = createLiveQueryCollection({
275
- query: input.query.limit(pageSize + 1).offset(0),
276
- // Construction happens during render. Synchronization starts only when
277
- // useSyncExternalStore commits the controller subscription.
278
- startSync: false,
279
- gcTime: DEFAULT_GC_TIME_MS,
280
- })
356
+ const input = resolveLiveQueryWindowInput<TContext>(inputValue)
357
+ if (input.kind === `collection`) {
358
+ collection = input.collection
359
+ suppliedCollection = true
360
+ // A supplied collection is never reused, so release the last one.
361
+ renderedCollectionRef.current = null
362
+ } else {
363
+ // Wrap the query with the peek-ahead window for every retained page,
364
+ // so a collection that starts syncing now never publishes fewer rows
365
+ // than the controller's pages. The controller grows the limit via
366
+ // setWindow.
367
+ collection = createLiveQueryCollection({
368
+ query: input.query.limit(requiredLimit).offset(0),
369
+ gcTime: DEFAULT_GC_TIME_MS,
370
+ })
371
+ startInRender = true
372
+ createdCollection = true
373
+ }
281
374
  }
282
375
  }
283
376
 
@@ -291,21 +384,26 @@ export function useLiveInfiniteQuery<TContext extends Context>(
291
384
  } else {
292
385
  assertLiveQueryWindowManyResult(collection)
293
386
  }
387
+ // Like useLiveQuery, start sync during render once the input is valid. A
388
+ // render reads only rows the sources already hold, such as a preloaded
389
+ // collection's, and sends no request until the subscription commits. GC
390
+ // reclaims a render that never commits. A supplied window that the
391
+ // controller must still adjust waits for the subscription.
392
+ if (suppliedCollection) {
393
+ startInRender = liveQueryWindowMatches(collection, requiredLimit)
394
+ }
395
+ if (startInRender) collection.startSyncImmediate()
396
+ // Keep a created collection for the next render only once it validated and
397
+ // started, so a retry after a startup error builds a new one and throws.
398
+ if (createdCollection) {
399
+ renderedCollectionRef.current = {
400
+ client: dbClient,
401
+ dependencies: [...identityDeps],
402
+ collection,
403
+ deferredCollections,
404
+ }
405
+ }
294
406
 
295
- const canPreservePageCount = shouldPreserveLiveQueryWindowPageCount({
296
- hasPreviousController: committed !== null,
297
- previousInputKind: committed?.inputKind,
298
- inputKind,
299
- sameCollection:
300
- inputIsCollection && committed?.inputCollection === collection,
301
- dependenciesChanged,
302
- dependenciesStructurallyEqual,
303
- pageShapeChanged,
304
- })
305
- const previousPageCount = committed
306
- ? Math.max(1, committed.controller.getSnapshot().pages.length)
307
- : 1
308
- const initialPageCount = canPreservePageCount ? previousPageCount : 1
309
407
  renderState = {
310
408
  inputKind,
311
409
  inputCollection: inputIsCollection ? collection : null,
@@ -337,10 +435,7 @@ export function useLiveInfiniteQuery<TContext extends Context>(
337
435
  currentRenderState.warned = true
338
436
  console.warn(currentRenderState.warning)
339
437
  }
340
- for (const collection of currentRenderState.deferredCollections) {
341
- collection._resumeSyncStart()
342
- }
343
- currentRenderState.deferredCollections.clear()
438
+ resumeSyncStarts(currentRenderState.deferredCollections)
344
439
  return unsubscribe
345
440
  },
346
441
  [controller, currentRenderState],
@@ -2,8 +2,6 @@
2
2
 
3
3
  import { useRef, useSyncExternalStore } from 'react'
4
4
  import {
5
- BaseQueryBuilder,
6
- IR,
7
5
  UnhashableQueryIRError,
8
6
  createLiveQueryObserver,
9
7
  deepEquals,
@@ -16,6 +14,13 @@ import {
16
14
  import { useOptionalDbClient } from './DbProvider'
17
15
  import { setLiveQueryResultInfo } from './live-query-internals'
18
16
  import { shouldWarnInDevelopment } from './development'
17
+ import {
18
+ claimSourceIds,
19
+ createSourceIdBindings,
20
+ getPreparedSources,
21
+ getSourceObjectToken,
22
+ resumeSyncStarts,
23
+ } from './source-id-bindings'
19
24
  import type {
20
25
  Collection,
21
26
  CollectionImpl,
@@ -56,36 +61,6 @@ const suspenseCollectionsByClient = new WeakMap<
56
61
  DbClient,
57
62
  Map<string, SuspenseCollectionEntry>
58
63
  >()
59
- const sourceObjectTokens = new WeakMap<object, number>()
60
- let nextSourceObjectToken = 0
61
-
62
- function getSourceObjectToken(source: object): number {
63
- let id = sourceObjectTokens.get(source)
64
- if (id === undefined) {
65
- id = ++nextSourceObjectToken
66
- sourceObjectTokens.set(source, id)
67
- }
68
- return id
69
- }
70
-
71
- function getPreparedSources(preparedValue: unknown): Array<{ id: string }> {
72
- if (isCollection(preparedValue)) return [preparedValue]
73
- const query =
74
- preparedValue instanceof BaseQueryBuilder
75
- ? preparedValue
76
- : preparedValue &&
77
- typeof preparedValue === `object` &&
78
- `query` in preparedValue &&
79
- preparedValue.query instanceof BaseQueryBuilder
80
- ? preparedValue.query
81
- : undefined
82
- return query
83
- ? IR.collectCollectionSources(query._getQuery()).map(
84
- ({ collection }) => collection,
85
- )
86
- : []
87
- }
88
-
89
64
  function getSourceQualifiedSuspenseKey(
90
65
  preparedValue: unknown,
91
66
  queryHash: string,
@@ -751,10 +726,7 @@ function createHookInstance(dbClient: DbClient | undefined) {
751
726
  deferredCollections: new Set<
752
727
  CollectionImpl<any, string | number, any, any, any>
753
728
  >(),
754
- sourceIds: {
755
- unscoped: new Map<string, number>(),
756
- byClient: new WeakMap<DbClient, Map<string, number>>(),
757
- },
729
+ sourceIds: createSourceIdBindings(),
758
730
  observer: null as LiveQueryObserver<object, string | number> | null,
759
731
  queryHash: undefined as string | undefined,
760
732
  suspenseKey: undefined as string | undefined,
@@ -778,19 +750,8 @@ function useLiveQueryImpl(
778
750
 
779
751
  const instanceRef = useRef<ReturnType<typeof createHookInstance> | null>(null)
780
752
  const instance = (instanceRef.current ??= createHookInstance(dbClient))
781
- const resumeDeferredCollections = () => {
782
- const collections = Array.from(instance.deferredCollections)
783
- instance.deferredCollections.clear()
784
- const errors: Array<unknown> = []
785
- for (const collection of collections) {
786
- try {
787
- collection._resumeSyncStart()
788
- } catch (error) {
789
- errors.push(error)
790
- }
791
- }
792
- if (errors.length) throw errors[0]
793
- }
753
+ const resumeDeferredCollections = () =>
754
+ resumeSyncStarts(instance.deferredCollections)
794
755
 
795
756
  const queryKey = !inputIsCollection
796
757
  ? getExplicitQueryKey(configOrQueryOrCollection)
@@ -873,25 +834,13 @@ function useLiveQueryImpl(
873
834
  deps === undefined &&
874
835
  preparedQueryValue !== unpreparedQueryValue
875
836
  ) {
876
- const prior = dbClient
877
- ? instance.sourceIds.byClient.get(dbClient)
878
- : instance.sourceIds.unscoped
879
- const seen = new Map<string, number>()
880
- for (const source of getPreparedSources(preparedQueryValue)) {
881
- const token = getSourceObjectToken(source)
882
- const previous = seen.get(source.id) ?? prior?.get(source.id)
883
- if (previous !== undefined && previous !== token) {
884
- // The rejected render must not retain a shared Collection's sync deferral.
885
- resumeDeferredCollections()
886
- throw new Error(
887
- `[useLiveQuery] Source Collection "${source.id}" was replaced by a different Collection with the same ID while this hook is mounted. Unmount the hook and clean up its previous source and client scope before reusing the ID.`,
888
- )
889
- }
890
- seen.set(source.id, token)
891
- }
892
- const bindings = prior ?? new Map<string, number>()
893
- for (const [id, token] of seen) bindings.set(id, token)
894
- if (dbClient) instance.sourceIds.byClient.set(dbClient, bindings)
837
+ claimSourceIds(
838
+ instance.sourceIds,
839
+ preparedQueryValue,
840
+ dbClient,
841
+ `useLiveQuery`,
842
+ resumeDeferredCollections,
843
+ )
895
844
  }
896
845
 
897
846
  const canReuseSuspenseKey =
@@ -987,6 +936,8 @@ function useLiveQueryImpl(
987
936
  : DEFAULT_GC_TIME_MS,
988
937
  // Hydration and Suspense key the live-query Collection by identity.
989
938
  pool: !forSuspense && !dbClient,
939
+ // Without a DbClient, Suspense starts it through a preload below.
940
+ startSync: !forSuspense || dbClient !== undefined,
990
941
  }) as SuspenseCollection | null
991
942
  if (suspenseCollections && suspenseKey && instance.collection) {
992
943
  const collection = instance.collection
@@ -1051,6 +1002,18 @@ function useLiveQueryImpl(
1051
1002
  })
1052
1003
  }
1053
1004
  const observer = instance.observer!
1005
+ // A Suspense render asks for its data. Preloading starts the sync run, so a
1006
+ // source that already holds the rows makes it ready before the snapshot is
1007
+ // read instead of suspending once. useLiveSuspenseQuery surfaces failures.
1008
+ // A DbClient defers source starts past render and owns its streamed query.
1009
+ if (
1010
+ forSuspense &&
1011
+ !dbClient &&
1012
+ (instance.collection?.status === `idle` ||
1013
+ instance.collection?.status === `cleaned-up`)
1014
+ ) {
1015
+ observer.preloadForInitialRender().catch(() => {})
1016
+ }
1054
1017
 
1055
1018
  // Stable subscribe bound to the current observer; the observer owns the
1056
1019
  // subscription, ready-race, and disposal.