@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/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(observer: {
243
- subscribe: (cb: () => void) => () => void
244
- }): void {
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(() => observer.subscribe(forceRender), [observer])
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
- const result = observer.getOptimisticResult(observer.options as any)
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
- if (result.status === 'error') throw result.error
304
- if (result.status === 'pending') {
305
- throw observer.fetchOptimistic(observer.options as any)
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: result.data as TData,
314
- error: result.error as TError | null,
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: result.isFetching,
320
- isPlaceholderData: result.isPlaceholderData,
321
- fetchStatus: result.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
- const result = observer.getOptimisticResult(observer.options as any)
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 (result.status === 'error') throw result.error
375
- if (result.status === 'pending') {
376
- throw (
377
- observer as unknown as {
378
- fetchOptimistic: (
379
- options: typeof observer.options,
380
- ) => Promise<unknown>
381
- }
382
- ).fetchOptimistic(observer.options)
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
- const r = result as typeof result & {
386
- hasNextPage: boolean
387
- hasPreviousPage: boolean
388
- isFetchingNextPage: boolean
389
- isFetchingPreviousPage: boolean
390
- isFetchNextPageError: boolean
391
- isFetchPreviousPageError: boolean
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: r.data as TData,
396
- error: r.error as TError | null,
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: r.isFetching,
402
- isPlaceholderData: r.isPlaceholderData,
403
- fetchStatus: r.fetchStatus,
404
- hasNextPage: r.hasNextPage,
405
- hasPreviousPage: r.hasPreviousPage,
406
- isFetchingNextPage: r.isFetchingNextPage,
407
- isFetchingPreviousPage: r.isFetchingPreviousPage,
408
- isFetchNextPageError: r.isFetchNextPageError,
409
- isFetchPreviousPageError: r.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
- const observer = observerInScope ?? transient
478
- if (!observer) {
479
- throw new Error(
480
- '[@effector-tanstack-query/react] useSuspenseQuery: no QueryClient is set. ' +
481
- 'Call setQueryClient(qc) or pass it to fork({ values: [[$queryClient, qc]] }).',
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
- return observer
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
  }