@tanstack/react-db 0.2.0 → 0.3.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.
Files changed (52) hide show
  1. package/README.md +12 -0
  2. package/dist/cjs/DbProvider.cjs +23 -0
  3. package/dist/cjs/DbProvider.cjs.map +1 -0
  4. package/dist/cjs/DbProvider.d.cts +9 -0
  5. package/dist/cjs/HydrationBoundary.cjs +16 -0
  6. package/dist/cjs/HydrationBoundary.cjs.map +1 -0
  7. package/dist/cjs/HydrationBoundary.d.cts +7 -0
  8. package/dist/cjs/index.cjs +6 -0
  9. package/dist/cjs/index.cjs.map +1 -1
  10. package/dist/cjs/index.d.cts +4 -1
  11. package/dist/cjs/live-query-internals.cjs +19 -0
  12. package/dist/cjs/live-query-internals.cjs.map +1 -0
  13. package/dist/cjs/live-query-internals.d.cts +9 -0
  14. package/dist/cjs/useLiveInfiniteQuery.cjs +76 -8
  15. package/dist/cjs/useLiveInfiniteQuery.cjs.map +1 -1
  16. package/dist/cjs/useLiveInfiniteQuery.d.cts +12 -58
  17. package/dist/cjs/useLiveQuery.cjs +263 -41
  18. package/dist/cjs/useLiveQuery.cjs.map +1 -1
  19. package/dist/cjs/useLiveQuery.d.cts +79 -33
  20. package/dist/cjs/useLiveSuspenseQuery.cjs +19 -5
  21. package/dist/cjs/useLiveSuspenseQuery.cjs.map +1 -1
  22. package/dist/cjs/useLiveSuspenseQuery.d.cts +22 -19
  23. package/dist/esm/DbProvider.d.ts +9 -0
  24. package/dist/esm/DbProvider.js +23 -0
  25. package/dist/esm/DbProvider.js.map +1 -0
  26. package/dist/esm/HydrationBoundary.d.ts +7 -0
  27. package/dist/esm/HydrationBoundary.js +16 -0
  28. package/dist/esm/HydrationBoundary.js.map +1 -0
  29. package/dist/esm/index.d.ts +4 -1
  30. package/dist/esm/index.js +6 -0
  31. package/dist/esm/index.js.map +1 -1
  32. package/dist/esm/live-query-internals.d.ts +9 -0
  33. package/dist/esm/live-query-internals.js +19 -0
  34. package/dist/esm/live-query-internals.js.map +1 -0
  35. package/dist/esm/useLiveInfiniteQuery.d.ts +12 -58
  36. package/dist/esm/useLiveInfiniteQuery.js +76 -8
  37. package/dist/esm/useLiveInfiniteQuery.js.map +1 -1
  38. package/dist/esm/useLiveQuery.d.ts +79 -33
  39. package/dist/esm/useLiveQuery.js +265 -43
  40. package/dist/esm/useLiveQuery.js.map +1 -1
  41. package/dist/esm/useLiveSuspenseQuery.d.ts +22 -19
  42. package/dist/esm/useLiveSuspenseQuery.js +19 -5
  43. package/dist/esm/useLiveSuspenseQuery.js.map +1 -1
  44. package/package.json +2 -2
  45. package/skills/react-db/SKILL.md +119 -90
  46. package/src/DbProvider.tsx +32 -0
  47. package/src/HydrationBoundary.tsx +25 -0
  48. package/src/index.ts +8 -1
  49. package/src/live-query-internals.ts +36 -0
  50. package/src/useLiveInfiniteQuery.ts +119 -65
  51. package/src/useLiveQuery.ts +441 -92
  52. package/src/useLiveSuspenseQuery.ts +56 -29
@@ -0,0 +1,36 @@
1
+ import type {
2
+ DbClient,
3
+ LiveQueryObserver,
4
+ UnhashableQueryIRError,
5
+ } from '@tanstack/db'
6
+
7
+ const liveQueryResultInfo = Symbol(`liveQueryResultInfo`)
8
+
9
+ export type LiveQueryResultInfo = {
10
+ client: DbClient | undefined
11
+ queryHash: string | undefined
12
+ identityError: UnhashableQueryIRError | undefined
13
+ observer: LiveQueryObserver<object, string | number>
14
+ }
15
+
16
+ type ResultWithInfo = {
17
+ [liveQueryResultInfo]?: LiveQueryResultInfo
18
+ }
19
+
20
+ export function setLiveQueryResultInfo(
21
+ result: object,
22
+ info: LiveQueryResultInfo,
23
+ ): void {
24
+ Object.defineProperty(result, liveQueryResultInfo, {
25
+ configurable: true,
26
+ value: info,
27
+ })
28
+ }
29
+
30
+ export function getLiveQueryResultInfo(result: object): LiveQueryResultInfo {
31
+ const info = (result as ResultWithInfo)[liveQueryResultInfo]
32
+ if (!info) {
33
+ throw new Error(`Missing internal live query result information.`)
34
+ }
35
+ return info
36
+ }
@@ -1,3 +1,5 @@
1
+ 'use client'
2
+
1
3
  import { useCallback, useRef, useSyncExternalStore } from 'react'
