@tanstack/react-db 0.1.94 → 0.1.96

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,23 +1,39 @@
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
+ CollectionImpl,
4
+ createLiveQueryCollection,
5
+ createLiveQueryWindowController,
6
+ deepEquals,
7
+ } from '@tanstack/db'
8
+ // Type-only: used in `ReturnType<typeof useLiveQuery>` in UseLiveInfiniteQueryReturn.
9
+ import type { useLiveQuery } from './useLiveQuery'
4
10
  import type {
5
11
  Collection,
6
12
  Context,
7
13
  InferResultType,
8
14
  InitialQueryBuilder,
9
- LiveQueryCollectionUtils,
15
+ LiveQueryWindowController,
10
16
  NonSingleResult,
11
17
  QueryBuilder,
12
18
  } from '@tanstack/db'
13
19
 
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`
20
+ // Live queries created here are cleaned up immediately (0 disables GC).
21
+ const DEFAULT_GC_TIME_MS = 1
22
+
23
+ type WindowedCollection = Collection<any, any, any> & {
24
+ utils: {
25
+ setWindow: (options: {
26
+ offset: number
27
+ limit: number
28
+ }) => true | Promise<void>
29
+ }
30
+ }
31
+
32
+ /** Type guard: does this collection expose `setWindow` (i.e. has an orderBy)? */
33
+ function hasSetWindow(
34
+ collection: Collection<any, any, any>,
35
+ ): collection is WindowedCollection {
36
+ return typeof collection.utils?.setWindow === `function`
21
37
  }
22
38
 
23
39
  export type UseLiveInfiniteQueryConfig<TContext extends Context> = {
@@ -46,8 +62,13 @@ export type UseLiveInfiniteQueryReturn<TContext extends Context> = Omit<
46
62
  fetchNextPage: () => void
47
63
  hasNextPage: boolean
48
64
  isFetchingNextPage: boolean
65
+ error: unknown
49
66
  }
50
67
 
68
+ type EnabledLiveQueryReturn<TContext extends Context> = ReturnType<
69
+ typeof useLiveQuery<TContext>
70
+ >
71
+
51
72
  /**
52
73
  * Create an infinite query using a query function with live updates
53
74
  *
@@ -151,180 +172,135 @@ export function useLiveInfiniteQuery<TContext extends Context>(
151
172
  )
152
173
  }
153
174
 
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
-
175
+ const collectionRef = useRef<Collection<any, any, any> | null>(null)
176
+ const controllerRef = useRef<LiveQueryWindowController<any, any> | null>(null)
177
+ const configRef = useRef<unknown>(null)
178
+ const depsRef = useRef<Array<unknown> | null>(null)
179
+ const pageSizeRef = useRef(pageSize)
180
+ const initialPageParamRef = useRef(initialPageParam)
181
+ const validatedCollectionRef = useRef<unknown>(null)
182
+ const inputKind = isCollection ? `collection` : `query`
183
+ const inputKindRef = useRef<typeof inputKind | null>(null)
184
+ const previousInputKind = inputKindRef.current
185
+
186
+ const dependenciesChanged =
187
+ !isCollection &&
188
+ (depsRef.current === null ||
189
+ depsRef.current.length !== deps.length ||
190
+ depsRef.current.some((dep, index) => dep !== deps[index]))
191
+ const dependenciesStructurallyEqual =
192
+ !isCollection &&
193
+ depsRef.current !== null &&
194
+ deepEquals(depsRef.current, deps)
195
+ const needsNewCollection =
196
+ !collectionRef.current ||
197
+ inputKindRef.current !== inputKind ||
198
+ (isCollection && configRef.current !== queryFnOrCollection) ||
199
+ dependenciesChanged
200
+ const pageShapeChanged =
201
+ pageSizeRef.current !== pageSize ||
202
+ initialPageParamRef.current !== initialPageParam
203
+ const needsNewController =
204
+ !controllerRef.current || needsNewCollection || pageShapeChanged
205
+
206
+ if (needsNewCollection) {
207
+ inputKindRef.current = inputKind
178
208
  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) {
209
+ const collection = queryFnOrCollection as Collection<any, any, any>
210
+ if (!hasSetWindow(collection)) {
221
211
  throw new Error(
222
212
  `useLiveInfiniteQuery: Pre-created live query collection must have an orderBy clause for infinite pagination to work. ` +
223
213
  `Please add .orderBy() to your createLiveQueryCollection query.`,
224
214
  )
225
215
  }
226
- return
227
- }
228
-
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
- )
216
+ // Warn once per collection instance if its current window doesn't match
217
+ // the first page the hook is about to enforce.
218
+ if (validatedCollectionRef.current !== collection) {
219
+ validatedCollectionRef.current = collection
220
+ const currentWindow = collection.utils.getWindow?.()
221
+ if (
222
+ currentWindow &&
223
+ (currentWindow.offset !== 0 || currentWindow.limit !== pageSize + 1)
224
+ ) {
225
+ console.warn(
226
+ `useLiveInfiniteQuery: Pre-created collection has window {offset: ${currentWindow.offset}, limit: ${currentWindow.limit}} ` +
227
+ `but the hook expects {offset: 0, limit: ${pageSize + 1}}. Adjusting window now.`,
228
+ )
229
+ }
241
230
  }
242
- hasValidatedCollectionRef.current = true
243
- }
244
-
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
- })
231
+ collectionRef.current = collection
232
+ configRef.current = queryFnOrCollection
265
233
  } 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)
234
+ // Wrap the query with the first page's peek-ahead window; the controller
235
+ // grows the limit from here via setWindow.
236
+ collectionRef.current = createLiveQueryCollection({
237
+ query: (q: InitialQueryBuilder) =>
238
+ queryFnOrCollection(q)
239
+ .limit(pageSize + 1)
240
+ .offset(0),
241
+ // Construction happens during render. Synchronization starts only when
242
+ // useSyncExternalStore commits the controller subscription.
243
+ startSync: false,
244
+ gcTime: DEFAULT_GC_TIME_MS,
245
+ })
246
+ depsRef.current = [...deps]
298
247
  }
248
+ }
299
249
 
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>
250
+ if (needsNewController) {
251
+ const previousController = controllerRef.current
252
+ const canPreservePageCount =
253
+ previousController !== null &&
254
+ (!needsNewCollection ||
255
+ (previousInputKind === `query` && dependenciesStructurallyEqual))
256
+ const initialPageCount = canPreservePageCount
257
+ ? Math.max(1, previousController.getSnapshot().pages.length)
258
+ : 1
259
+ pageSizeRef.current = pageSize
260
+ initialPageParamRef.current = initialPageParam
261
+ controllerRef.current = createLiveQueryWindowController(
262
+ collectionRef.current,
263
+ {
264
+ pageSize,
265
+ initialPageParam,
266
+ initialPageCount,
267
+ },
268
+ )
269
+ }
270
+ const controller = controllerRef.current!
305
271
 
306
- return {
307
- pages: pagesResult,
308
- pageParams: pageParamsResult,
309
- hasNextPage: hasMore,
310
- flatData: flatDataResult,
311
- }
312
- }, [queryResult.data, loadedPageCount, pageSize, initialPageParam])
272
+ const subscribe = useCallback(
273
+ (onStoreChange: () => void) => controller.subscribe(onStoreChange),
274
+ [controller],
275
+ )
276
+ const getSnapshot = useCallback(() => controller.getSnapshot(), [controller])
277
+ const snapshot = useSyncExternalStore(subscribe, getSnapshot)
313
278
 
314
- // Fetch next page
315
279
  const fetchNextPage = useCallback(() => {
316
- if (!hasNextPage || isFetchingNextPage) return
317
-
318
- setLoadedPageCount((prev) => prev + 1)
319
- }, [hasNextPage, isFetchingNextPage])
280
+ void controller.fetchNextPage().catch(() => {
281
+ // Pagination errors are exposed through the controller snapshot. The
282
+ // hook's void callback has no promise error channel, so consume it here.
283
+ })
284
+ }, [controller])
320
285
 
321
286
  return {
322
- ...queryResult,
323
- data: flatData,
324
- pages,
325
- pageParams,
287
+ data: snapshot.data as InferResultType<TContext>,
288
+ state: snapshot.state as EnabledLiveQueryReturn<TContext>[`state`],
289
+ status: snapshot.status as EnabledLiveQueryReturn<TContext>[`status`],
290
+ isLoading: snapshot.isLoading,
291
+ isReady: snapshot.isReady,
292
+ isIdle: snapshot.isIdle,
293
+ isError: snapshot.isError,
294
+ isCleanedUp: snapshot.isCleanedUp,
295
+ collection:
296
+ snapshot.collection as EnabledLiveQueryReturn<TContext>[`collection`],
297
+ isEnabled:
298
+ snapshot.isEnabled as EnabledLiveQueryReturn<TContext>[`isEnabled`],
299
+ pages: snapshot.pages as Array<Array<InferResultType<TContext>[number]>>,
300
+ pageParams: snapshot.pageParams as Array<number>,
326
301
  fetchNextPage,
327
- hasNextPage,
328
- isFetchingNextPage,
329
- } as UseLiveInfiniteQueryReturn<TContext>
302
+ hasNextPage: snapshot.hasNextPage,
303
+ isFetchingNextPage: snapshot.isFetchingNextPage,
304
+ error: snapshot.error,
305
+ }
330
306
  }
@@ -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
  }