@tanstack/react-db 0.1.95 → 0.2.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.
@@ -1,24 +1,30 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
- import { CollectionImpl } from '@tanstack/db'
3
- import { useLiveQuery } from './useLiveQuery'
1
+ import { useCallback, useRef, useSyncExternalStore } from 'react'
2
+ import {
3
+ assertLiveQueryWindowManyResult,
4
+ compareLiveQueryWindowDependencies,
5
+ createLiveQueryCollection,
6
+ createLiveQueryWindowController,
7
+ fetchNextLiveQueryWindowPage,
8
+ getLiveQueryWindowCollectionWarning,
9
+ getLiveQueryWindowInputKind,
10
+ normalizeLiveQueryWindowPageSize,
11
+ resolveLiveQueryWindowInput,
12
+ shouldPreserveLiveQueryWindowPageCount,
13
+ } from '@tanstack/db'
14
+ // Type-only: used in `ReturnType<typeof useLiveQuery>` in UseLiveInfiniteQueryReturn.
15
+ import type { useLiveQuery } from './useLiveQuery'
4
16
  import type {
5
17
  Collection,
6
18
  Context,
7
19
  InferResultType,
8
20
  InitialQueryBuilder,
9
- LiveQueryCollectionUtils,
21
+ LiveQueryWindowController,
10
22
  NonSingleResult,
11
23
  QueryBuilder,
12
24
  } from '@tanstack/db'
13
25
 
14
- /**
15
- * Type guard to check if utils object has setWindow method (LiveQueryCollectionUtils)
16
- */
17
- function isLiveQueryCollectionUtils(
18
- utils: unknown,
19
- ): utils is LiveQueryCollectionUtils {
20
- return typeof (utils as any).setWindow === `function`
21
- }
26
+ // Live queries created here are cleaned up immediately (0 disables GC).
27
+ const DEFAULT_GC_TIME_MS = 1
22
28
 
23
29
  export type UseLiveInfiniteQueryConfig<TContext extends Context> = {
24
30
  pageSize?: number
@@ -43,9 +49,26 @@ export type UseLiveInfiniteQueryReturn<TContext extends Context> = Omit<
43
49
  data: InferResultType<TContext>
44
50
  pages: Array<Array<InferResultType<TContext>[number]>>
45
51
  pageParams: Array<number>
46
- fetchNextPage: () => void
52
+ fetchNextPage: () => Promise<void>
47
53
  hasNextPage: boolean
48
54
  isFetchingNextPage: boolean
55
+ error: unknown
56
+ }
57
+
58
+ type EnabledLiveQueryReturn<TContext extends Context> = ReturnType<
59
+ typeof useLiveQuery<TContext>
60
+ >
61
+
62
+ type InfiniteQueryRenderState = {
63
+ inputKind: `collection` | `query`
64
+ inputCollection: Collection<any, any, any> | null
65
+ dependencies: Array<unknown> | null
66
+ pageSize: number
67
+ initialPageParam: number
68
+ collection: Collection<any, any, any>
69
+ controller: LiveQueryWindowController<any, any>
70
+ warning: string | null
71
+ warned: boolean
49
72
  }
50
73
 
