@effector-tanstack-query/react 0.3.0 → 0.4.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/dist/index.cjs +300 -31
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +20 -2
- package/dist/index.d.ts +20 -2
- package/dist/index.js +299 -32
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +492 -54
package/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
InfiniteQueryResult,
|
|
16
16
|
MutationResult,
|
|
17
17
|
MutationStatus,
|
|
18
|
+
QueriesResult,
|
|
18
19
|
QueryResult,
|
|
19
20
|
} from '@effector-tanstack-query/core'
|
|
20
21
|
|
|
@@ -226,6 +227,119 @@ export function useInfiniteQuery<TData, TError = Error, TPageParam = unknown>(
|
|
|
226
227
|
return { ...state, refresh, fetchNextPage, fetchPreviousPage }
|
|
227
228
|
}
|
|
228
229
|
|
|
230
|
+
// =============================================================================
|
|
231
|
+
// useQueries — parallel reads across a static tuple of factories OR a family
|
|
232
|
+
// produced by `createQueries({ source, query })`.
|
|
233
|
+
// =============================================================================
|
|
234
|
+
|
|
235
|
+
type UseQueriesTuple = ReadonlyArray<QueryResult<any, any>>
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Maps a tuple of `QueryResult<TData, TError>` to a tuple of
|
|
239
|
+
* `UseQueryResult<TData, TError>`, preserving per-element types so
|
|
240
|
+
* destructuring (`const [user, posts] = useQueries([...] as const)`)
|
|
241
|
+
* yields fully-typed entries.
|
|
242
|
+
*/
|
|
243
|
+
export type UseQueriesTupleResult<T extends UseQueriesTuple> = {
|
|
244
|
+
[K in keyof T]: T[K] extends QueryResult<infer D, infer E>
|
|
245
|
+
? UseQueryResult<D, E>
|
|
246
|
+
: never
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function useQueries<const T extends UseQueriesTuple>(
|
|
250
|
+
queries: T,
|
|
251
|
+
): UseQueriesTupleResult<T>
|
|
252
|
+
export function useQueries<TItem, TData, TError>(
|
|
253
|
+
family: QueriesResult<TItem, TData, TError>,
|
|
254
|
+
): ReadonlyArray<UseQueryResult<TData, TError>>
|
|
255
|
+
export function useQueries(
|
|
256
|
+
arg: UseQueriesTuple | QueriesResult<unknown, unknown, unknown>,
|
|
257
|
+
): UseQueriesTupleResult<UseQueriesTuple> | ReadonlyArray<UseQueryResult<unknown, unknown>> {
|
|
258
|
+
if (Array.isArray(arg)) {
|
|
259
|
+
return useQueriesTuple(arg)
|
|
260
|
+
}
|
|
261
|
+
return useQueriesFamily(arg as QueriesResult<unknown, unknown, unknown>)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function useQueriesTuple<T extends UseQueriesTuple>(
|
|
265
|
+
queries: T,
|
|
266
|
+
): UseQueriesTupleResult<T> {
|
|
267
|
+
// Twelve `useUnit` calls — count is FIXED, independent of N. The
|
|
268
|
+
// array argument may grow / shrink between renders; effector-react
|
|
269
|
+
// re-subscribes the underlying stores transparently.
|
|
270
|
+
//
|
|
271
|
+
// Rules-of-hooks constraint: the SHAPE of the call list must be
|
|
272
|
+
// stable, not the length of each input array. We satisfy this with a
|
|
273
|
+
// fixed sequence of 9 state-store calls + 3 event-bind calls.
|
|
274
|
+
const datas = useUnit(queries.map((q) => q.$data))
|
|
275
|
+
const errors = useUnit(queries.map((q) => q.$error))
|
|
276
|
+
const statuses = useUnit(queries.map((q) => q.$status))
|
|
277
|
+
const isPendings = useUnit(queries.map((q) => q.$isPending))
|
|
278
|
+
const isFetchings = useUnit(queries.map((q) => q.$isFetching))
|
|
279
|
+
const isSuccesses = useUnit(queries.map((q) => q.$isSuccess))
|
|
280
|
+
const isErrors = useUnit(queries.map((q) => q.$isError))
|
|
281
|
+
const isPlaceholderDatas = useUnit(queries.map((q) => q.$isPlaceholderData))
|
|
282
|
+
const fetchStatuses = useUnit(queries.map((q) => q.$fetchStatus))
|
|
283
|
+
|
|
284
|
+
const mounts = useUnit(queries.map((q) => q.mounted))
|
|
285
|
+
const unmounts = useUnit(queries.map((q) => q.unmounted))
|
|
286
|
+
const refreshes = useUnit(queries.map((q) => q.refresh))
|
|
287
|
+
|
|
288
|
+
React.useEffect(() => {
|
|
289
|
+
for (const m of mounts) m()
|
|
290
|
+
return () => {
|
|
291
|
+
for (const u of unmounts) u()
|
|
292
|
+
}
|
|
293
|
+
// The dep on length re-runs mount/unmount when the consumer swaps
|
|
294
|
+
// out the factory set (rare; most consumers pass a stable `as const`
|
|
295
|
+
// literal). Function refs from `useUnit` are stable per (unit, scope)
|
|
296
|
+
// — depending on their array identity would re-fire every render.
|
|
297
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
298
|
+
}, [queries.length])
|
|
299
|
+
|
|
300
|
+
return queries.map((_, i) => ({
|
|
301
|
+
data: datas[i],
|
|
302
|
+
error: errors[i],
|
|
303
|
+
status: statuses[i],
|
|
304
|
+
isPending: isPendings[i],
|
|
305
|
+
isFetching: isFetchings[i],
|
|
306
|
+
isSuccess: isSuccesses[i],
|
|
307
|
+
isError: isErrors[i],
|
|
308
|
+
isPlaceholderData: isPlaceholderDatas[i],
|
|
309
|
+
fetchStatus: fetchStatuses[i],
|
|
310
|
+
refresh: refreshes[i],
|
|
311
|
+
})) as UseQueriesTupleResult<T>
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function useQueriesFamily<TItem, TData, TError>(
|
|
315
|
+
family: QueriesResult<TItem, TData, TError>,
|
|
316
|
+
): ReadonlyArray<UseQueryResult<TData, TError>> {
|
|
317
|
+
const items = useUnit(family.$items)
|
|
318
|
+
const mount = useUnit(family.mounted)
|
|
319
|
+
const unmount = useUnit(family.unmounted)
|
|
320
|
+
const refreshOne = useUnit(family.refreshOne)
|
|
321
|
+
|
|
322
|
+
React.useEffect(() => {
|
|
323
|
+
mount()
|
|
324
|
+
return () => unmount()
|
|
325
|
+
}, [mount, unmount])
|
|
326
|
+
|
|
327
|
+
return items.map((it) => ({
|
|
328
|
+
data: it.data,
|
|
329
|
+
error: it.error,
|
|
330
|
+
status: it.status,
|
|
331
|
+
isPending: it.isPending,
|
|
332
|
+
isFetching: it.isFetching,
|
|
333
|
+
isSuccess: it.isSuccess,
|
|
334
|
+
isError: it.isError,
|
|
335
|
+
isPlaceholderData: it.isPlaceholderData,
|
|
336
|
+
fetchStatus: it.fetchStatus,
|
|
337
|
+
// Per-item refresh — routes through the family's `refreshOne(item)`
|
|
338
|
+
// so the consumer doesn't have to thread the source manually.
|
|
339
|
+
refresh: () => refreshOne(it.source),
|
|
340
|
+
}))
|
|
341
|
+
}
|
|
342
|
+
|
|
229
343
|
// Suspense data path: read from a per-scope observer.
|
|
230
344
|
//
|
|
231
345
|
// The scope mount chain runs in useEffect, which is skipped while a component
|
|
@@ -239,11 +353,14 @@ export function useInfiniteQuery<TData, TError = Error, TPageParam = unknown>(
|
|
|
239
353
|
// The mount/unmount effect is still wired up so that other consumers reading
|
|
240
354
|
// the same query through `useUnit` / `useQuery` see updates in scope state.
|
|
241
355
|
|
|
242
|
-
function useObserverRerender(
|
|
243
|
-
subscribe: (cb: () => void) => () => void
|
|
244
|
-
|
|
356
|
+
function useObserverRerender(
|
|
357
|
+
observer: { subscribe: (cb: () => void) => () => void } | null,
|
|
358
|
+
): void {
|
|
245
359
|
const [, forceRender] = React.useReducer((x: number) => x + 1, 0)
|
|
246
|
-
React.useEffect(() =>
|
|
360
|
+
React.useEffect(() => {
|
|
361
|
+
if (!observer) return
|
|
362
|
+
return observer.subscribe(forceRender)
|
|
363
|
+
}, [observer])
|
|
247
364
|
}
|
|
248
365
|
|
|
249
366
|
interface SuspenseFactory<TObserver> {
|
|
@@ -295,30 +412,71 @@ export function useSuspenseQuery<TData, TError = Error>(
|
|
|
295
412
|
}, [mount, unmount])
|
|
296
413
|
|
|
297
414
|
const observer = useSuspenseObserver(query)
|
|
298
|
-
|
|
299
415
|
useObserverRerender(observer)
|
|
300
416
|
|
|
301
|
-
|
|
417
|
+
// Store snapshot — always read so hook order is fixed across renders.
|
|
418
|
+
// The observer path doesn't use it; the store-only path (server-RSC of
|
|
419
|
+
// a scope rehydrated from `serialize`, where `$observer` and
|
|
420
|
+
// `$queryClient` are both `null` because they're `serialize: 'ignore'`)
|
|
421
|
+
// reads everything from here.
|
|
422
|
+
const state = useUnit({
|
|
423
|
+
data: query.$data,
|
|
424
|
+
error: query.$error,
|
|
425
|
+
status: query.$status,
|
|
426
|
+
isFetching: query.$isFetching,
|
|
427
|
+
isPlaceholderData: query.$isPlaceholderData,
|
|
428
|
+
fetchStatus: query.$fetchStatus,
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
// Observer path: `getOptimisticResult` reads from `QueryCache`
|
|
432
|
+
// synchronously and reflects fetches/refetches the moment the observer
|
|
433
|
+
// notifies. Used whenever we have an in-scope observer
|
|
434
|
+
// (post-`mounted()`) or a transient one built from `$queryClient`.
|
|
435
|
+
if (observer) {
|
|
436
|
+
const result = observer.getOptimisticResult(observer.options as any)
|
|
437
|
+
|
|
438
|
+
if (result.status === 'error') throw result.error
|
|
439
|
+
if (result.status === 'pending') {
|
|
440
|
+
throw observer.fetchOptimistic(observer.options as any)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
data: result.data as TData,
|
|
445
|
+
error: result.error as TError | null,
|
|
446
|
+
status: 'success',
|
|
447
|
+
isPending: false,
|
|
448
|
+
isSuccess: true,
|
|
449
|
+
isError: false,
|
|
450
|
+
isFetching: result.isFetching,
|
|
451
|
+
isPlaceholderData: result.isPlaceholderData,
|
|
452
|
+
fetchStatus: result.fetchStatus,
|
|
453
|
+
refresh,
|
|
454
|
+
}
|
|
455
|
+
}
|
|
302
456
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
457
|
+
// Store-only path: no observer materialisable. Trust `$status` —
|
|
458
|
+
// populated by `prefetchQueries` before the scope was serialised. A
|
|
459
|
+
// pending status here means there's no `QueryClient` and no prefetch
|
|
460
|
+
// happened: can't deduplicate-throw a fetch promise, so surface the
|
|
461
|
+
// misconfiguration as an error to `<ErrorBoundary>`.
|
|
462
|
+
if (state.status === 'error') throw state.error
|
|
463
|
+
if (state.status === 'pending') {
|
|
464
|
+
throw new Error(
|
|
465
|
+
'[@effector-tanstack-query/react] useSuspenseQuery: no QueryClient is set. ' +
|
|
466
|
+
'Call setQueryClient(qc) or pass it to fork({ values: [[$queryClient, qc]] }).',
|
|
467
|
+
)
|
|
306
468
|
}
|
|
307
469
|
|
|
308
|
-
// Read all secondary fields from the observer result, not the effector
|
|
309
|
-
// stores: stores are only populated after mountFx fires from useEffect,
|
|
310
|
-
// which on the very first successful render hasn't run yet. The observer
|
|
311
|
-
// result is always live and consistent.
|
|
312
470
|
return {
|
|
313
|
-
data:
|
|
314
|
-
error:
|
|
471
|
+
data: state.data as TData,
|
|
472
|
+
error: state.error as TError | null,
|
|
315
473
|
status: 'success',
|
|
316
474
|
isPending: false,
|
|
317
475
|
isSuccess: true,
|
|
318
476
|
isError: false,
|
|
319
|
-
isFetching:
|
|
320
|
-
isPlaceholderData:
|
|
321
|
-
fetchStatus:
|
|
477
|
+
isFetching: state.isFetching,
|
|
478
|
+
isPlaceholderData: state.isPlaceholderData,
|
|
479
|
+
fetchStatus: state.fetchStatus,
|
|
322
480
|
refresh,
|
|
323
481
|
}
|
|
324
482
|
}
|
|
@@ -366,47 +524,96 @@ export function useSuspenseInfiniteQuery<
|
|
|
366
524
|
}, [mount, unmount])
|
|
367
525
|
|
|
368
526
|
const observer = useSuspenseObserver(query)
|
|
369
|
-
|
|
370
527
|
useObserverRerender(observer)
|
|
371
528
|
|
|
372
|
-
|
|
529
|
+
// See `useSuspenseQuery` for the observer-vs-stores dual path rationale.
|
|
530
|
+
// Infinite queries carry pagination fields in separate effector stores
|
|
531
|
+
// (`$hasNextPage`, `$isFetchingNextPage`, …), so the store snapshot has
|
|
532
|
+
// to read those too.
|
|
533
|
+
const state = useUnit({
|
|
534
|
+
data: query.$data,
|
|
535
|
+
error: query.$error,
|
|
536
|
+
status: query.$status,
|
|
537
|
+
isFetching: query.$isFetching,
|
|
538
|
+
isPlaceholderData: query.$isPlaceholderData,
|
|
539
|
+
fetchStatus: query.$fetchStatus,
|
|
540
|
+
hasNextPage: query.$hasNextPage,
|
|
541
|
+
hasPreviousPage: query.$hasPreviousPage,
|
|
542
|
+
isFetchingNextPage: query.$isFetchingNextPage,
|
|
543
|
+
isFetchingPreviousPage: query.$isFetchingPreviousPage,
|
|
544
|
+
isFetchNextPageError: query.$isFetchNextPageError,
|
|
545
|
+
isFetchPreviousPageError: query.$isFetchPreviousPageError,
|
|
546
|
+
})
|
|
373
547
|
|
|
374
|
-
if (
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
548
|
+
if (observer) {
|
|
549
|
+
const obs = observer
|
|
550
|
+
const result = obs.getOptimisticResult(obs.options as any)
|
|
551
|
+
|
|
552
|
+
if (result.status === 'error') throw result.error
|
|
553
|
+
if (result.status === 'pending') {
|
|
554
|
+
throw (
|
|
555
|
+
obs as unknown as {
|
|
556
|
+
fetchOptimistic: (options: typeof obs.options) => Promise<unknown>
|
|
557
|
+
}
|
|
558
|
+
).fetchOptimistic(obs.options)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const r = result as typeof result & {
|
|
562
|
+
hasNextPage: boolean
|
|
563
|
+
hasPreviousPage: boolean
|
|
564
|
+
isFetchingNextPage: boolean
|
|
565
|
+
isFetchingPreviousPage: boolean
|
|
566
|
+
isFetchNextPageError: boolean
|
|
567
|
+
isFetchPreviousPageError: boolean
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return {
|
|
571
|
+
data: r.data as TData,
|
|
572
|
+
error: r.error as TError | null,
|
|
573
|
+
status: 'success',
|
|
574
|
+
isPending: false,
|
|
575
|
+
isSuccess: true,
|
|
576
|
+
isError: false,
|
|
577
|
+
isFetching: r.isFetching,
|
|
578
|
+
isPlaceholderData: r.isPlaceholderData,
|
|
579
|
+
fetchStatus: r.fetchStatus,
|
|
580
|
+
hasNextPage: r.hasNextPage,
|
|
581
|
+
hasPreviousPage: r.hasPreviousPage,
|
|
582
|
+
isFetchingNextPage: r.isFetchingNextPage,
|
|
583
|
+
isFetchingPreviousPage: r.isFetchingPreviousPage,
|
|
584
|
+
isFetchNextPageError: r.isFetchNextPageError,
|
|
585
|
+
isFetchPreviousPageError: r.isFetchPreviousPageError,
|
|
586
|
+
refresh,
|
|
587
|
+
fetchNextPage,
|
|
588
|
+
fetchPreviousPage,
|
|
589
|
+
}
|
|
383
590
|
}
|
|
384
591
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
592
|
+
// Store-only path (server-RSC of a hydrated scope).
|
|
593
|
+
if (state.status === 'error') throw state.error
|
|
594
|
+
if (state.status === 'pending') {
|
|
595
|
+
throw new Error(
|
|
596
|
+
'[@effector-tanstack-query/react] useSuspenseInfiniteQuery: no QueryClient is set. ' +
|
|
597
|
+
'Call setQueryClient(qc) or pass it to fork({ values: [[$queryClient, qc]] }).',
|
|
598
|
+
)
|
|
392
599
|
}
|
|
393
600
|
|
|
394
601
|
return {
|
|
395
|
-
data:
|
|
396
|
-
error:
|
|
602
|
+
data: state.data as TData,
|
|
603
|
+
error: state.error as TError | null,
|
|
397
604
|
status: 'success',
|
|
398
605
|
isPending: false,
|
|
399
606
|
isSuccess: true,
|
|
400
607
|
isError: false,
|
|
401
|
-
isFetching:
|
|
402
|
-
isPlaceholderData:
|
|
403
|
-
fetchStatus:
|
|
404
|
-
hasNextPage:
|
|
405
|
-
hasPreviousPage:
|
|
406
|
-
isFetchingNextPage:
|
|
407
|
-
isFetchingPreviousPage:
|
|
408
|
-
isFetchNextPageError:
|
|
409
|
-
isFetchPreviousPageError:
|
|
608
|
+
isFetching: state.isFetching,
|
|
609
|
+
isPlaceholderData: state.isPlaceholderData,
|
|
610
|
+
fetchStatus: state.fetchStatus,
|
|
611
|
+
hasNextPage: state.hasNextPage,
|
|
612
|
+
hasPreviousPage: state.hasPreviousPage,
|
|
613
|
+
isFetchingNextPage: state.isFetchingNextPage,
|
|
614
|
+
isFetchingPreviousPage: state.isFetchingPreviousPage,
|
|
615
|
+
isFetchNextPageError: state.isFetchNextPageError,
|
|
616
|
+
isFetchPreviousPageError: state.isFetchPreviousPageError,
|
|
410
617
|
refresh,
|
|
411
618
|
fetchNextPage,
|
|
412
619
|
fetchPreviousPage,
|
|
@@ -450,7 +657,7 @@ function useSuspenseObserver<
|
|
|
450
657
|
}
|
|
451
658
|
fetchOptimistic(options: any): Promise<unknown>
|
|
452
659
|
},
|
|
453
|
-
>(query: TQuery): TObserver {
|
|
660
|
+
>(query: TQuery): TObserver | null {
|
|
454
661
|
const factory = query as unknown as TQuery & SuspenseFactory<TObserver>
|
|
455
662
|
const observerInScope = useUnit(query.$observer) as TObserver | null
|
|
456
663
|
const qc = useUnit(query.$queryClient)
|
|
@@ -474,12 +681,243 @@ function useSuspenseObserver<
|
|
|
474
681
|
transient.setOptions({ ...transient.options, queryKey, enabled })
|
|
475
682
|
}, [transient, queryKey, enabled])
|
|
476
683
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
684
|
+
// Null is a legitimate return: server-RSC render of a scope built from
|
|
685
|
+
// `serialize(scope)` has neither `$observer` nor `$queryClient` (both are
|
|
686
|
+
// `serialize: 'ignore'` — instances can't ride through the RSC boundary).
|
|
687
|
+
// Callers branch on `$status === 'success'` from the serialized stores;
|
|
688
|
+
// they only error out when a pending state is unreachable without an
|
|
689
|
+
// observer to throw `fetchOptimistic` on.
|
|
690
|
+
return observerInScope ?? transient
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// =============================================================================
|
|
694
|
+
// useSuspenseQueries — Suspense variant of `useQueries`. Same two overloads:
|
|
695
|
+
// static tuple of factories OR a family from `createQueries(...)`.
|
|
696
|
+
// =============================================================================
|
|
697
|
+
|
|
698
|
+
type UseSuspenseQueriesTuple = ReadonlyArray<QueryResult<any, any>>
|
|
699
|
+
|
|
700
|
+
export type UseSuspenseQueriesTupleResult<T extends UseSuspenseQueriesTuple> = {
|
|
701
|
+
[K in keyof T]: T[K] extends QueryResult<infer D, infer E>
|
|
702
|
+
? UseSuspenseQueryResult<D, E>
|
|
703
|
+
: never
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
export function useSuspenseQueries<const T extends UseSuspenseQueriesTuple>(
|
|
707
|
+
queries: T,
|
|
708
|
+
): UseSuspenseQueriesTupleResult<T>
|
|
709
|
+
export function useSuspenseQueries<TItem, TData, TError>(
|
|
710
|
+
family: QueriesResult<TItem, TData, TError>,
|
|
711
|
+
): ReadonlyArray<UseSuspenseQueryResult<TData, TError>>
|
|
712
|
+
export function useSuspenseQueries(
|
|
713
|
+
arg: UseSuspenseQueriesTuple | QueriesResult<unknown, unknown, unknown>,
|
|
714
|
+
):
|
|
715
|
+
| UseSuspenseQueriesTupleResult<UseSuspenseQueriesTuple>
|
|
716
|
+
| ReadonlyArray<UseSuspenseQueryResult<unknown, unknown>> {
|
|
717
|
+
if (Array.isArray(arg)) {
|
|
718
|
+
return useSuspenseQueriesTuple(arg)
|
|
719
|
+
}
|
|
720
|
+
return useSuspenseQueriesFamily(
|
|
721
|
+
arg as QueriesResult<unknown, unknown, unknown>,
|
|
722
|
+
)
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function useSuspenseQueriesTuple<T extends UseSuspenseQueriesTuple>(
|
|
726
|
+
queries: T,
|
|
727
|
+
): UseSuspenseQueriesTupleResult<T> {
|
|
728
|
+
// Mount lifecycle (same as `useQueriesTuple`).
|
|
729
|
+
const mounts = useUnit(queries.map((q) => q.mounted))
|
|
730
|
+
const unmounts = useUnit(queries.map((q) => q.unmounted))
|
|
731
|
+
const refreshes = useUnit(queries.map((q) => q.refresh))
|
|
732
|
+
React.useEffect(() => {
|
|
733
|
+
for (const m of mounts) m()
|
|
734
|
+
return () => {
|
|
735
|
+
for (const u of unmounts) u()
|
|
736
|
+
}
|
|
737
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
738
|
+
}, [queries.length])
|
|
739
|
+
|
|
740
|
+
// Per-query state from stores — fixed hook count.
|
|
741
|
+
const datas = useUnit(queries.map((q) => q.$data))
|
|
742
|
+
const errors = useUnit(queries.map((q) => q.$error))
|
|
743
|
+
const statuses = useUnit(queries.map((q) => q.$status))
|
|
744
|
+
const isFetchings = useUnit(queries.map((q) => q.$isFetching))
|
|
745
|
+
const isPlaceholderDatas = useUnit(queries.map((q) => q.$isPlaceholderData))
|
|
746
|
+
const fetchStatuses = useUnit(queries.map((q) => q.$fetchStatus))
|
|
747
|
+
|
|
748
|
+
// Observer / qc / key info per query — same fixed-count pattern.
|
|
749
|
+
const observersInScope = useUnit(queries.map((q) => q.$observer))
|
|
750
|
+
const qcs = useUnit(queries.map((q) => q.$queryClient))
|
|
751
|
+
const resolvedKeys = useUnit(
|
|
752
|
+
queries.map((q) => (q as unknown as SuspenseFactory<unknown>).__resolvedKey),
|
|
753
|
+
)
|
|
754
|
+
const enabledStates = useUnit(
|
|
755
|
+
queries.map((q) => (q as unknown as SuspenseFactory<unknown>).__enabled),
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
// Transient observers per slot (null when an in-scope observer
|
|
759
|
+
// exists or no qc is available). One useMemo across all queries —
|
|
760
|
+
// hook-count stable.
|
|
761
|
+
const transients = React.useMemo(() => {
|
|
762
|
+
return queries.map((q, i) => {
|
|
763
|
+
if (observersInScope[i]) return null
|
|
764
|
+
const qc = qcs[i]
|
|
765
|
+
if (!qc) return null
|
|
766
|
+
return (q as unknown as SuspenseFactory<any>).__createObserver(qc, {
|
|
767
|
+
queryKey: resolvedKeys[i],
|
|
768
|
+
enabled: enabledStates[i] as boolean,
|
|
769
|
+
})
|
|
770
|
+
})
|
|
771
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
772
|
+
}, [queries, ...observersInScope, ...qcs, ...resolvedKeys, ...enabledStates])
|
|
773
|
+
|
|
774
|
+
type SuspendableObserver = {
|
|
775
|
+
options: { queryKey: unknown }
|
|
776
|
+
subscribe(cb: () => void): () => void
|
|
777
|
+
fetchOptimistic(options: any): Promise<unknown>
|
|
778
|
+
getOptimisticResult(options: any): {
|
|
779
|
+
status: 'pending' | 'success' | 'error'
|
|
780
|
+
data: unknown
|
|
781
|
+
error: unknown
|
|
782
|
+
isFetching: boolean
|
|
783
|
+
isPlaceholderData: boolean
|
|
784
|
+
fetchStatus: FetchStatus
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
const observers = queries.map(
|
|
788
|
+
(_, i) =>
|
|
789
|
+
((observersInScope[i] ?? transients[i]) ?? null) as
|
|
790
|
+
| SuspendableObserver
|
|
791
|
+
| null,
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
// Subscribe to every live observer in one effect so the consumer
|
|
795
|
+
// re-renders when ANY of them notifies.
|
|
796
|
+
const [, forceRender] = React.useReducer((x: number) => x + 1, 0)
|
|
797
|
+
React.useEffect(() => {
|
|
798
|
+
const unsubs = observers.map((obs) =>
|
|
799
|
+
obs ? obs.subscribe(forceRender) : null,
|
|
800
|
+
)
|
|
801
|
+
return () => {
|
|
802
|
+
for (const u of unsubs) u?.()
|
|
803
|
+
}
|
|
804
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
805
|
+
}, [queries.length, ...observers])
|
|
806
|
+
|
|
807
|
+
// Per-slot live result — from observer if available (synchronous
|
|
808
|
+
// QueryCache read), otherwise null and we fall back to the
|
|
809
|
+
// effector-store snapshot. Mirrors the dual path in
|
|
810
|
+
// `useSuspenseQuery` so SSR scopes (`$observer` + `$queryClient`
|
|
811
|
+
// both null) keep working.
|
|
812
|
+
const liveResults = observers.map((obs) =>
|
|
813
|
+
obs ? obs.getOptimisticResult(obs.options) : null,
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
// Errors first — first error wins.
|
|
817
|
+
for (let i = 0; i < queries.length; i++) {
|
|
818
|
+
const live = liveResults[i]
|
|
819
|
+
if (live) {
|
|
820
|
+
if (live.status === 'error') throw live.error
|
|
821
|
+
} else if (statuses[i] === 'error') {
|
|
822
|
+
throw errors[i]
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Then pending — collect every pending query's inflight promise into
|
|
827
|
+
// a single `Promise.all` thrown to Suspense. The store-only path has
|
|
828
|
+
// no observer to fetch with: that's a misconfiguration (no QC, no
|
|
829
|
+
// prefetch), throw a clear error.
|
|
830
|
+
const pendingPromises: Array<Promise<unknown>> = []
|
|
831
|
+
for (let i = 0; i < queries.length; i++) {
|
|
832
|
+
const live = liveResults[i]
|
|
833
|
+
const obs = observers[i]
|
|
834
|
+
if (live) {
|
|
835
|
+
if (live.status === 'pending' && obs) {
|
|
836
|
+
pendingPromises.push(obs.fetchOptimistic(obs.options))
|
|
837
|
+
}
|
|
838
|
+
} else if (statuses[i] === 'pending') {
|
|
839
|
+
throw new Error(
|
|
840
|
+
'[@effector-tanstack-query/react] useSuspenseQueries: no QueryClient is set. ' +
|
|
841
|
+
'Call setQueryClient(qc) or pass it to fork({ values: [[$queryClient, qc]] }).',
|
|
842
|
+
)
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
if (pendingPromises.length > 0) throw Promise.all(pendingPromises)
|
|
846
|
+
|
|
847
|
+
// All success — shape the result tuple. Prefer the observer's live
|
|
848
|
+
// result when present (reflects in-flight refetches), fall back to
|
|
849
|
+
// the store snapshot otherwise.
|
|
850
|
+
return queries.map((_, i) => {
|
|
851
|
+
const live = liveResults[i]
|
|
852
|
+
return {
|
|
853
|
+
data: (live ? live.data : datas[i]) as unknown,
|
|
854
|
+
error: (live ? live.error : (errors[i] ?? null)) as unknown,
|
|
855
|
+
status: 'success' as const,
|
|
856
|
+
isPending: false as const,
|
|
857
|
+
isSuccess: true as const,
|
|
858
|
+
isError: false as const,
|
|
859
|
+
isFetching: (live ? live.isFetching : isFetchings[i]) as boolean,
|
|
860
|
+
isPlaceholderData: (live
|
|
861
|
+
? live.isPlaceholderData
|
|
862
|
+
: isPlaceholderDatas[i]) as boolean,
|
|
863
|
+
fetchStatus: (live ? live.fetchStatus : fetchStatuses[i]) as FetchStatus,
|
|
864
|
+
refresh: refreshes[i] as () => void,
|
|
865
|
+
}
|
|
866
|
+
}) as UseSuspenseQueriesTupleResult<T>
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
interface FamilyInternals<TItem> {
|
|
870
|
+
__queryFor: (item: TItem) => {
|
|
871
|
+
queryKey: ReadonlyArray<unknown>
|
|
872
|
+
queryFn?: unknown
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function useSuspenseQueriesFamily<TItem, TData, TError>(
|
|
877
|
+
family: QueriesResult<TItem, TData, TError>,
|
|
878
|
+
): ReadonlyArray<UseSuspenseQueryResult<TData, TError>> {
|
|
879
|
+
const mount = useUnit(family.mounted)
|
|
880
|
+
const unmount = useUnit(family.unmounted)
|
|
881
|
+
const refreshOne = useUnit(family.refreshOne)
|
|
882
|
+
React.useEffect(() => {
|
|
883
|
+
mount()
|
|
884
|
+
return () => unmount()
|
|
885
|
+
}, [mount, unmount])
|
|
886
|
+
|
|
887
|
+
const items = useUnit(family.$items)
|
|
888
|
+
const qc = useUnit(family.$queryClient)
|
|
889
|
+
|
|
890
|
+
// Error wins over pending.
|
|
891
|
+
for (const it of items) {
|
|
892
|
+
if (it.status === 'error') throw it.error
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const pending = items.filter((it) => it.status === 'pending')
|
|
896
|
+
if (pending.length > 0) {
|
|
897
|
+
if (!qc) {
|
|
898
|
+
throw new Error(
|
|
899
|
+
'[@effector-tanstack-query/react] useSuspenseQueries: no QueryClient is set. ' +
|
|
900
|
+
'Call setQueryClient(qc) or pass it to fork({ values: [[$queryClient, qc]] }).',
|
|
901
|
+
)
|
|
902
|
+
}
|
|
903
|
+
const queryFor = (family as unknown as FamilyInternals<TItem>).__queryFor
|
|
904
|
+
throw Promise.all(
|
|
905
|
+
pending.map((it) =>
|
|
906
|
+
qc.fetchQuery(queryFor(it.source) as any).catch(() => undefined),
|
|
907
|
+
),
|
|
482
908
|
)
|
|
483
909
|
}
|
|
484
|
-
|
|
910
|
+
|
|
911
|
+
return items.map((it) => ({
|
|
912
|
+
data: it.data as TData,
|
|
913
|
+
error: it.error,
|
|
914
|
+
status: 'success' as const,
|
|
915
|
+
isPending: false as const,
|
|
916
|
+
isSuccess: true as const,
|
|
917
|
+
isError: false as const,
|
|
918
|
+
isFetching: it.isFetching,
|
|
919
|
+
isPlaceholderData: it.isPlaceholderData,
|
|
920
|
+
fetchStatus: it.fetchStatus,
|
|
921
|
+
refresh: () => refreshOne(it.source),
|
|
922
|
+
}))
|
|
485
923
|
}
|