@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.
- package/README.md +17 -0
- package/dist/cjs/useLiveInfiniteQuery.cjs +95 -118
- package/dist/cjs/useLiveInfiniteQuery.cjs.map +1 -1
- package/dist/cjs/useLiveInfiniteQuery.d.cts +2 -1
- package/dist/cjs/useLiveQuery.cjs +10 -86
- package/dist/cjs/useLiveQuery.cjs.map +1 -1
- package/dist/esm/useLiveInfiniteQuery.d.ts +2 -1
- package/dist/esm/useLiveInfiniteQuery.js +97 -120
- package/dist/esm/useLiveInfiniteQuery.js.map +1 -1
- package/dist/esm/useLiveQuery.js +11 -87
- package/dist/esm/useLiveQuery.js.map +1 -1
- package/package.json +2 -2
- package/src/useLiveInfiniteQuery.ts +157 -189
- package/src/useLiveQuery.ts +31 -138
|
@@ -1,24 +1,30 @@
|
|
|
1
|
-
import { useCallback,
|
|
2
|
-
import {
|
|
3
|
-
|
|
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
|
-
|
|
21
|
+
LiveQueryWindowController,
|
|
10
22
|
NonSingleResult,
|
|
11
23
|
QueryBuilder,
|
|
12
24
|
} from '@tanstack/db'
|
|
13
25
|
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
163
|
+
const pageSize = normalizeLiveQueryWindowPageSize(config.pageSize)
|
|
141
164
|
const initialPageParam = config.initialPageParam ?? 0
|
|
142
165
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
294
|
+
hasNextPage: snapshot.hasNextPage,
|
|
295
|
+
isFetchingNextPage: snapshot.isFetchingNextPage,
|
|
296
|
+
error: snapshot.error,
|
|
297
|
+
}
|
|
330
298
|
}
|
package/src/useLiveQuery.ts
CHANGED
|
@@ -2,9 +2,8 @@ import { useRef, useSyncExternalStore } from 'react'
|
|
|
2
2
|
import {
|
|
3
3
|
BaseQueryBuilder,
|
|
4
4
|
createLiveQueryCollection,
|
|
5
|
-
|
|
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
|
-
//
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
419
|
-
|
|
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
|
-
//
|
|
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
|
|
428
|
-
|
|
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
|
|
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
|
}
|