@pyreon/query 0.0.1

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.
@@ -0,0 +1,1768 @@
1
+ import { QueryClient } from '@tanstack/query-core'
2
+ import { h } from '@pyreon/core'
3
+ import { signal } from '@pyreon/reactivity'
4
+ import { mount } from '@pyreon/runtime-dom'
5
+ import {
6
+ QueryClientProvider,
7
+ useQueryClient,
8
+ useQuery,
9
+ useMutation,
10
+ useIsFetching,
11
+ useIsMutating,
12
+ useQueries,
13
+ useSuspenseQuery,
14
+ useSuspenseInfiniteQuery,
15
+ useInfiniteQuery,
16
+ QuerySuspense,
17
+ QueryErrorResetBoundary,
18
+ useQueryErrorResetBoundary,
19
+ dehydrate,
20
+ hydrate,
21
+ } from '../index'
22
+
23
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
24
+
25
+ function makeClient() {
26
+ return new QueryClient({
27
+ defaultOptions: { queries: { retry: false, gcTime: Infinity } },
28
+ })
29
+ }
30
+
31
+ /** Mount a component inside a QueryClientProvider, return unmount fn. */
32
+ function _withProvider(client: QueryClient, component: () => void): () => void {
33
+ const el = document.createElement('div')
34
+ document.body.appendChild(el)
35
+ const unmount = mount(
36
+ h(QueryClientProvider, { client }, () => {
37
+ component()
38
+ return null
39
+ }),
40
+ el,
41
+ )
42
+ return () => {
43
+ unmount()
44
+ el.remove()
45
+ }
46
+ }
47
+
48
+ /** Returns a promise + its resolve/reject handles. */
49
+ function deferred<T>() {
50
+ let resolve!: (v: T) => void
51
+ let reject!: (e: unknown) => void
52
+ const promise = new Promise<T>((res, rej) => {
53
+ resolve = res
54
+ reject = rej
55
+ })
56
+ return { promise, resolve, reject }
57
+ }
58
+
59
+ // ─── QueryClientProvider / useQueryClient ────────────────────────────────────
60
+
61
+ describe('QueryClientProvider / useQueryClient', () => {
62
+ it('useQueryClient throws when no provider is present', () => {
63
+ // Call directly outside any renderer — context stack is empty so it must throw.
64
+ expect(() => useQueryClient()).toThrow('[pyreon/query]')
65
+ })
66
+
67
+ it('provides the QueryClient to descendants', () => {
68
+ const client = makeClient()
69
+ let received: QueryClient | null = null
70
+ const el = document.createElement('div')
71
+ document.body.appendChild(el)
72
+ const unmount = mount(
73
+ h(
74
+ QueryClientProvider,
75
+ { client },
76
+ h(() => {
77
+ received = useQueryClient()
78
+ return null
79
+ }, null),
80
+ ),
81
+ el,
82
+ )
83
+ unmount()
84
+ el.remove()
85
+ expect(received).toBe(client)
86
+ })
87
+
88
+ it('inner provider overrides outer', () => {
89
+ const outer = makeClient()
90
+ const inner = makeClient()
91
+ let received: QueryClient | null = null
92
+ const el = document.createElement('div')
93
+ document.body.appendChild(el)
94
+ const unmount = mount(
95
+ h(
96
+ QueryClientProvider,
97
+ { client: outer },
98
+ h(
99
+ QueryClientProvider,
100
+ { client: inner },
101
+ h(() => {
102
+ received = useQueryClient()
103
+ return null
104
+ }, null),
105
+ ),
106
+ ),
107
+ el,
108
+ )
109
+ unmount()
110
+ el.remove()
111
+ expect(received).toBe(inner)
112
+ })
113
+ })
114
+
115
+ // ─── useQuery ─────────────────────────────────────────────────────────────────
116
+
117
+ describe('useQuery', () => {
118
+ let client: QueryClient
119
+
120
+ beforeEach(() => {
121
+ client = makeClient()
122
+ })
123
+
124
+ it('starts in pending state when cache is empty', () => {
125
+ let query: ReturnType<typeof useQuery> | undefined
126
+ const el = document.createElement('div')
127
+ document.body.appendChild(el)
128
+ const unmount = mount(
129
+ h(
130
+ QueryClientProvider,
131
+ { client },
132
+ h(() => {
133
+ query = useQuery(() => ({
134
+ queryKey: ['test-pending'],
135
+ queryFn: () =>
136
+ new Promise(() => {
137
+ /* never resolves */
138
+ }), // never resolves
139
+ }))
140
+ return null
141
+ }, null),
142
+ ),
143
+ el,
144
+ )
145
+ expect(query!.isPending()).toBe(true)
146
+ expect(query!.data()).toBeUndefined()
147
+ unmount()
148
+ el.remove()
149
+ })
150
+
151
+ it('resolves to success state with data', async () => {
152
+ const { promise, resolve } = deferred<{ name: string }>()
153
+ let query: ReturnType<typeof useQuery<{ name: string }>> | undefined
154
+
155
+ const el = document.createElement('div')
156
+ document.body.appendChild(el)
157
+ const unmount = mount(
158
+ h(
159
+ QueryClientProvider,
160
+ { client },
161
+ h(() => {
162
+ query = useQuery(() => ({
163
+ queryKey: ['test-success'],
164
+ queryFn: () => promise,
165
+ }))
166
+ return null
167
+ }, null),
168
+ ),
169
+ el,
170
+ )
171
+
172
+ resolve({ name: 'Pyreon' })
173
+ await promise
174
+
175
+ // Let the observer's internal promise chain flush
176
+ await new Promise((r) => setTimeout(r, 0))
177
+
178
+ expect(query!.isSuccess()).toBe(true)
179
+ expect(query!.data()).toEqual({ name: 'Pyreon' })
180
+ unmount()
181
+ el.remove()
182
+ })
183
+
184
+ it('captures errors in isError state', async () => {
185
+ const { promise, reject } = deferred<never>()
186
+ let query: ReturnType<typeof useQuery> | undefined
187
+
188
+ const el = document.createElement('div')
189
+ document.body.appendChild(el)
190
+ const unmount = mount(
191
+ h(
192
+ QueryClientProvider,
193
+ { client },
194
+ h(() => {
195
+ query = useQuery(() => ({
196
+ queryKey: ['test-error'],
197
+ queryFn: () => promise,
198
+ }))
199
+ return null
200
+ }, null),
201
+ ),
202
+ el,
203
+ )
204
+
205
+ reject(new Error('fetch failed'))
206
+ await promise.catch(() => {
207
+ /* expected */
208
+ })
209
+ await new Promise((r) => setTimeout(r, 0))
210
+
211
+ expect(query!.isError()).toBe(true)
212
+ expect((query!.error() as Error).message).toBe('fetch failed')
213
+ unmount()
214
+ el.remove()
215
+ })
216
+
217
+ it('respects enabled: false — does not fetch', async () => {
218
+ const queryFn = vi.fn(() => Promise.resolve('should not run'))
219
+ let query: ReturnType<typeof useQuery<string>> | undefined
220
+
221
+ const el = document.createElement('div')
222
+ document.body.appendChild(el)
223
+ const unmount = mount(
224
+ h(
225
+ QueryClientProvider,
226
+ { client },
227
+ h(() => {
228
+ query = useQuery(() => ({
229
+ queryKey: ['test-disabled'],
230
+ queryFn,
231
+ enabled: false,
232
+ }))
233
+ return null
234
+ }, null),
235
+ ),
236
+ el,
237
+ )
238
+
239
+ await new Promise((r) => setTimeout(r, 10))
240
+ expect(queryFn).not.toHaveBeenCalled()
241
+ expect(query!.isPending()).toBe(true)
242
+ expect(query!.isFetching()).toBe(false)
243
+ unmount()
244
+ el.remove()
245
+ })
246
+
247
+ it('reactive query key — refetches when signal changes', async () => {
248
+ const calls: number[] = []
249
+ const userId = signal(1)
250
+ let query: ReturnType<typeof useQuery<string>> | undefined
251
+
252
+ const el = document.createElement('div')
253
+ document.body.appendChild(el)
254
+ const unmount = mount(
255
+ h(
256
+ QueryClientProvider,
257
+ { client },
258
+ h(() => {
259
+ query = useQuery(() => ({
260
+ queryKey: ['user', userId()],
261
+ queryFn: async () => {
262
+ const id = userId()
263
+ calls.push(id)
264
+ return `user-${id}`
265
+ },
266
+ }))
267
+ return null
268
+ }, null),
269
+ ),
270
+ el,
271
+ )
272
+
273
+ await new Promise((r) => setTimeout(r, 10))
274
+ expect(calls).toContain(1)
275
+
276
+ userId.set(2)
277
+ await new Promise((r) => setTimeout(r, 10))
278
+ expect(calls).toContain(2)
279
+
280
+ expect(query!.data()).toBe('user-2')
281
+ unmount()
282
+ el.remove()
283
+ })
284
+
285
+ it('invalidateQueries triggers a refetch', async () => {
286
+ let callCount = 0
287
+ const el = document.createElement('div')
288
+ document.body.appendChild(el)
289
+ const unmount = mount(
290
+ h(
291
+ QueryClientProvider,
292
+ { client },
293
+ h(() => {
294
+ useQuery(() => ({
295
+ queryKey: ['invalidate-test'],
296
+ queryFn: async () => {
297
+ callCount++
298
+ return callCount
299
+ },
300
+ }))
301
+ return null
302
+ }, null),
303
+ ),
304
+ el,
305
+ )
306
+
307
+ await new Promise((r) => setTimeout(r, 10))
308
+ const before = callCount
309
+ await client.invalidateQueries({ queryKey: ['invalidate-test'] })
310
+ await new Promise((r) => setTimeout(r, 10))
311
+ expect(callCount).toBeGreaterThan(before)
312
+ unmount()
313
+ el.remove()
314
+ })
315
+ })
316
+
317
+ // ─── useMutation ──────────────────────────────────────────────────────────────
318
+
319
+ describe('useMutation', () => {
320
+ let client: QueryClient
321
+ beforeEach(() => {
322
+ client = makeClient()
323
+ })
324
+
325
+ it('starts in idle state', () => {
326
+ let mut: ReturnType<typeof useMutation> | undefined
327
+ const el = document.createElement('div')
328
+ document.body.appendChild(el)
329
+ const unmount = mount(
330
+ h(
331
+ QueryClientProvider,
332
+ { client },
333
+ h(() => {
334
+ mut = useMutation({ mutationFn: () => Promise.resolve('ok') })
335
+ return null
336
+ }, null),
337
+ ),
338
+ el,
339
+ )
340
+ expect(mut!.isIdle()).toBe(true)
341
+ expect(mut!.isPending()).toBe(false)
342
+ unmount()
343
+ el.remove()
344
+ })
345
+
346
+ it('goes pending then success', async () => {
347
+ let mut: ReturnType<typeof useMutation<string, Error, string>> | undefined
348
+ const el = document.createElement('div')
349
+ document.body.appendChild(el)
350
+ const unmount = mount(
351
+ h(
352
+ QueryClientProvider,
353
+ { client },
354
+ h(() => {
355
+ mut = useMutation<string, Error, string>({
356
+ mutationFn: async (input: string) => `result:${input}`,
357
+ })
358
+ return null
359
+ }, null),
360
+ ),
361
+ el,
362
+ )
363
+
364
+ mut!.mutate('hello')
365
+ await new Promise((r) => setTimeout(r, 10))
366
+
367
+ expect(mut!.isSuccess()).toBe(true)
368
+ expect(mut!.data()).toBe('result:hello')
369
+ unmount()
370
+ el.remove()
371
+ })
372
+
373
+ it('captures mutation error', async () => {
374
+ let mut: ReturnType<typeof useMutation> | undefined
375
+ const el = document.createElement('div')
376
+ document.body.appendChild(el)
377
+ const unmount = mount(
378
+ h(
379
+ QueryClientProvider,
380
+ { client },
381
+ h(() => {
382
+ mut = useMutation({
383
+ mutationFn: async () => {
384
+ throw new Error('mutation failed')
385
+ },
386
+ })
387
+ return null
388
+ }, null),
389
+ ),
390
+ el,
391
+ )
392
+
393
+ mut!.mutate(undefined)
394
+ await new Promise((r) => setTimeout(r, 10))
395
+
396
+ expect(mut!.isError()).toBe(true)
397
+ expect((mut!.error() as Error).message).toBe('mutation failed')
398
+ unmount()
399
+ el.remove()
400
+ })
401
+
402
+ it('reset() clears mutation state', async () => {
403
+ let mut: ReturnType<typeof useMutation<string, Error, void>> | undefined
404
+ const el = document.createElement('div')
405
+ document.body.appendChild(el)
406
+ const unmount = mount(
407
+ h(
408
+ QueryClientProvider,
409
+ { client },
410
+ h(() => {
411
+ mut = useMutation<string, Error, void>({
412
+ mutationFn: async () => 'done',
413
+ })
414
+ return null
415
+ }, null),
416
+ ),
417
+ el,
418
+ )
419
+
420
+ mut!.mutate(undefined)
421
+ await new Promise((r) => setTimeout(r, 10))
422
+ expect(mut!.isSuccess()).toBe(true)
423
+
424
+ mut!.reset()
425
+ await new Promise((r) => setTimeout(r, 0))
426
+ expect(mut!.isIdle()).toBe(true)
427
+ expect(mut!.data()).toBeUndefined()
428
+ unmount()
429
+ el.remove()
430
+ })
431
+ })
432
+
433
+ // ─── useIsFetching / useIsMutating ────────────────────────────────────────────
434
+
435
+ describe('useIsFetching', () => {
436
+ it('returns 0 when no queries are in-flight', () => {
437
+ const client = makeClient()
438
+ let count: (() => number) | undefined
439
+ const el = document.createElement('div')
440
+ document.body.appendChild(el)
441
+ const unmount = mount(
442
+ h(
443
+ QueryClientProvider,
444
+ { client },
445
+ h(() => {
446
+ count = useIsFetching()
447
+ return null
448
+ }, null),
449
+ ),
450
+ el,
451
+ )
452
+ expect(count!()).toBe(0)
453
+ unmount()
454
+ el.remove()
455
+ })
456
+
457
+ it('increments while a query is fetching', async () => {
458
+ const client = makeClient()
459
+ const { promise, resolve } = deferred<string>()
460
+ const counts: number[] = []
461
+ let isFetching: (() => number) | undefined
462
+
463
+ const el = document.createElement('div')
464
+ document.body.appendChild(el)
465
+ const unmount = mount(
466
+ h(
467
+ QueryClientProvider,
468
+ { client },
469
+ h(() => {
470
+ isFetching = useIsFetching()
471
+ useQuery(() => ({
472
+ queryKey: ['fetch-count'],
473
+ queryFn: () => promise,
474
+ }))
475
+ return null
476
+ }, null),
477
+ ),
478
+ el,
479
+ )
480
+
481
+ // At this point the query has started fetching
482
+ await new Promise((r) => setTimeout(r, 0))
483
+ counts.push(isFetching!())
484
+
485
+ resolve('done')
486
+ await promise
487
+ await new Promise((r) => setTimeout(r, 10))
488
+ counts.push(isFetching!())
489
+
490
+ expect(counts[0]).toBeGreaterThan(0)
491
+ expect(counts[counts.length - 1]).toBe(0)
492
+ unmount()
493
+ el.remove()
494
+ })
495
+ })
496
+
497
+ describe('useIsMutating', () => {
498
+ it('returns 0 when idle, >0 while mutating', async () => {
499
+ const client = makeClient()
500
+ const { promise, resolve } = deferred<void>()
501
+ let isMutating: (() => number) | undefined
502
+ let mut: ReturnType<typeof useMutation<void, Error, void>> | undefined
503
+
504
+ const el = document.createElement('div')
505
+ document.body.appendChild(el)
506
+ const unmount = mount(
507
+ h(
508
+ QueryClientProvider,
509
+ { client },
510
+ h(() => {
511
+ isMutating = useIsMutating()
512
+ mut = useMutation<void, Error, void>({ mutationFn: () => promise })
513
+ return null
514
+ }, null),
515
+ ),
516
+ el,
517
+ )
518
+
519
+ expect(isMutating!()).toBe(0)
520
+ mut!.mutate(undefined)
521
+ await new Promise((r) => setTimeout(r, 0))
522
+ expect(isMutating!()).toBeGreaterThan(0)
523
+ resolve()
524
+ await promise
525
+ await new Promise((r) => setTimeout(r, 10))
526
+ expect(isMutating!()).toBe(0)
527
+ unmount()
528
+ el.remove()
529
+ })
530
+ })
531
+
532
+ // ─── SSR: dehydrate / hydrate ─────────────────────────────────────────────────
533
+
534
+ describe('dehydrate / hydrate', () => {
535
+ it('round-trips query data — prefetched data available without refetching', async () => {
536
+ // Server: prefetch + serialize
537
+ const serverClient = makeClient()
538
+ await serverClient.prefetchQuery({
539
+ queryKey: ['ssr-user'],
540
+ queryFn: async () => ({ name: 'SSR User' }),
541
+ })
542
+ const state = dehydrate(serverClient)
543
+
544
+ // Client: rehydrate
545
+ const clientClient = makeClient()
546
+ hydrate(clientClient, state)
547
+
548
+ let query: ReturnType<typeof useQuery<{ name: string }>> | undefined
549
+ let callCount = 0
550
+
551
+ const el = document.createElement('div')
552
+ document.body.appendChild(el)
553
+ const unmount = mount(
554
+ h(
555
+ QueryClientProvider,
556
+ { client: clientClient },
557
+ h(() => {
558
+ query = useQuery(() => ({
559
+ queryKey: ['ssr-user'],
560
+ queryFn: async () => {
561
+ callCount++
562
+ return { name: 'fresh' }
563
+ },
564
+ staleTime: Infinity, // treat hydrated data as fresh
565
+ }))
566
+ return null
567
+ }, null),
568
+ ),
569
+ el,
570
+ )
571
+
572
+ // Data should be immediately available from the hydrated cache
573
+ expect(query!.isSuccess()).toBe(true)
574
+ expect(query!.data()).toEqual({ name: 'SSR User' })
575
+ // queryFn should NOT have been called (data was in cache)
576
+ expect(callCount).toBe(0)
577
+ unmount()
578
+ el.remove()
579
+ })
580
+ })
581
+
582
+ // ─── useQueries ───────────────────────────────────────────────────────────────
583
+
584
+ describe('useQueries', () => {
585
+ it('returns results for all queries in the array', async () => {
586
+ const client = makeClient()
587
+ let results: ReturnType<typeof useQueries> | undefined
588
+
589
+ const el = document.createElement('div')
590
+ document.body.appendChild(el)
591
+ const unmount = mount(
592
+ h(
593
+ QueryClientProvider,
594
+ { client },
595
+ h(() => {
596
+ results = useQueries(() => [
597
+ { queryKey: ['a'], queryFn: async () => 'alpha' },
598
+ { queryKey: ['b'], queryFn: async () => 'beta' },
599
+ ])
600
+ return null
601
+ }, null),
602
+ ),
603
+ el,
604
+ )
605
+
606
+ await new Promise((r) => setTimeout(r, 20))
607
+ const res = results?.() ?? []
608
+ expect(res).toHaveLength(2)
609
+ expect(res[0]!.data).toBe('alpha')
610
+ expect(res[1]!.data).toBe('beta')
611
+ unmount()
612
+ el.remove()
613
+ })
614
+
615
+ it('reactive — updates when the queries signal changes', async () => {
616
+ const client = makeClient()
617
+ const { signal: sig } = await import('@pyreon/reactivity')
618
+ const ids = sig([1])
619
+ let results: ReturnType<typeof useQueries> | undefined
620
+
621
+ const el = document.createElement('div')
622
+ document.body.appendChild(el)
623
+ const unmount = mount(
624
+ h(
625
+ QueryClientProvider,
626
+ { client },
627
+ h(() => {
628
+ results = useQueries(() =>
629
+ ids().map((id) => ({
630
+ queryKey: ['item', id],
631
+ queryFn: async () => `item-${id}`,
632
+ })),
633
+ )
634
+ return null
635
+ }, null),
636
+ ),
637
+ el,
638
+ )
639
+
640
+ await new Promise((r) => setTimeout(r, 20))
641
+ expect(results?.()).toHaveLength(1)
642
+
643
+ ids.set([1, 2])
644
+ await new Promise((r) => setTimeout(r, 20))
645
+ expect(results?.()).toHaveLength(2)
646
+ expect(results!()[1]!.data).toBe('item-2')
647
+ unmount()
648
+ el.remove()
649
+ })
650
+ })
651
+
652
+ // ─── useSuspenseQuery / QuerySuspense ─────────────────────────────────────────
653
+
654
+ describe('useSuspenseQuery + QuerySuspense', () => {
655
+ let client: QueryClient
656
+ beforeEach(() => {
657
+ client = makeClient()
658
+ })
659
+
660
+ it('QuerySuspense shows fallback while pending, then children on success', async () => {
661
+ const { promise, resolve } = deferred<string>()
662
+ const rendered: string[] = []
663
+
664
+ const el = document.createElement('div')
665
+ document.body.appendChild(el)
666
+ const unmount = mount(
667
+ h(
668
+ QueryClientProvider,
669
+ { client },
670
+ h(() => {
671
+ const q = useSuspenseQuery(() => ({
672
+ queryKey: ['sq-pending'],
673
+ queryFn: () => promise,
674
+ }))
675
+ return h(
676
+ QuerySuspense as any,
677
+ { query: q, fallback: 'loading' },
678
+ () => {
679
+ rendered.push(q.data())
680
+ return null
681
+ },
682
+ )
683
+ }, null),
684
+ ),
685
+ el,
686
+ )
687
+
688
+ // While pending — fallback rendered, children not called
689
+ await new Promise((r) => setTimeout(r, 0))
690
+ expect(rendered).toHaveLength(0)
691
+
692
+ resolve('done')
693
+ await promise
694
+ await new Promise((r) => setTimeout(r, 10))
695
+ expect(rendered.at(-1)).toBe('done')
696
+ unmount()
697
+ el.remove()
698
+ })
699
+
700
+ it('QuerySuspense shows error fallback on query failure', async () => {
701
+ const { promise, reject } = deferred<never>()
702
+ let errorMsg: string | undefined
703
+
704
+ const el = document.createElement('div')
705
+ document.body.appendChild(el)
706
+ const unmount = mount(
707
+ h(
708
+ QueryClientProvider,
709
+ { client },
710
+ h(() => {
711
+ const q = useSuspenseQuery(() => ({
712
+ queryKey: ['sq-error'],
713
+ queryFn: () => promise,
714
+ }))
715
+ return h(
716
+ QuerySuspense as any,
717
+ {
718
+ query: q,
719
+ fallback: 'loading',
720
+ error: (err: unknown) => {
721
+ errorMsg = (err as Error).message
722
+ return null
723
+ },
724
+ },
725
+ () => null,
726
+ )
727
+ }, null),
728
+ ),
729
+ el,
730
+ )
731
+
732
+ reject(new Error('sq failed'))
733
+ await promise.catch(() => {
734
+ /* expected */
735
+ })
736
+ await new Promise((r) => setTimeout(r, 10))
737
+ expect(errorMsg).toBe('sq failed')
738
+ unmount()
739
+ el.remove()
740
+ })
741
+
742
+ it('multiple queries — children only render when all succeed', async () => {
743
+ const d1 = deferred<string>()
744
+ const d2 = deferred<string>()
745
+ let childrenRendered = false
746
+
747
+ const el = document.createElement('div')
748
+ document.body.appendChild(el)
749
+ const unmount = mount(
750
+ h(
751
+ QueryClientProvider,
752
+ { client },
753
+ h(() => {
754
+ const q1 = useSuspenseQuery(() => ({
755
+ queryKey: ['mq1'],
756
+ queryFn: () => d1.promise,
757
+ }))
758
+ const q2 = useSuspenseQuery(() => ({
759
+ queryKey: ['mq2'],
760
+ queryFn: () => d2.promise,
761
+ }))
762
+ return h(
763
+ QuerySuspense as any,
764
+ { query: [q1, q2], fallback: 'loading' },
765
+ () => {
766
+ childrenRendered = true
767
+ return null
768
+ },
769
+ )
770
+ }, null),
771
+ ),
772
+ el,
773
+ )
774
+
775
+ d1.resolve('first')
776
+ await d1.promise
777
+ await new Promise((r) => setTimeout(r, 10))
778
+ // q2 still pending — children should not render
779
+ expect(childrenRendered).toBe(false)
780
+
781
+ d2.resolve('second')
782
+ await d2.promise
783
+ await new Promise((r) => setTimeout(r, 10))
784
+ expect(childrenRendered).toBe(true)
785
+ unmount()
786
+ el.remove()
787
+ })
788
+ })
789
+
790
+ // ─── QueryErrorResetBoundary / useQueryErrorResetBoundary ─────────────────────
791
+
792
+ describe('QueryErrorResetBoundary', () => {
793
+ it('reset() re-triggers fetch for errored queries', async () => {
794
+ const client = makeClient()
795
+ let callCount = 0
796
+ let shouldFail = true
797
+ let resetFn: (() => void) | undefined
798
+
799
+ const el = document.createElement('div')
800
+ document.body.appendChild(el)
801
+ const unmount = mount(
802
+ h(
803
+ QueryClientProvider,
804
+ { client },
805
+ h(
806
+ QueryErrorResetBoundary,
807
+ null,
808
+ h(() => {
809
+ const { reset } = useQueryErrorResetBoundary()
810
+ resetFn = reset
811
+ useQuery(() => ({
812
+ queryKey: ['reset-test'],
813
+ queryFn: async () => {
814
+ callCount++
815
+ if (shouldFail) throw new Error('fail')
816
+ return 'ok'
817
+ },
818
+ }))
819
+ return null
820
+ }, null),
821
+ ),
822
+ ),
823
+ el,
824
+ )
825
+
826
+ await new Promise((r) => setTimeout(r, 10))
827
+ const afterFirst = callCount
828
+
829
+ shouldFail = false
830
+ resetFn?.()
831
+ await new Promise((r) => setTimeout(r, 10))
832
+ expect(callCount).toBeGreaterThan(afterFirst)
833
+ unmount()
834
+ el.remove()
835
+ })
836
+
837
+ it('useQueryErrorResetBoundary works without explicit boundary', () => {
838
+ const client = makeClient()
839
+ let reset: (() => void) | undefined
840
+
841
+ const el = document.createElement('div')
842
+ document.body.appendChild(el)
843
+ const unmount = mount(
844
+ h(
845
+ QueryClientProvider,
846
+ { client },
847
+ h(() => {
848
+ const boundary = useQueryErrorResetBoundary()
849
+ reset = boundary.reset
850
+ return null
851
+ }, null),
852
+ ),
853
+ el,
854
+ )
855
+
856
+ // Should not throw — falls back to client-level reset
857
+ expect(() => reset?.()).not.toThrow()
858
+ unmount()
859
+ el.remove()
860
+ })
861
+ })
862
+
863
+ // ─── useInfiniteQuery ─────────────────────────────────────────────────────────
864
+
865
+ describe('useInfiniteQuery', () => {
866
+ let client: QueryClient
867
+ beforeEach(() => {
868
+ client = makeClient()
869
+ })
870
+
871
+ it('starts in pending state', () => {
872
+ let query: ReturnType<typeof useInfiniteQuery> | undefined
873
+ const el = document.createElement('div')
874
+ document.body.appendChild(el)
875
+ const unmount = mount(
876
+ h(
877
+ QueryClientProvider,
878
+ { client },
879
+ h(() => {
880
+ query = useInfiniteQuery(() => ({
881
+ queryKey: ['inf-pending'],
882
+ queryFn: () =>
883
+ new Promise(() => {
884
+ /* never resolves */
885
+ }),
886
+ initialPageParam: 0,
887
+ getNextPageParam: () => undefined,
888
+ }))
889
+ return null
890
+ }, null),
891
+ ),
892
+ el,
893
+ )
894
+ expect(query!.isPending()).toBe(true)
895
+ expect(query!.isLoading()).toBe(true)
896
+ expect(query!.data()).toBeUndefined()
897
+ expect(query!.status()).toBe('pending')
898
+ expect(query!.isSuccess()).toBe(false)
899
+ expect(query!.isError()).toBe(false)
900
+ expect(query!.error()).toBeNull()
901
+ expect(query!.hasNextPage()).toBe(false)
902
+ expect(query!.hasPreviousPage()).toBe(false)
903
+ expect(query!.isFetchingNextPage()).toBe(false)
904
+ expect(query!.isFetchingPreviousPage()).toBe(false)
905
+ unmount()
906
+ el.remove()
907
+ })
908
+
909
+ it('resolves to success with pages data', async () => {
910
+ let query: ReturnType<typeof useInfiniteQuery<string>> | undefined
911
+ const el = document.createElement('div')
912
+ document.body.appendChild(el)
913
+ const unmount = mount(
914
+ h(
915
+ QueryClientProvider,
916
+ { client },
917
+ h(() => {
918
+ query = useInfiniteQuery(() => ({
919
+ queryKey: ['inf-success'],
920
+ queryFn: ({ pageParam }: { pageParam: number }) =>
921
+ Promise.resolve(`page-${pageParam}`),
922
+ initialPageParam: 0,
923
+ getNextPageParam: (
924
+ _last: string,
925
+ _all: string[],
926
+ lastParam: number,
927
+ ) => (lastParam < 2 ? lastParam + 1 : undefined),
928
+ }))
929
+ return null
930
+ }, null),
931
+ ),
932
+ el,
933
+ )
934
+
935
+ await new Promise((r) => setTimeout(r, 20))
936
+ expect(query!.isSuccess()).toBe(true)
937
+ expect(query!.status()).toBe('success')
938
+ expect(query!.data()?.pages).toEqual(['page-0'])
939
+ expect(query!.hasNextPage()).toBe(true)
940
+ expect(query!.isPending()).toBe(false)
941
+ expect(query!.isFetching()).toBe(false)
942
+ unmount()
943
+ el.remove()
944
+ })
945
+
946
+ it('fetchNextPage loads the next page', async () => {
947
+ let query: ReturnType<typeof useInfiniteQuery<string>> | undefined
948
+ const el = document.createElement('div')
949
+ document.body.appendChild(el)
950
+ const unmount = mount(
951
+ h(
952
+ QueryClientProvider,
953
+ { client },
954
+ h(() => {
955
+ query = useInfiniteQuery(() => ({
956
+ queryKey: ['inf-next'],
957
+ queryFn: ({ pageParam }: { pageParam: number }) =>
958
+ Promise.resolve(`page-${pageParam}`),
959
+ initialPageParam: 0,
960
+ getNextPageParam: (
961
+ _last: string,
962
+ _all: string[],
963
+ lastParam: number,
964
+ ) => (lastParam < 2 ? lastParam + 1 : undefined),
965
+ }))
966
+ return null
967
+ }, null),
968
+ ),
969
+ el,
970
+ )
971
+
972
+ await new Promise((r) => setTimeout(r, 20))
973
+ expect(query!.data()?.pages).toEqual(['page-0'])
974
+
975
+ await query!.fetchNextPage()
976
+ await new Promise((r) => setTimeout(r, 20))
977
+ expect(query!.data()?.pages).toEqual(['page-0', 'page-1'])
978
+ expect(query!.hasNextPage()).toBe(true)
979
+
980
+ await query!.fetchNextPage()
981
+ await new Promise((r) => setTimeout(r, 20))
982
+ expect(query!.data()?.pages).toEqual(['page-0', 'page-1', 'page-2'])
983
+ expect(query!.hasNextPage()).toBe(false)
984
+ unmount()
985
+ el.remove()
986
+ })
987
+
988
+ it('fetchPreviousPage loads the previous page', async () => {
989
+ let query: ReturnType<typeof useInfiniteQuery<string>> | undefined
990
+ const el = document.createElement('div')
991
+ document.body.appendChild(el)
992
+ const unmount = mount(
993
+ h(
994
+ QueryClientProvider,
995
+ { client },
996
+ h(() => {
997
+ query = useInfiniteQuery(() => ({
998
+ queryKey: ['inf-prev'],
999
+ queryFn: ({ pageParam }: { pageParam: number }) =>
1000
+ Promise.resolve(`page-${pageParam}`),
1001
+ initialPageParam: 5,
1002
+ getNextPageParam: () => undefined,
1003
+ getPreviousPageParam: (
1004
+ _first: string,
1005
+ _all: string[],
1006
+ firstParam: number,
1007
+ ) => (firstParam > 3 ? firstParam - 1 : undefined),
1008
+ }))
1009
+ return null
1010
+ }, null),
1011
+ ),
1012
+ el,
1013
+ )
1014
+
1015
+ await new Promise((r) => setTimeout(r, 20))
1016
+ expect(query!.hasPreviousPage()).toBe(true)
1017
+
1018
+ await query!.fetchPreviousPage()
1019
+ await new Promise((r) => setTimeout(r, 20))
1020
+ expect(query!.data()?.pages).toContain('page-4')
1021
+ unmount()
1022
+ el.remove()
1023
+ })
1024
+
1025
+ it('captures error state', async () => {
1026
+ let query: ReturnType<typeof useInfiniteQuery> | undefined
1027
+ const el = document.createElement('div')
1028
+ document.body.appendChild(el)
1029
+ const unmount = mount(
1030
+ h(
1031
+ QueryClientProvider,
1032
+ { client },
1033
+ h(() => {
1034
+ query = useInfiniteQuery(() => ({
1035
+ queryKey: ['inf-error'],
1036
+ queryFn: () => Promise.reject(new Error('inf failed')),
1037
+ initialPageParam: 0,
1038
+ getNextPageParam: () => undefined,
1039
+ }))
1040
+ return null
1041
+ }, null),
1042
+ ),
1043
+ el,
1044
+ )
1045
+
1046
+ await new Promise((r) => setTimeout(r, 20))
1047
+ expect(query!.isError()).toBe(true)
1048
+ expect(query!.status()).toBe('error')
1049
+ expect((query!.error() as Error).message).toBe('inf failed')
1050
+ unmount()
1051
+ el.remove()
1052
+ })
1053
+
1054
+ it('refetch re-fetches the query', async () => {
1055
+ let callCount = 0
1056
+ let query: ReturnType<typeof useInfiniteQuery<string>> | undefined
1057
+ const el = document.createElement('div')
1058
+ document.body.appendChild(el)
1059
+ const unmount = mount(
1060
+ h(
1061
+ QueryClientProvider,
1062
+ { client },
1063
+ h(() => {
1064
+ query = useInfiniteQuery(() => ({
1065
+ queryKey: ['inf-refetch'],
1066
+ queryFn: () => {
1067
+ callCount++
1068
+ return Promise.resolve('data')
1069
+ },
1070
+ initialPageParam: 0,
1071
+ getNextPageParam: () => undefined,
1072
+ }))
1073
+ return null
1074
+ }, null),
1075
+ ),
1076
+ el,
1077
+ )
1078
+
1079
+ await new Promise((r) => setTimeout(r, 20))
1080
+ const before = callCount
1081
+ await query!.refetch()
1082
+ await new Promise((r) => setTimeout(r, 20))
1083
+ expect(callCount).toBeGreaterThan(before)
1084
+ unmount()
1085
+ el.remove()
1086
+ })
1087
+
1088
+ it('result signal contains full observer result', async () => {
1089
+ let query: ReturnType<typeof useInfiniteQuery<string>> | undefined
1090
+ const el = document.createElement('div')
1091
+ document.body.appendChild(el)
1092
+ const unmount = mount(
1093
+ h(
1094
+ QueryClientProvider,
1095
+ { client },
1096
+ h(() => {
1097
+ query = useInfiniteQuery(() => ({
1098
+ queryKey: ['inf-result'],
1099
+ queryFn: () => Promise.resolve('val'),
1100
+ initialPageParam: 0,
1101
+ getNextPageParam: () => undefined,
1102
+ }))
1103
+ return null
1104
+ }, null),
1105
+ ),
1106
+ el,
1107
+ )
1108
+
1109
+ await new Promise((resolve) => setTimeout(resolve, 20))
1110
+ const r = query!.result()
1111
+ expect(r.status).toBe('success')
1112
+ expect(r.data?.pages).toEqual(['val'])
1113
+ unmount()
1114
+ el.remove()
1115
+ })
1116
+
1117
+ it('reactive options update observer', async () => {
1118
+ const key = signal('a')
1119
+ let query: ReturnType<typeof useInfiniteQuery<string>> | undefined
1120
+ const el = document.createElement('div')
1121
+ document.body.appendChild(el)
1122
+ const unmount = mount(
1123
+ h(
1124
+ QueryClientProvider,
1125
+ { client },
1126
+ h(() => {
1127
+ query = useInfiniteQuery(() => ({
1128
+ queryKey: ['inf-reactive', key()],
1129
+ queryFn: () => Promise.resolve(`data-${key()}`),
1130
+ initialPageParam: 0,
1131
+ getNextPageParam: () => undefined,
1132
+ }))
1133
+ return null
1134
+ }, null),
1135
+ ),
1136
+ el,
1137
+ )
1138
+
1139
+ await new Promise((r) => setTimeout(r, 20))
1140
+ expect(query!.data()?.pages).toEqual(['data-a'])
1141
+
1142
+ key.set('b')
1143
+ await new Promise((r) => setTimeout(r, 20))
1144
+ expect(query!.data()?.pages).toEqual(['data-b'])
1145
+ unmount()
1146
+ el.remove()
1147
+ })
1148
+ })
1149
+
1150
+ // ─── useSuspenseInfiniteQuery ────────────────────────────────────────────────
1151
+
1152
+ describe('useSuspenseInfiniteQuery', () => {
1153
+ let client: QueryClient
1154
+ beforeEach(() => {
1155
+ client = makeClient()
1156
+ })
1157
+
1158
+ it('returns all fine-grained signals and resolves to success', async () => {
1159
+ let query: ReturnType<typeof useSuspenseInfiniteQuery<string>> | undefined
1160
+ const el = document.createElement('div')
1161
+ document.body.appendChild(el)
1162
+ const unmount = mount(
1163
+ h(
1164
+ QueryClientProvider,
1165
+ { client },
1166
+ h(() => {
1167
+ query = useSuspenseInfiniteQuery(() => ({
1168
+ queryKey: ['sinf-1'],
1169
+ queryFn: ({ pageParam }: { pageParam: number }) =>
1170
+ Promise.resolve(`p${pageParam}`),
1171
+ initialPageParam: 0,
1172
+ getNextPageParam: (_l: string, _a: string[], lp: number) =>
1173
+ lp < 1 ? lp + 1 : undefined,
1174
+ }))
1175
+ return null
1176
+ }, null),
1177
+ ),
1178
+ el,
1179
+ )
1180
+
1181
+ await new Promise((r) => setTimeout(r, 20))
1182
+ expect(query!.isSuccess()).toBe(true)
1183
+ expect(query!.status()).toBe('success')
1184
+ expect(query!.data()?.pages).toEqual(['p0'])
1185
+ expect(query!.error()).toBeNull()
1186
+ expect(query!.isError()).toBe(false)
1187
+ expect(query!.isFetching()).toBe(false)
1188
+ expect(query!.isFetchingNextPage()).toBe(false)
1189
+ expect(query!.isFetchingPreviousPage()).toBe(false)
1190
+ expect(query!.hasNextPage()).toBe(true)
1191
+ expect(query!.hasPreviousPage()).toBe(false)
1192
+ unmount()
1193
+ el.remove()
1194
+ })
1195
+
1196
+ it('fetchNextPage and fetchPreviousPage work', async () => {
1197
+ let query: ReturnType<typeof useSuspenseInfiniteQuery<string>> | undefined
1198
+ const el = document.createElement('div')
1199
+ document.body.appendChild(el)
1200
+ const unmount = mount(
1201
+ h(
1202
+ QueryClientProvider,
1203
+ { client },
1204
+ h(() => {
1205
+ query = useSuspenseInfiniteQuery(() => ({
1206
+ queryKey: ['sinf-pages'],
1207
+ queryFn: ({ pageParam }: { pageParam: number }) =>
1208
+ Promise.resolve(`p${pageParam}`),
1209
+ initialPageParam: 1,
1210
+ getNextPageParam: (_l: string, _a: string[], lp: number) =>
1211
+ lp < 3 ? lp + 1 : undefined,
1212
+ getPreviousPageParam: (_f: string, _a: string[], fp: number) =>
1213
+ fp > 0 ? fp - 1 : undefined,
1214
+ }))
1215
+ return null
1216
+ }, null),
1217
+ ),
1218
+ el,
1219
+ )
1220
+
1221
+ await new Promise((r) => setTimeout(r, 20))
1222
+ await query!.fetchNextPage()
1223
+ await new Promise((r) => setTimeout(r, 20))
1224
+ expect(query!.data()?.pages).toContain('p2')
1225
+
1226
+ await query!.fetchPreviousPage()
1227
+ await new Promise((r) => setTimeout(r, 20))
1228
+ expect(query!.data()?.pages).toContain('p0')
1229
+ unmount()
1230
+ el.remove()
1231
+ })
1232
+
1233
+ it('refetch works', async () => {
1234
+ let callCount = 0
1235
+ let query: ReturnType<typeof useSuspenseInfiniteQuery<string>> | undefined
1236
+ const el = document.createElement('div')
1237
+ document.body.appendChild(el)
1238
+ const unmount = mount(
1239
+ h(
1240
+ QueryClientProvider,
1241
+ { client },
1242
+ h(() => {
1243
+ query = useSuspenseInfiniteQuery(() => ({
1244
+ queryKey: ['sinf-refetch'],
1245
+ queryFn: () => {
1246
+ callCount++
1247
+ return Promise.resolve('d')
1248
+ },
1249
+ initialPageParam: 0,
1250
+ getNextPageParam: () => undefined,
1251
+ }))
1252
+ return null
1253
+ }, null),
1254
+ ),
1255
+ el,
1256
+ )
1257
+
1258
+ await new Promise((r) => setTimeout(r, 20))
1259
+ const before = callCount
1260
+ await query!.refetch()
1261
+ await new Promise((r) => setTimeout(r, 20))
1262
+ expect(callCount).toBeGreaterThan(before)
1263
+ unmount()
1264
+ el.remove()
1265
+ })
1266
+
1267
+ it('result signal contains full observer result', async () => {
1268
+ let query: ReturnType<typeof useSuspenseInfiniteQuery<string>> | undefined
1269
+ const el = document.createElement('div')
1270
+ document.body.appendChild(el)
1271
+ const unmount = mount(
1272
+ h(
1273
+ QueryClientProvider,
1274
+ { client },
1275
+ h(() => {
1276
+ query = useSuspenseInfiniteQuery(() => ({
1277
+ queryKey: ['sinf-result'],
1278
+ queryFn: () => Promise.resolve('v'),
1279
+ initialPageParam: 0,
1280
+ getNextPageParam: () => undefined,
1281
+ }))
1282
+ return null
1283
+ }, null),
1284
+ ),
1285
+ el,
1286
+ )
1287
+
1288
+ await new Promise((r) => setTimeout(r, 20))
1289
+ expect(query!.result().status).toBe('success')
1290
+ unmount()
1291
+ el.remove()
1292
+ })
1293
+
1294
+ it('reactive options update observer', async () => {
1295
+ const key = signal('x')
1296
+ let query: ReturnType<typeof useSuspenseInfiniteQuery<string>> | undefined
1297
+ const el = document.createElement('div')
1298
+ document.body.appendChild(el)
1299
+ const unmount = mount(
1300
+ h(
1301
+ QueryClientProvider,
1302
+ { client },
1303
+ h(() => {
1304
+ query = useSuspenseInfiniteQuery(() => ({
1305
+ queryKey: ['sinf-reactive', key()],
1306
+ queryFn: () => Promise.resolve(`val-${key()}`),
1307
+ initialPageParam: 0,
1308
+ getNextPageParam: () => undefined,
1309
+ }))
1310
+ return null
1311
+ }, null),
1312
+ ),
1313
+ el,
1314
+ )
1315
+
1316
+ await new Promise((r) => setTimeout(r, 20))
1317
+ expect(query!.data()?.pages).toEqual(['val-x'])
1318
+ key.set('y')
1319
+ await new Promise((r) => setTimeout(r, 20))
1320
+ expect(query!.data()?.pages).toEqual(['val-y'])
1321
+ unmount()
1322
+ el.remove()
1323
+ })
1324
+ })
1325
+
1326
+ // ─── useSuspenseQuery — additional coverage ──────────────────────────────────
1327
+
1328
+ describe('useSuspenseQuery — additional', () => {
1329
+ let client: QueryClient
1330
+ beforeEach(() => {
1331
+ client = makeClient()
1332
+ })
1333
+
1334
+ it('data is typed as TData (never undefined) after success', async () => {
1335
+ let query: ReturnType<typeof useSuspenseQuery<{ name: string }>> | undefined
1336
+ const el = document.createElement('div')
1337
+ document.body.appendChild(el)
1338
+ const unmount = mount(
1339
+ h(
1340
+ QueryClientProvider,
1341
+ { client },
1342
+ h(() => {
1343
+ query = useSuspenseQuery(() => ({
1344
+ queryKey: ['sq-data-type'],
1345
+ queryFn: () => Promise.resolve({ name: 'test' }),
1346
+ }))
1347
+ return null
1348
+ }, null),
1349
+ ),
1350
+ el,
1351
+ )
1352
+
1353
+ await new Promise((r) => setTimeout(r, 20))
1354
+ expect(query!.data().name).toBe('test')
1355
+ expect(query!.isSuccess()).toBe(true)
1356
+ expect(query!.isFetching()).toBe(false)
1357
+ expect(query!.isError()).toBe(false)
1358
+ expect(query!.error()).toBeNull()
1359
+ expect(query!.status()).toBe('success')
1360
+ expect(query!.result().status).toBe('success')
1361
+ unmount()
1362
+ el.remove()
1363
+ })
1364
+
1365
+ it('refetch re-fetches the query', async () => {
1366
+ let callCount = 0
1367
+ let query: ReturnType<typeof useSuspenseQuery<string>> | undefined
1368
+ const el = document.createElement('div')
1369
+ document.body.appendChild(el)
1370
+ const unmount = mount(
1371
+ h(
1372
+ QueryClientProvider,
1373
+ { client },
1374
+ h(() => {
1375
+ query = useSuspenseQuery(() => ({
1376
+ queryKey: ['sq-refetch'],
1377
+ queryFn: () => {
1378
+ callCount++
1379
+ return Promise.resolve('ok')
1380
+ },
1381
+ }))
1382
+ return null
1383
+ }, null),
1384
+ ),
1385
+ el,
1386
+ )
1387
+
1388
+ await new Promise((r) => setTimeout(r, 20))
1389
+ const before = callCount
1390
+ await query!.refetch()
1391
+ await new Promise((r) => setTimeout(r, 20))
1392
+ expect(callCount).toBeGreaterThan(before)
1393
+ unmount()
1394
+ el.remove()
1395
+ })
1396
+
1397
+ it('reactive key changes trigger re-fetch', async () => {
1398
+ const key = signal('k1')
1399
+ let query: ReturnType<typeof useSuspenseQuery<string>> | undefined
1400
+ const el = document.createElement('div')
1401
+ document.body.appendChild(el)
1402
+ const unmount = mount(
1403
+ h(
1404
+ QueryClientProvider,
1405
+ { client },
1406
+ h(() => {
1407
+ query = useSuspenseQuery(() => ({
1408
+ queryKey: ['sq-reactive', key()],
1409
+ queryFn: () => Promise.resolve(`data-${key()}`),
1410
+ }))
1411
+ return null
1412
+ }, null),
1413
+ ),
1414
+ el,
1415
+ )
1416
+
1417
+ await new Promise((r) => setTimeout(r, 20))
1418
+ expect(query!.data()).toBe('data-k1')
1419
+
1420
+ key.set('k2')
1421
+ await new Promise((r) => setTimeout(r, 20))
1422
+ expect(query!.data()).toBe('data-k2')
1423
+ unmount()
1424
+ el.remove()
1425
+ })
1426
+
1427
+ it('captures error state in suspense query', async () => {
1428
+ const { promise, reject } = deferred<never>()
1429
+
1430
+ const el = document.createElement('div')
1431
+ document.body.appendChild(el)
1432
+
1433
+ let query: ReturnType<typeof useSuspenseQuery> | undefined
1434
+ const unmount = mount(
1435
+ h(
1436
+ QueryClientProvider,
1437
+ { client },
1438
+ h(() => {
1439
+ query = useSuspenseQuery(() => ({
1440
+ queryKey: ['sq-rethrow2'],
1441
+ queryFn: () => promise,
1442
+ }))
1443
+ return null
1444
+ }, null),
1445
+ ),
1446
+ el,
1447
+ )
1448
+
1449
+ reject(new Error('rethrow test'))
1450
+ await promise.catch(() => {
1451
+ /* expected */
1452
+ })
1453
+ await new Promise((r) => setTimeout(r, 10))
1454
+ expect(query!.isError()).toBe(true)
1455
+ expect(query!.isPending()).toBe(false)
1456
+ unmount()
1457
+ el.remove()
1458
+ })
1459
+
1460
+ it('QuerySuspense handles fallback as function', async () => {
1461
+ let query: ReturnType<typeof useSuspenseQuery<string>> | undefined
1462
+ const el = document.createElement('div')
1463
+ document.body.appendChild(el)
1464
+ const unmount = mount(
1465
+ h(
1466
+ QueryClientProvider,
1467
+ { client },
1468
+ h(() => {
1469
+ query = useSuspenseQuery(() => ({
1470
+ queryKey: ['sq-fn-fallback'],
1471
+ queryFn: () =>
1472
+ new Promise(() => {
1473
+ /* never resolves */
1474
+ }),
1475
+ }))
1476
+ return h(
1477
+ QuerySuspense as any,
1478
+ { query: query!, fallback: () => 'loading fn' },
1479
+ () => null,
1480
+ )
1481
+ }, null),
1482
+ ),
1483
+ el,
1484
+ )
1485
+
1486
+ await new Promise((r) => setTimeout(r, 10))
1487
+ // The fallback function should be called
1488
+ expect(query!.isPending()).toBe(true)
1489
+ unmount()
1490
+ el.remove()
1491
+ })
1492
+ })
1493
+
1494
+ // ─── Coverage gap tests ──────────────────────────────────────────────────────
1495
+
1496
+ describe('QueryClientProvider — VNode children branch', () => {
1497
+ it('renders when children is a VNode (not a function)', () => {
1498
+ const client = makeClient()
1499
+ let received: QueryClient | null = null
1500
+ const el = document.createElement('div')
1501
+ document.body.appendChild(el)
1502
+ // Pass children as a direct VNode, not wrapped in a function
1503
+ const unmount = mount(
1504
+ h(
1505
+ QueryClientProvider,
1506
+ { client },
1507
+ h(() => {
1508
+ received = useQueryClient()
1509
+ return null
1510
+ }, null),
1511
+ ),
1512
+ el,
1513
+ )
1514
+ expect(received).toBe(client)
1515
+ unmount()
1516
+ el.remove()
1517
+ })
1518
+
1519
+ it('renders when children is passed as a function returning VNode', () => {
1520
+ const client = makeClient()
1521
+ let received: QueryClient | null = null
1522
+ const el = document.createElement('div')
1523
+ document.body.appendChild(el)
1524
+ const unmount = mount(
1525
+ h(QueryClientProvider, { client }, () => {
1526
+ return h(() => {
1527
+ received = useQueryClient()
1528
+ return null
1529
+ }, null)
1530
+ }),
1531
+ el,
1532
+ )
1533
+ expect(received).toBe(client)
1534
+ unmount()
1535
+ el.remove()
1536
+ })
1537
+ })
1538
+
1539
+ describe('useMutation — mutateAsync', () => {
1540
+ let client: QueryClient
1541
+ beforeEach(() => {
1542
+ client = makeClient()
1543
+ })
1544
+
1545
+ it('mutateAsync returns a promise that resolves with data', async () => {
1546
+ let mut: ReturnType<typeof useMutation<string, Error, string>> | undefined
1547
+ const el = document.createElement('div')
1548
+ document.body.appendChild(el)
1549
+ const unmount = mount(
1550
+ h(
1551
+ QueryClientProvider,
1552
+ { client },
1553
+ h(() => {
1554
+ mut = useMutation<string, Error, string>({
1555
+ mutationFn: async (input: string) => `async-result:${input}`,
1556
+ })
1557
+ return null
1558
+ }, null),
1559
+ ),
1560
+ el,
1561
+ )
1562
+
1563
+ const result = await mut!.mutateAsync('test')
1564
+ expect(result).toBe('async-result:test')
1565
+ expect(mut!.isSuccess()).toBe(true)
1566
+ expect(mut!.data()).toBe('async-result:test')
1567
+ unmount()
1568
+ el.remove()
1569
+ })
1570
+
1571
+ it('mutateAsync rejects when mutation fails', async () => {
1572
+ let mut: ReturnType<typeof useMutation<string, Error, void>> | undefined
1573
+ const el = document.createElement('div')
1574
+ document.body.appendChild(el)
1575
+ const unmount = mount(
1576
+ h(
1577
+ QueryClientProvider,
1578
+ { client },
1579
+ h(() => {
1580
+ mut = useMutation<string, Error, void>({
1581
+ mutationFn: async () => {
1582
+ throw new Error('async-fail')
1583
+ },
1584
+ })
1585
+ return null
1586
+ }, null),
1587
+ ),
1588
+ el,
1589
+ )
1590
+
1591
+ await expect(mut!.mutateAsync(undefined)).rejects.toThrow('async-fail')
1592
+ await new Promise((r) => setTimeout(r, 0))
1593
+ expect(mut!.isError()).toBe(true)
1594
+ expect((mut!.error() as Error).message).toBe('async-fail')
1595
+ unmount()
1596
+ el.remove()
1597
+ })
1598
+ })
1599
+
1600
+ describe('useQuery — refetch', () => {
1601
+ let client: QueryClient
1602
+ beforeEach(() => {
1603
+ client = makeClient()
1604
+ })
1605
+
1606
+ it('refetch re-fetches the query and returns updated result', async () => {
1607
+ let callCount = 0
1608
+ let query: ReturnType<typeof useQuery<string>> | undefined
1609
+ const el = document.createElement('div')
1610
+ document.body.appendChild(el)
1611
+ const unmount = mount(
1612
+ h(
1613
+ QueryClientProvider,
1614
+ { client },
1615
+ h(() => {
1616
+ query = useQuery(() => ({
1617
+ queryKey: ['refetch-test'],
1618
+ queryFn: async () => {
1619
+ callCount++
1620
+ return `call-${callCount}`
1621
+ },
1622
+ }))
1623
+ return null
1624
+ }, null),
1625
+ ),
1626
+ el,
1627
+ )
1628
+
1629
+ await new Promise((r) => setTimeout(r, 10))
1630
+ expect(query!.data()).toBe('call-1')
1631
+
1632
+ const result = await query!.refetch()
1633
+ await new Promise((r) => setTimeout(r, 10))
1634
+ expect(callCount).toBe(2)
1635
+ expect(result.data).toBe('call-2')
1636
+ expect(query!.data()).toBe('call-2')
1637
+ unmount()
1638
+ el.remove()
1639
+ })
1640
+ })
1641
+
1642
+ describe('QueryErrorResetBoundary — VNode children branch', () => {
1643
+ it('renders when children is a VNode (not a function)', async () => {
1644
+ const client = makeClient()
1645
+ let resetFn: (() => void) | undefined
1646
+
1647
+ const el = document.createElement('div')
1648
+ document.body.appendChild(el)
1649
+ // Pass children as a direct VNode, not a function
1650
+ const unmount = mount(
1651
+ h(
1652
+ QueryClientProvider,
1653
+ { client },
1654
+ h(
1655
+ QueryErrorResetBoundary,
1656
+ null,
1657
+ h(() => {
1658
+ const { reset } = useQueryErrorResetBoundary()
1659
+ resetFn = reset
1660
+ return null
1661
+ }, null),
1662
+ ),
1663
+ ),
1664
+ el,
1665
+ )
1666
+
1667
+ expect(resetFn).toBeDefined()
1668
+ expect(() => resetFn?.()).not.toThrow()
1669
+ unmount()
1670
+ el.remove()
1671
+ })
1672
+ })
1673
+
1674
+ describe('useSuspenseQuery — error without handler (QuerySuspense throw branch)', () => {
1675
+ let client: QueryClient
1676
+ beforeEach(() => {
1677
+ client = makeClient()
1678
+ })
1679
+
1680
+ it('QuerySuspense throws error when no error handler is provided', async () => {
1681
+ const { promise, reject } = deferred<never>()
1682
+
1683
+ const el = document.createElement('div')
1684
+ document.body.appendChild(el)
1685
+
1686
+ const _thrownError: unknown = null
1687
+ let query: ReturnType<typeof useSuspenseQuery> | undefined
1688
+
1689
+ const unmount = mount(
1690
+ h(
1691
+ QueryClientProvider,
1692
+ { client },
1693
+ h(() => {
1694
+ query = useSuspenseQuery(() => ({
1695
+ queryKey: ['sq-throw-no-handler'],
1696
+ queryFn: () => promise,
1697
+ }))
1698
+ // QuerySuspense with NO error handler — should throw
1699
+ return h(
1700
+ QuerySuspense as any,
1701
+ { query: query!, fallback: 'loading' },
1702
+ () => null,
1703
+ )
1704
+ }, null),
1705
+ ),
1706
+ el,
1707
+ )
1708
+
1709
+ reject(new Error('unhandled suspense error'))
1710
+ await promise.catch(() => {
1711
+ /* expected */
1712
+ })
1713
+ await new Promise((r) => setTimeout(r, 10))
1714
+
1715
+ // The error state should be set on the query
1716
+ expect(query!.isError()).toBe(true)
1717
+ expect((query!.error() as Error).message).toBe('unhandled suspense error')
1718
+
1719
+ // Verify that calling the QuerySuspense render function directly would throw
1720
+ // by checking the query is in error state without an error handler
1721
+ // The throw happens inside the reactive render cycle — we verify the query errored
1722
+ unmount()
1723
+ el.remove()
1724
+ })
1725
+
1726
+ it('QuerySuspense re-throws error to be caught externally', async () => {
1727
+ const { promise, reject } = deferred<never>()
1728
+
1729
+ const el = document.createElement('div')
1730
+ document.body.appendChild(el)
1731
+
1732
+ let query: ReturnType<typeof useSuspenseQuery> | undefined
1733
+
1734
+ const unmount = mount(
1735
+ h(
1736
+ QueryClientProvider,
1737
+ { client },
1738
+ h(() => {
1739
+ query = useSuspenseQuery(() => ({
1740
+ queryKey: ['sq-rethrow-direct'],
1741
+ queryFn: () => promise,
1742
+ }))
1743
+ return null
1744
+ }, null),
1745
+ ),
1746
+ el,
1747
+ )
1748
+
1749
+ reject(new Error('direct throw'))
1750
+ await promise.catch(() => {
1751
+ /* expected */
1752
+ })
1753
+ await new Promise((r) => setTimeout(r, 10))
1754
+
1755
+ // Manually invoke QuerySuspense to verify it throws when no error handler
1756
+ expect(() => {
1757
+ const renderFn = QuerySuspense({
1758
+ query: query!,
1759
+ fallback: 'loading',
1760
+ children: () => null,
1761
+ }) as () => unknown
1762
+ renderFn()
1763
+ }).toThrow('direct throw')
1764
+
1765
+ unmount()
1766
+ el.remove()
1767
+ })
1768
+ })