51
74
  /**
@@ -137,194 +160,139 @@ export function useLiveInfiniteQuery<TContext extends Context>(
137
160
  config: UseLiveInfiniteQueryConfig<TContext>,
138
161
  deps: Array<unknown> = [],
139
162
  ): UseLiveInfiniteQueryReturn<TContext> {
140
- const pageSize = config.pageSize || 20
163
+ const pageSize = normalizeLiveQueryWindowPageSize(config.pageSize)
141
164
  const initialPageParam = config.initialPageParam ?? 0
142
165
 
143
- // Detect if input is a collection or query function
144
- const isCollection = queryFnOrCollection instanceof CollectionImpl
145
-
146
- // Validate input type
147
- if (!isCollection && typeof queryFnOrCollection !== `function`) {
148
- throw new Error(
149
- `useLiveInfiniteQuery: First argument must be either a pre-created live query collection (CollectionImpl) ` +
150
- `or a query function. Received: ${typeof queryFnOrCollection}`,
151
- )
152
- }
153
-
154
- // Track how many pages have been loaded
155
- const [loadedPageCount, setLoadedPageCount] = useState(1)
156
- const [isFetchingNextPage, setIsFetchingNextPage] = useState(false)
157
-
158
- // Track collection instance and whether we've validated it (only for pre-created collections)
159
- const collectionRef = useRef(isCollection ? queryFnOrCollection : null)
160
- const hasValidatedCollectionRef = useRef(false)
161
-
162
- // Track deps for query functions (stringify for comparison)
163
- let depsKey: string
164
- try {
165
- depsKey = JSON.stringify(deps)
166
- } catch {
167
- throw new Error(
168
- `useLiveInfiniteQuery: dependency array contains values that cannot be serialized (e.g. circular references). ` +
169
- `Ensure all dependency values are JSON-serializable.`,
170
- )
171
- }
172
- const prevDepsKeyRef = useRef(depsKey)
173
-
174
- // Reset pagination when inputs change
175
- useEffect(() => {
176
- let shouldReset = false
177
-
178
- if (isCollection) {
179
- // Reset if collection instance changed
180
- if (collectionRef.current !== queryFnOrCollection) {
181
- collectionRef.current = queryFnOrCollection
182
- hasValidatedCollectionRef.current = false
183
- shouldReset = true
184
- }
185
- } else {
186
- // Reset if deps changed (for query functions)
187
- if (prevDepsKeyRef.current !== depsKey) {
188
- prevDepsKeyRef.current = depsKey
189
- shouldReset = true
190
- }
191
- }
192
-
193
- if (shouldReset) {
194
- setLoadedPageCount(1)
195
- }
196
- }, [isCollection, queryFnOrCollection, depsKey])
197
-
198
- // Create a live query with initial limit and offset
199
- // Either pass collection directly or wrap query function
200
- // Use pageSize + 1 for peek-ahead detection (to know if there are more pages)
201
- const queryResult = isCollection
202
- ? useLiveQuery(queryFnOrCollection)
203
- : useLiveQuery(
204
- (q) =>
205
- queryFnOrCollection(q)
206
- .limit(pageSize + 1)
207
- .offset(0),
208
- deps,
209
- )
210
-
211
- // Adjust window when pagination changes
212
- useEffect(() => {
213
- const utils = queryResult.collection.utils
214
- const expectedOffset = 0
215
- const expectedLimit = loadedPageCount * pageSize + 1 // +1 for peek ahead
216
-
217
- // Check if collection has orderBy (required for setWindow)
218
- if (!isLiveQueryCollectionUtils(utils)) {
219
- // For pre-created collections, throw an error if no orderBy
220
- if (isCollection) {
221
- throw new Error(
222
- `useLiveInfiniteQuery: Pre-created live query collection must have an orderBy clause for infinite pagination to work. ` +
223
- `Please add .orderBy() to your createLiveQueryCollection query.`,
224
- )
166
+ const inputIsCollection =
167
+ getLiveQueryWindowInputKind(queryFnOrCollection) === `collection`
168
+
169
+ const committedRef = useRef<InfiniteQueryRenderState | null>(null)
170
+ const committed = committedRef.current
171
+ const inputKind = inputIsCollection ? `collection` : `query`
172
+
173
+ const dependencyComparison = compareLiveQueryWindowDependencies(
174
+ committed?.dependencies,
175
+ deps,
176
+ )
177
+ const dependenciesChanged = !inputIsCollection && dependencyComparison.changed
178
+ const dependenciesStructurallyEqual =
179
+ !inputIsCollection && dependencyComparison.structurallyEqual
180
+ const needsNewCollection =
181
+ committed === null ||
182
+ committed.inputKind !== inputKind ||
183
+ (inputIsCollection && committed.inputCollection !== queryFnOrCollection) ||
184
+ dependenciesChanged
185
+ const pageShapeChanged =
186
+ committed === null ||
187
+ committed.pageSize !== pageSize ||
188
+ committed.initialPageParam !== initialPageParam
189
+ const needsNewController =
190
+ committed === null || needsNewCollection || pageShapeChanged
191
+
192
+ let renderState = committed
193
+ if (needsNewController) {
194
+ let collection = committed?.collection
195
+ let warning: string | null = null
196
+
197
+ if (needsNewCollection) {
198
+ const input = resolveLiveQueryWindowInput<TContext>(queryFnOrCollection)
199
+ if (input.kind === `collection`) {
200
+ collection = input.collection
201
+ } else {
202
+ // Wrap the query with the first page's peek-ahead window; the controller
203
+ // grows the limit from here via setWindow.
204
+ collection = createLiveQueryCollection({
205
+ query: input.query.limit(pageSize + 1).offset(0),
206
+ // Construction happens during render. Synchronization starts only when
207
+ // useSyncExternalStore commits the controller subscription.
208
+ startSync: false,
209
+ gcTime: DEFAULT_GC_TIME_MS,
210
+ })
225
211
  }
226
- return
227
212
  }
228
213
 
229
- // For pre-created collections, validate window on first check
230
- if (isCollection && !hasValidatedCollectionRef.current) {
231
- const currentWindow = utils.getWindow()
232
- if (
233
- currentWindow &&
234
- (currentWindow.offset !== expectedOffset ||
235
- currentWindow.limit !== expectedLimit)
236
- ) {
237
- console.warn(
238
- `useLiveInfiniteQuery: Pre-created collection has window {offset: ${currentWindow.offset}, limit: ${currentWindow.limit}} ` +
239
- `but hook expects {offset: ${expectedOffset}, limit: ${expectedLimit}}. Adjusting window now.`,
240
- )
241
- }
242
- hasValidatedCollectionRef.current = true
214
+ if (!collection) {
215
+ throw new Error(`useLiveInfiniteQuery: Failed to create a collection.`)
243
216
  }
244
217
 
245
- // For query functions, wait until collection is ready
246
- if (!isCollection && !queryResult.isReady) return
247
-
248
- // Adjust the window
249
- let cancelled = false
250
- const result = utils.setWindow({
251
- offset: expectedOffset,
252
- limit: expectedLimit,
253
- })
254
-
255
- if (result !== true) {
256
- setIsFetchingNextPage(true)
257
- result
258
- .catch((error: unknown) => {
259
- if (!cancelled)
260
- console.error(`useLiveInfiniteQuery: setWindow failed:`, error)
261
- })
262
- .finally(() => {
263
- if (!cancelled) setIsFetchingNextPage(false)
264
- })
218
+ if (inputIsCollection) {
219
+ warning =
220
+ getLiveQueryWindowCollectionWarning(collection, pageSize + 1) ?? null
265
221
  } else {
266
- setIsFetchingNextPage(false)
267
- }
268
-
269
- return () => {
270
- cancelled = true
271
- }
272
- }, [
273
- isCollection,
274
- queryResult.collection,
275
- queryResult.isReady,
276
- loadedPageCount,
277
- pageSize,
278
- ])
279
-
280
- // Split the data array into pages and determine if there's a next page
281
- const { pages, pageParams, hasNextPage, flatData } = useMemo(() => {
282
- const dataArray = (
283
- Array.isArray(queryResult.data) ? queryResult.data : []
284
- ) as InferResultType<TContext>
285
- const totalItemsRequested = loadedPageCount * pageSize
286
-
287
- // Check if we have more data than requested (the peek ahead item)
288
- const hasMore = dataArray.length > totalItemsRequested
289
-
290
- // Build pages array (without the peek ahead item)
291
- const pagesResult: Array<Array<InferResultType<TContext>[number]>> = []
292
- const pageParamsResult: Array<number> = []
293
-
294
- for (let i = 0; i < loadedPageCount; i++) {
295
- const pageData = dataArray.slice(i * pageSize, (i + 1) * pageSize)
296
- pagesResult.push(pageData)
297
- pageParamsResult.push(initialPageParam + i)
222
+ assertLiveQueryWindowManyResult(collection)
298
223
  }
299
224
 
300
- // Flatten the pages for the data return (without peek ahead item)
301
- const flatDataResult = dataArray.slice(
302
- 0,
303
- totalItemsRequested,
304
- ) as InferResultType<TContext>
305
-
306
- return {
307
- pages: pagesResult,
308
- pageParams: pageParamsResult,
309
- hasNextPage: hasMore,
310
- flatData: flatDataResult,
225
+ const canPreservePageCount = shouldPreserveLiveQueryWindowPageCount({
226
+ hasPreviousController: committed !== null,
227
+ previousInputKind: committed?.inputKind,
228
+ inputKind,
229
+ sameCollection:
230
+ inputIsCollection && committed?.inputCollection === collection,
231
+ dependenciesChanged,
232
+ dependenciesStructurallyEqual,
233
+ pageShapeChanged,
234
+ })
235
+ const previousPageCount = committed
236
+ ? Math.max(1, committed.controller.getSnapshot().pages.length)
237
+ : 1
238
+ const initialPageCount = canPreservePageCount ? previousPageCount : 1
239
+ renderState = {
240
+ inputKind,
241
+ inputCollection: inputIsCollection ? collection : null,
242
+ dependencies: inputIsCollection ? null : [...deps],
243
+ pageSize,
244
+ initialPageParam,
245
+ collection,
246
+ controller: createLiveQueryWindowController(collection, {
247
+ pageSize,
248
+ initialPageParam,
249
+ initialPageCount,
250
+ }),
251
+ warning,
252
+ warned: false,
311
253
  }
312
- }, [queryResult.data, loadedPageCount, pageSize, initialPageParam])
313
-
314
- // Fetch next page
315
- const fetchNextPage = useCallback(() => {
316
- if (!hasNextPage || isFetchingNextPage) return
317
-
318
- setLoadedPageCount((prev) => prev + 1)
319
- }, [hasNextPage, isFetchingNextPage])
254
+ }
255
+ const currentRenderState = renderState!
256
+ const controller = currentRenderState.controller
257
+
258
+ const subscribe = useCallback(
259
+ (onStoreChange: () => void) => {
260
+ const unsubscribe = controller.subscribe(onStoreChange)
261
+ committedRef.current = currentRenderState
262
+ if (currentRenderState.warning && !currentRenderState.warned) {
263
+ currentRenderState.warned = true
264
+ console.warn(currentRenderState.warning)
265
+ }
266
+ return unsubscribe
267
+ },
268
+ [controller, currentRenderState],
269
+ )
270
+ const getSnapshot = useCallback(() => controller.getSnapshot(), [controller])
271
+ const snapshot = useSyncExternalStore(subscribe, getSnapshot)
272
+
273
+ const fetchNextPage = useCallback(
274
+ () => fetchNextLiveQueryWindowPage(controller),
275
+ [controller],
276
+ )
320
277
 
321
278
  return {
322
- ...queryResult,
323
- data: flatData,
324
- pages,
325
- pageParams,
279
+ data: snapshot.data as InferResultType<TContext>,
280
+ state: snapshot.state as EnabledLiveQueryReturn<TContext>[`state`],
281
+ status: snapshot.status as EnabledLiveQueryReturn<TContext>[`status`],
282
+ isLoading: snapshot.isLoading,
283
+ isReady: snapshot.isReady,
284
+ isIdle: snapshot.isIdle,
285
+ isError: snapshot.isError,
286
+ isCleanedUp: snapshot.isCleanedUp,
287
+ collection:
288
+ snapshot.collection as EnabledLiveQueryReturn<TContext>[`collection`],
289
+ isEnabled:
290
+ snapshot.isEnabled as EnabledLiveQueryReturn<TContext>[`isEnabled`],
291
+ pages: snapshot.pages as Array<Array<InferResultType<TContext>[number]>>,
292
+ pageParams: snapshot.pageParams as Array<number>,
326
293
  fetchNextPage,
327
- hasNextPage,
328
- isFetchingNextPage,
329
- } as UseLiveInfiniteQueryReturn<TContext>
294
+ hasNextPage: snapshot.hasNextPage,
295
+ isFetchingNextPage: snapshot.isFetchingNextPage,
296
+ error: snapshot.error,
297
+ }
330
298
  }
@@ -2,9 +2,8 @@ import { useRef, useSyncExternalStore } from 'react'
2
2
  import {
3
3
  BaseQueryBuilder,
4
4
  createLiveQueryCollection,
5
- getLiveQueryStatusFlags,
5
+ createLiveQueryObserver,
6
6
  isCollection,
7
- isSingleResultCollection,
8
7
  } from '@tanstack/db'
9
8
  import type {
10
9
  Collection,
@@ -14,6 +13,7 @@ import type {
14
13
  InferResultType,
15
14
  InitialQueryBuilder,
16
15
  LiveQueryCollectionConfig,
16
+ LiveQueryObserver,
17
17
  NonSingleResult,
18
18
  QueryBuilder,
19
19
  SingleResult,
@@ -328,12 +328,10 @@ export function useLiveQuery(
328
328
  const depsRef = useRef<Array<unknown> | null>(null)
329
329
  const configRef = useRef<unknown>(null)
330
330
 
331
- // Use refs to track version and memoized snapshot
332
- const versionRef = useRef(0)
333
- const snapshotRef = useRef<{
334
- collection: Collection<object, string | number, {}> | null
335
- version: number
336
- } | null>(null)
331
+ // The shared observer owns subscription, the ready-race, and the snapshot.
332
+ const observerRef = useRef<LiveQueryObserver<object, string | number> | null>(
333
+ null,
334
+ )
337
335
 
338
336
  // Check if we need to create/recreate the collection
339
337
  const needsNewCollection =
@@ -413,143 +411,38 @@ export function useLiveQuery(
413
411
  }
414
412
  }
415
413
 
416
- // Reset refs when collection changes
414
+ // Recreate the observer when the underlying collection changes. The observer
415
+ // is not disposed explicitly here or on unmount: `useSyncExternalStore`
416
+ // unsubscribes it when the subscribe changes or the component unmounts, which
417
+ // detaches the collection subscription; the observer is then GC'd. (An unmount
418
+ // effect that disposed it would misfire under StrictMode/offscreen effect
419
+ // replay, leaving a disposed observer in the ref.)
417
420
  if (needsNewCollection) {
418
- versionRef.current = 0
419
- snapshotRef.current = null
421
+ // Defer the initial notify: useSyncExternalStore must not be notified
422
+ // synchronously during subscribe.
423
+ // Wholesale mode: React re-reads getSnapshot() on notify, keeps the
424
+ // hook's pre-observer loading policy, and — because wholesale delivers
425
+ // nothing synchronously during subscribe — never notifies
426
+ // useSyncExternalStore inside its own subscribe call.
427
+ observerRef.current = createLiveQueryObserver(collectionRef.current, {
428
+ mode: `wholesale`,
429
+ })
420
430
  }
431
+ const observer = observerRef.current!
421
432
 
422
- // Create stable subscribe function using ref
433
+ // Stable subscribe bound to the current observer; the observer owns the
434
+ // subscription, ready-race, and disposal.
423
435
  const subscribeRef = useRef<
424
436
  ((onStoreChange: () => void) => () => void) | null
425
437
  >(null)
426
438
  if (!subscribeRef.current || needsNewCollection) {
427
- subscribeRef.current = (onStoreChange: () => void) => {
428
- // If no collection, return a no-op unsubscribe function
429
- if (!collectionRef.current) {
430
- return () => {}
431
- }
432
-
433
- let unsubscribed = false
434
-
435
- const subscription = collectionRef.current.subscribeChanges(() => {
436
- // Drop late notifies that race with unsubscribe.
437
- if (unsubscribed) return
438
- // Bump version on any change; getSnapshot will rebuild next time
439
- versionRef.current += 1
440
- onStoreChange()
441
- })
442
- // Already-ready collections won't emit an initial change. Notify React
443
- // ourselves, but defer to a microtask — calling onStoreChange synchronously
444
- // here lands during the render-to-commit window and trips React's
445
- // "state update on a component that hasn't mounted yet" warning.
446
- if (collectionRef.current.status === `ready`) {
447
- queueMicrotask(() => {
448
- if (unsubscribed) return
449
- versionRef.current += 1
450
- onStoreChange()
451
- })
452
- }
453
- return () => {
454
- unsubscribed = true
455
- subscription.unsubscribe()
456
- }
457
- }
458
- }
459
-
460
- // Create stable getSnapshot function using ref
461
- const getSnapshotRef = useRef<
462
- | (() => {
463
- collection: Collection<object, string | number, {}> | null
464
- version: number
465
- })
466
- | null
467
- >(null)
468
- if (!getSnapshotRef.current || needsNewCollection) {
469
- getSnapshotRef.current = () => {
470
- const currentVersion = versionRef.current
471
- const currentCollection = collectionRef.current
472
-
473
- // Recreate snapshot object only if version/collection changed
474
- if (
475
- !snapshotRef.current ||
476
- snapshotRef.current.version !== currentVersion ||
477
- snapshotRef.current.collection !== currentCollection
478
- ) {
479
- snapshotRef.current = {
480
- collection: currentCollection,
481
- version: currentVersion,
482
- }
483
- }
484
-
485
- return snapshotRef.current
486
- }
487
- }
488
-
489
- // Use useSyncExternalStore to subscribe to collection changes
490
- const snapshot = useSyncExternalStore(
491
- subscribeRef.current,
492
- getSnapshotRef.current,
493
- )
494
-
495
- // Track last snapshot (from useSyncExternalStore) and the returned value separately
496
- const returnedSnapshotRef = useRef<{
497
- collection: Collection<object, string | number, {}> | null
498
- version: number
499
- } | null>(null)
500
- // Keep implementation return loose to satisfy overload signatures
501
- const returnedRef = useRef<any>(null)
502
-
503
- // Rebuild returned object only when the snapshot changes (version or collection identity)
504
- if (
505
- !returnedSnapshotRef.current ||
506
- returnedSnapshotRef.current.version !== snapshot.version ||
507
- returnedSnapshotRef.current.collection !== snapshot.collection
508
- ) {
509
- // Handle null collection case (when callback returns undefined/null)
510
- if (!snapshot.collection) {
511
- returnedRef.current = {
512
- state: undefined,
513
- data: undefined,
514
- collection: undefined,
515
- status: `disabled`,
516
- isLoading: false,
517
- isReady: true,
518
- isIdle: false,
519
- isError: false,
520
- isCleanedUp: false,
521
- isEnabled: false,
522
- }
523
- } else {
524
- // Capture a stable view of entries for this snapshot to avoid tearing
525
- const entries = Array.from(snapshot.collection.entries())
526
- const singleResult = isSingleResultCollection(snapshot.collection)
527
- let stateCache: Map<string | number, unknown> | null = null
528
- let dataCache: Array<unknown> | null = null
529
-
530
- returnedRef.current = {
531
- get state() {
532
- if (!stateCache) {
533
- stateCache = new Map(entries)
534
- }
535
- return stateCache
536
- },
537
- get data() {
538
- if (!dataCache) {
539
- dataCache = entries.map(([, value]) => value)
540
- }
541
- return singleResult ? dataCache[0] : dataCache
542
- },
543
- collection: snapshot.collection,
544
- status: snapshot.collection.status,
545
- ...getLiveQueryStatusFlags(snapshot.collection.status),
546
- isEnabled: true,
547
- }
548
- }
549
-
550
- // Remember the snapshot that produced this returned value
551
- returnedSnapshotRef.current = snapshot
439
+ subscribeRef.current = (onStoreChange) =>
440
+ observer.subscribe(() => onStoreChange())
552
441
  }
553
442
 
554
- return returnedRef.current!
443
+ // The observer returns a stable snapshot per revision, which is the return
444
+ // shape this hook exposes. Keep the return loose to satisfy the overloads.
445
+ return useSyncExternalStore(subscribeRef.current, () =>
446
+ observer.getSnapshot(),
447
+ ) as any
555
448
  }