2
4
  import {
3
5
  assertLiveQueryWindowManyResult,
@@ -11,11 +13,23 @@ import {
11
13
  resolveLiveQueryWindowInput,
12
14
  shouldPreserveLiveQueryWindowPageCount,
13
15
  } from '@tanstack/db'
14
- // Type-only: used in `ReturnType<typeof useLiveQuery>` in UseLiveInfiniteQueryReturn.
15
- import type { useLiveQuery } from './useLiveQuery'
16
+ import { useOptionalDbClient } from './DbProvider'
17
+ import {
18
+ prepareDerivedQuery,
19
+ prepareQueryValue,
20
+ warnDeprecatedDepsArray,
21
+ warnUnhashableDerivedIdentity,
22
+ } from './useLiveQuery'
23
+ import type {
24
+ DerivedIdentityProfiler,
25
+ LiveQueryKey,
26
+ useLiveQuery,
27
+ } from './useLiveQuery'
16
28
  import type {
17
29
  Collection,
30
+ CollectionImpl as CollectionImplType,
18
31
  Context,
32
+ DbClient,
19
33
  InferResultType,
20
34
  InitialQueryBuilder,
21
35
  LiveQueryWindowController,
@@ -25,8 +39,17 @@ import type {
25
39
 
26
40
  // Live queries created here are cleaned up immediately (0 disables GC).
27
41
  const DEFAULT_GC_TIME_MS = 1
42
+ const unpreparedQueryValue = Symbol(`unpreparedQueryValue`)
28
43
 
29
44
  export type UseLiveInfiniteQueryConfig<TContext extends Context> = {
45
+ /**
46
+ * Explicit identity for queries that contain opaque functional variants or
47
+ * are hot enough that deriving identity from structured IR is too expensive.
48
+ * Structured queries should omit this so DB can derive identity directly.
49
+ */
50
+ queryKey?: LiveQueryKey
51
+ /** Override the nearest DbProvider for this query. */
52
+ client?: DbClient
30
53
  pageSize?: number
31
54
  initialPageParam?: number
32
55
  /**
@@ -62,6 +85,9 @@ type EnabledLiveQueryReturn<TContext extends Context> = ReturnType<
62
85
  type InfiniteQueryRenderState = {
63
86
  inputKind: `collection` | `query`
64
87
  inputCollection: Collection<any, any, any> | null
88
+ inputQuery: unknown
89
+ client: DbClient | undefined
90
+ identityMode: `collection` | `queryKey` | `legacyDeps` | `derived`
65
91
  dependencies: Array<unknown> | null
66
92
  pageSize: number
67
93
  initialPageParam: number
@@ -69,72 +95,21 @@ type InfiniteQueryRenderState = {
69
95
  controller: LiveQueryWindowController<any, any>
70
96
  warning: string | null
71
97
  warned: boolean
98
+ deferredCollections: Set<
99
+ CollectionImplType<any, string | number, any, any, any>
100
+ >
72
101
  }
73
102
 
74
103
  /**
75
- * Create an infinite query using a query function with live updates
104
+ * Create an infinite query using a query function with live updates.
76
105
  *
77
106
  * Uses `utils.setWindow()` to dynamically adjust the limit/offset window
78
107
  * without recreating the live query collection on each page change.
79
108
  *
80
109
  * @param queryFn - Query function that defines what data to fetch. Must include `.orderBy()` for setWindow to work.
81
110
  * @param config - Configuration including pageSize and getNextPageParam
82
- * @param deps - Array of dependencies that trigger query re-execution when changed
111
+ * @param deps - Deprecated array of dependencies that trigger query re-execution when changed
83
112
  * @returns Object with pages, data, and pagination controls
84
- *
85
- * @example
86
- * // Basic infinite query
87
- * const { data, pages, fetchNextPage, hasNextPage } = useLiveInfiniteQuery(
88
- * (q) => q
89
- * .from({ posts: postsCollection })
90
- * .orderBy(({ posts }) => posts.createdAt, 'desc')
91
- * .select(({ posts }) => ({
92
- * id: posts.id,
93
- * title: posts.title
94
- * })),
95
- * {
96
- * pageSize: 20,
97
- * getNextPageParam: (lastPage, allPages) =>
98
- * lastPage.length === 20 ? allPages.length : undefined
99
- * }
100
- * )
101
- *
102
- * @example
103
- * // With dependencies
104
- * const { pages, fetchNextPage } = useLiveInfiniteQuery(
105
- * (q) => q
106
- * .from({ posts: postsCollection })
107
- * .where(({ posts }) => eq(posts.category, category))
108
- * .orderBy(({ posts }) => posts.createdAt, 'desc'),
109
- * {
110
- * pageSize: 10,
111
- * getNextPageParam: (lastPage) =>
112
- * lastPage.length === 10 ? lastPage.length : undefined
113
- * },
114
- * [category]
115
- * )
116
- *
117
- * @example
118
- * // Router loader pattern with pre-created collection
119
- * // In loader:
120
- * const postsQuery = createLiveQueryCollection({
121
- * query: (q) => q
122
- * .from({ posts: postsCollection })
123
- * .orderBy(({ posts }) => posts.createdAt, 'desc')
124
- * .limit(20)
125
- * })
126
- * await postsQuery.preload()
127
- * return { postsQuery }
128
- *
129
- * // In component:
130
- * const { postsQuery } = useLoaderData()
131
- * const { data, fetchNextPage, hasNextPage } = useLiveInfiniteQuery(
132
- * postsQuery,
133
- * {
134
- * pageSize: 20,
135
- * getNextPageParam: (lastPage) => lastPage.length === 20 ? lastPage.length : undefined
136
- * }
137
- * )
138
113
  */
139
114
 
140
115
  // Overload for pre-created collection (non-single result)
@@ -158,10 +133,12 @@ export function useLiveInfiniteQuery<TContext extends Context>(
158
133
  export function useLiveInfiniteQuery<TContext extends Context>(
159
134
  queryFnOrCollection: any,
160
135
  config: UseLiveInfiniteQueryConfig<TContext>,
161
- deps: Array<unknown> = [],
136
+ deps?: Array<unknown>,
162
137
  ): UseLiveInfiniteQueryReturn<TContext> {
163
138
  const pageSize = normalizeLiveQueryWindowPageSize(config.pageSize)
164
139
  const initialPageParam = config.initialPageParam ?? 0
140
+ const contextDbClient = useOptionalDbClient()
141
+ const dbClient = config.client ?? contextDbClient
165
142
 
166
143
  const inputIsCollection =
167
144
  getLiveQueryWindowInputKind(queryFnOrCollection) === `collection`
@@ -169,14 +146,72 @@ export function useLiveInfiniteQuery<TContext extends Context>(
169
146
  const committedRef = useRef<InfiniteQueryRenderState | null>(null)
170
147
  const committed = committedRef.current
171
148
  const inputKind = inputIsCollection ? `collection` : `query`
149
+ const derivedIdentityProfilerRef = useRef<DerivedIdentityProfiler>({
150
+ renderCount: 0,
151
+ totalMs: 0,
152
+ maxMs: 0,
153
+ warned: false,
154
+ })
155
+ const legacyUnhashableIdentityRef = useRef<Array<unknown>>([
156
+ `legacy-unhashable`,
157
+ ])
158
+ const deferredCollections = new Set<
159
+ CollectionImplType<any, string | number, any, any, any>
160
+ >()
172
161
 
162
+ let preparedQueryValue: unknown | typeof unpreparedQueryValue =
163
+ unpreparedQueryValue
164
+ let identityDeps: ReadonlyArray<unknown> = []
165
+ let identityMode: InfiniteQueryRenderState[`identityMode`] = `collection`
166
+
167
+ if (!inputIsCollection) {
168
+ if (config.queryKey !== undefined) {
169
+ identityMode = `queryKey`
170
+ identityDeps = config.queryKey
171
+ } else if (deps !== undefined) {
172
+ identityMode = `legacyDeps`
173
+ identityDeps = deps
174
+ warnDeprecatedDepsArray(`useLiveInfiniteQuery`)
175
+ } else if (
176
+ committed?.identityMode === `derived` &&
177
+ committed.inputQuery === queryFnOrCollection &&
178
+ committed.client === dbClient
179
+ ) {
180
+ identityMode = `derived`
181
+ identityDeps = committed.dependencies ?? []
182
+ } else {
183
+ identityMode = `derived`
184
+ const preparation = prepareDerivedQuery(
185
+ queryFnOrCollection,
186
+ dbClient,
187
+ derivedIdentityProfilerRef.current,
188
+ deferredCollections,
189
+ )
190
+ preparedQueryValue = preparation.value
191
+ if (preparation.status === `hashable`) {
192
+ identityDeps = preparation.identityDeps
193
+ } else {
194
+ warnUnhashableDerivedIdentity(preparation.error)
195
+ identityDeps = legacyUnhashableIdentityRef.current
196
+ }
197
+ }
198
+ }
199
+
200
+ const usesLegacyDeps =
201
+ !inputIsCollection && config.queryKey === undefined && deps !== undefined
173
202
  const dependencyComparison = compareLiveQueryWindowDependencies(
174
203
  committed?.dependencies,
175
- deps,
204
+ identityDeps,
176
205
  )
177
- const dependenciesChanged = !inputIsCollection && dependencyComparison.changed
206
+ const sameClient = committed?.client === dbClient
207
+ const dependenciesChanged =
208
+ !inputIsCollection &&
209
+ (!sameClient ||
210
+ (usesLegacyDeps
211
+ ? dependencyComparison.changed
212
+ : !dependencyComparison.structurallyEqual))
178
213
  const dependenciesStructurallyEqual =
179
- !inputIsCollection && dependencyComparison.structurallyEqual
214
+ usesLegacyDeps && sameClient && dependencyComparison.structurallyEqual
180
215
  const needsNewCollection =
181
216
  committed === null ||
182
217
  committed.inputKind !== inputKind ||
@@ -195,7 +230,18 @@ export function useLiveInfiniteQuery<TContext extends Context>(
195
230
  let warning: string | null = null
196
231
 
197
232
  if (needsNewCollection) {
198
- const input = resolveLiveQueryWindowInput<TContext>(queryFnOrCollection)
233
+ let inputValue = queryFnOrCollection
234
+ if (!inputIsCollection) {
235
+ if (preparedQueryValue === unpreparedQueryValue) {
236
+ preparedQueryValue = prepareQueryValue(
237
+ queryFnOrCollection,
238
+ dbClient,
239
+ deferredCollections,
240
+ )
241
+ }
242
+ inputValue = () => preparedQueryValue
243
+ }
244
+ const input = resolveLiveQueryWindowInput<TContext>(inputValue)
199
245
  if (input.kind === `collection`) {
200
246
  collection = input.collection
201
247
  } else {
@@ -239,7 +285,10 @@ export function useLiveInfiniteQuery<TContext extends Context>(
239
285
  renderState = {
240
286
  inputKind,
241
287
  inputCollection: inputIsCollection ? collection : null,
242
- dependencies: inputIsCollection ? null : [...deps],
288
+ inputQuery: inputIsCollection ? null : queryFnOrCollection,
289
+ client: dbClient,
290
+ identityMode,
291
+ dependencies: inputIsCollection ? null : [...identityDeps],
243
292
  pageSize,
244
293
  initialPageParam,
245
294
  collection,
@@ -250,6 +299,7 @@ export function useLiveInfiniteQuery<TContext extends Context>(
250
299
  }),
251
300
  warning,
252
301
  warned: false,
302
+ deferredCollections,
253
303
  }
254
304
  }
255
305
  const currentRenderState = renderState!
@@ -263,12 +313,16 @@ export function useLiveInfiniteQuery<TContext extends Context>(
263
313
  currentRenderState.warned = true
264
314
  console.warn(currentRenderState.warning)
265
315
  }
316
+ for (const collection of currentRenderState.deferredCollections) {
317
+ collection._resumeSyncStart()
318
+ }
319
+ currentRenderState.deferredCollections.clear()
266
320
  return unsubscribe
267
321
  },
268
322
  [controller, currentRenderState],
269
323
  )
270
324
  const getSnapshot = useCallback(() => controller.getSnapshot(), [controller])
271
- const snapshot = useSyncExternalStore(subscribe, getSnapshot)
325
+ const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
272
326
 
273
327
  const fetchNextPage = useCallback(
274
328
  () => fetchNextLiveQueryWindowPage(controller),