@pyreon/query 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.
@@ -0,0 +1,581 @@
1
+ import { signal } from '@pyreon/reactivity'
2
+ import { mount } from '@pyreon/runtime-dom'
3
+ import { QueryClient } from '@tanstack/query-core'
4
+ import {
5
+ QueryClientProvider,
6
+ type UseSubscriptionResult,
7
+ useSubscription,
8
+ } from '../index'
9
+
10
+ // ─── Mock WebSocket ──────────────────────────────────────────────────────────
11
+
12
+ type MockWSListener = ((event: unknown) => void) | null
13
+
14
+ interface MockWebSocket {
15
+ url: string
16
+ protocols?: string | string[]
17
+ readyState: number
18
+ onopen: MockWSListener
19
+ onmessage: MockWSListener
20
+ onclose: MockWSListener
21
+ onerror: MockWSListener
22
+ send: ReturnType<typeof vi.fn>
23
+ close: ReturnType<typeof vi.fn>
24
+ // Test helpers
25
+ _simulateOpen: () => void
26
+ _simulateMessage: (data: string) => void
27
+ _simulateClose: (code?: number, reason?: string) => void
28
+ _simulateError: () => void
29
+ }
30
+
31
+ let mockInstances: MockWebSocket[] = []
32
+
33
+ class MockWebSocketClass {
34
+ static CONNECTING = 0
35
+ static OPEN = 1
36
+ static CLOSING = 2
37
+ static CLOSED = 3
38
+
39
+ url: string
40
+ protocols?: string | string[]
41
+ readyState = 0
42
+ onopen: MockWSListener = null
43
+ onmessage: MockWSListener = null
44
+ onclose: MockWSListener = null
45
+ onerror: MockWSListener = null
46
+
47
+ send = vi.fn()
48
+ close = vi.fn(() => {
49
+ this.readyState = MockWebSocketClass.CLOSED
50
+ })
51
+
52
+ constructor(url: string, protocols?: string | string[]) {
53
+ this.url = url
54
+ this.protocols = protocols
55
+ this.readyState = MockWebSocketClass.CONNECTING
56
+ mockInstances.push(this as unknown as MockWebSocket)
57
+ }
58
+
59
+ _simulateOpen() {
60
+ this.readyState = MockWebSocketClass.OPEN
61
+ this.onopen?.({ type: 'open' })
62
+ }
63
+
64
+ _simulateMessage(data: string) {
65
+ this.onmessage?.({ type: 'message', data } as unknown as MessageEvent)
66
+ }
67
+
68
+ _simulateClose(code = 1000, reason = '') {
69
+ this.readyState = MockWebSocketClass.CLOSED
70
+ this.onclose?.({ type: 'close', code, reason } as unknown as CloseEvent)
71
+ }
72
+
73
+ _simulateError() {
74
+ this.onerror?.({ type: 'error' })
75
+ }
76
+ }
77
+
78
+ // Install mock
79
+ const OriginalWebSocket = globalThis.WebSocket
80
+ beforeAll(() => {
81
+ ;(globalThis as any).WebSocket = MockWebSocketClass
82
+ })
83
+ afterAll(() => {
84
+ globalThis.WebSocket = OriginalWebSocket
85
+ })
86
+
87
+ // biome-ignore lint/suspicious/noEmptyBlockStatements: intentional no-op for tests where onMessage isn't the focus
88
+ const noop = () => {}
89
+
90
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
91
+
92
+ function makeClient() {
93
+ return new QueryClient({
94
+ defaultOptions: { queries: { retry: false, gcTime: Infinity } },
95
+ })
96
+ }
97
+
98
+ function withProvider(client: QueryClient, component: () => void): () => void {
99
+ const el = document.createElement('div')
100
+ document.body.appendChild(el)
101
+ const unmount = mount(
102
+ <QueryClientProvider client={client}>
103
+ {() => {
104
+ component()
105
+ return null
106
+ }}
107
+ </QueryClientProvider>,
108
+ el,
109
+ )
110
+ return () => {
111
+ unmount()
112
+ el.remove()
113
+ }
114
+ }
115
+
116
+ function lastMockWS(): MockWebSocket {
117
+ return mockInstances[mockInstances.length - 1]!
118
+ }
119
+
120
+ // ─── Tests ───────────────────────────────────────────────────────────────────
121
+
122
+ describe('useSubscription', () => {
123
+ beforeEach(() => {
124
+ mockInstances = []
125
+ })
126
+
127
+ it('connects to the WebSocket URL', () => {
128
+ const client = makeClient()
129
+ let sub: UseSubscriptionResult | null = null
130
+
131
+ const unmount = withProvider(client, () => {
132
+ sub = useSubscription({
133
+ url: 'wss://example.com/ws',
134
+ onMessage: noop,
135
+ })
136
+ })
137
+
138
+ expect(mockInstances).toHaveLength(1)
139
+ expect(lastMockWS().url).toBe('wss://example.com/ws')
140
+ expect(sub!.status()).toBe('connecting')
141
+
142
+ unmount()
143
+ })
144
+
145
+ it('status transitions to connected on open', () => {
146
+ const client = makeClient()
147
+ let sub: UseSubscriptionResult | null = null
148
+
149
+ const unmount = withProvider(client, () => {
150
+ sub = useSubscription({
151
+ url: 'wss://example.com/ws',
152
+ onMessage: noop,
153
+ })
154
+ })
155
+
156
+ lastMockWS()._simulateOpen()
157
+ expect(sub!.status()).toBe('connected')
158
+
159
+ unmount()
160
+ })
161
+
162
+ it('calls onMessage with event and queryClient', () => {
163
+ const client = makeClient()
164
+ const messages: string[] = []
165
+
166
+ const unmount = withProvider(client, () => {
167
+ useSubscription({
168
+ url: 'wss://example.com/ws',
169
+ onMessage: (event, qc) => {
170
+ messages.push(event.data as string)
171
+ expect(qc).toBe(client)
172
+ },
173
+ })
174
+ })
175
+
176
+ lastMockWS()._simulateOpen()
177
+ lastMockWS()._simulateMessage('hello')
178
+ lastMockWS()._simulateMessage('world')
179
+
180
+ expect(messages).toEqual(['hello', 'world'])
181
+ unmount()
182
+ })
183
+
184
+ it('invalidates queries on message', () => {
185
+ const client = makeClient()
186
+ const invalidateSpy = vi.spyOn(client, 'invalidateQueries')
187
+
188
+ const unmount = withProvider(client, () => {
189
+ useSubscription({
190
+ url: 'wss://example.com/ws',
191
+ onMessage: (_event, qc) => {
192
+ qc.invalidateQueries({ queryKey: ['orders'] })
193
+ },
194
+ })
195
+ })
196
+
197
+ lastMockWS()._simulateOpen()
198
+ lastMockWS()._simulateMessage('order-updated')
199
+
200
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['orders'] })
201
+ unmount()
202
+ })
203
+
204
+ it('send() sends data through WebSocket', () => {
205
+ const client = makeClient()
206
+ let sub: UseSubscriptionResult | null = null
207
+
208
+ const unmount = withProvider(client, () => {
209
+ sub = useSubscription({
210
+ url: 'wss://example.com/ws',
211
+ onMessage: noop,
212
+ })
213
+ })
214
+
215
+ lastMockWS()._simulateOpen()
216
+ sub!.send('test-message')
217
+
218
+ expect(lastMockWS().send).toHaveBeenCalledWith('test-message')
219
+ unmount()
220
+ })
221
+
222
+ it('send() is a no-op when not connected', () => {
223
+ const client = makeClient()
224
+ let sub: UseSubscriptionResult | null = null
225
+
226
+ const unmount = withProvider(client, () => {
227
+ sub = useSubscription({
228
+ url: 'wss://example.com/ws',
229
+ onMessage: noop,
230
+ })
231
+ })
232
+
233
+ // Still connecting — send should not throw
234
+ sub!.send('ignored')
235
+ expect(lastMockWS().send).not.toHaveBeenCalled()
236
+ unmount()
237
+ })
238
+
239
+ it('close() disconnects and sets status', () => {
240
+ const client = makeClient()
241
+ let sub: UseSubscriptionResult | null = null
242
+
243
+ const unmount = withProvider(client, () => {
244
+ sub = useSubscription({
245
+ url: 'wss://example.com/ws',
246
+ onMessage: noop,
247
+ })
248
+ })
249
+
250
+ lastMockWS()._simulateOpen()
251
+ sub!.close()
252
+
253
+ expect(sub!.status()).toBe('disconnected')
254
+ expect(lastMockWS().close).toHaveBeenCalled()
255
+ unmount()
256
+ })
257
+
258
+ it('status transitions to disconnected on close', () => {
259
+ const client = makeClient()
260
+ let sub: UseSubscriptionResult | null = null
261
+
262
+ const unmount = withProvider(client, () => {
263
+ sub = useSubscription({
264
+ url: 'wss://example.com/ws',
265
+ onMessage: noop,
266
+ reconnect: false,
267
+ })
268
+ })
269
+
270
+ lastMockWS()._simulateOpen()
271
+ lastMockWS()._simulateClose()
272
+
273
+ expect(sub!.status()).toBe('disconnected')
274
+ unmount()
275
+ })
276
+
277
+ it('status transitions to error on error', () => {
278
+ const client = makeClient()
279
+ let sub: UseSubscriptionResult | null = null
280
+ const errors: Event[] = []
281
+
282
+ const unmount = withProvider(client, () => {
283
+ sub = useSubscription({
284
+ url: 'wss://example.com/ws',
285
+ onMessage: noop,
286
+ reconnect: false,
287
+ onError: (e) => errors.push(e as Event),
288
+ })
289
+ })
290
+
291
+ lastMockWS()._simulateError()
292
+ expect(sub!.status()).toBe('error')
293
+ expect(errors).toHaveLength(1)
294
+ unmount()
295
+ })
296
+
297
+ it('calls onOpen callback', () => {
298
+ const client = makeClient()
299
+ let opened = false
300
+
301
+ const unmount = withProvider(client, () => {
302
+ useSubscription({
303
+ url: 'wss://example.com/ws',
304
+ onMessage: noop,
305
+ onOpen: () => {
306
+ opened = true
307
+ },
308
+ })
309
+ })
310
+
311
+ lastMockWS()._simulateOpen()
312
+ expect(opened).toBe(true)
313
+ unmount()
314
+ })
315
+
316
+ it('calls onClose callback', () => {
317
+ const client = makeClient()
318
+ let closed = false
319
+
320
+ const unmount = withProvider(client, () => {
321
+ useSubscription({
322
+ url: 'wss://example.com/ws',
323
+ onMessage: noop,
324
+ reconnect: false,
325
+ onClose: () => {
326
+ closed = true
327
+ },
328
+ })
329
+ })
330
+
331
+ lastMockWS()._simulateOpen()
332
+ lastMockWS()._simulateClose()
333
+ expect(closed).toBe(true)
334
+ unmount()
335
+ })
336
+
337
+ it('auto-reconnects on unexpected close', async () => {
338
+ const client = makeClient()
339
+
340
+ const unmount = withProvider(client, () => {
341
+ useSubscription({
342
+ url: 'wss://example.com/ws',
343
+ onMessage: noop,
344
+ reconnect: true,
345
+ reconnectDelay: 50,
346
+ })
347
+ })
348
+
349
+ expect(mockInstances).toHaveLength(1)
350
+ lastMockWS()._simulateOpen()
351
+ lastMockWS()._simulateClose()
352
+
353
+ // Wait for reconnect
354
+ await new Promise((r) => setTimeout(r, 100))
355
+
356
+ expect(mockInstances).toHaveLength(2)
357
+ unmount()
358
+ })
359
+
360
+ it('does not reconnect when reconnect is false', async () => {
361
+ const client = makeClient()
362
+
363
+ const unmount = withProvider(client, () => {
364
+ useSubscription({
365
+ url: 'wss://example.com/ws',
366
+ onMessage: noop,
367
+ reconnect: false,
368
+ })
369
+ })
370
+
371
+ lastMockWS()._simulateOpen()
372
+ lastMockWS()._simulateClose()
373
+
374
+ await new Promise((r) => setTimeout(r, 100))
375
+ expect(mockInstances).toHaveLength(1)
376
+ unmount()
377
+ })
378
+
379
+ it('does not reconnect after intentional close()', async () => {
380
+ const client = makeClient()
381
+ let sub: UseSubscriptionResult | null = null
382
+
383
+ const unmount = withProvider(client, () => {
384
+ sub = useSubscription({
385
+ url: 'wss://example.com/ws',
386
+ onMessage: noop,
387
+ reconnect: true,
388
+ reconnectDelay: 50,
389
+ })
390
+ })
391
+
392
+ lastMockWS()._simulateOpen()
393
+ sub!.close()
394
+
395
+ await new Promise((r) => setTimeout(r, 100))
396
+ expect(mockInstances).toHaveLength(1)
397
+ unmount()
398
+ })
399
+
400
+ it('respects maxReconnectAttempts', async () => {
401
+ const client = makeClient()
402
+
403
+ const unmount = withProvider(client, () => {
404
+ useSubscription({
405
+ url: 'wss://example.com/ws',
406
+ onMessage: noop,
407
+ reconnect: true,
408
+ reconnectDelay: 10,
409
+ maxReconnectAttempts: 2,
410
+ })
411
+ })
412
+
413
+ // First connection
414
+ lastMockWS()._simulateOpen()
415
+ lastMockWS()._simulateClose()
416
+
417
+ // Reconnect 1
418
+ await new Promise((r) => setTimeout(r, 30))
419
+ expect(mockInstances).toHaveLength(2)
420
+ lastMockWS()._simulateClose()
421
+
422
+ // Reconnect 2
423
+ await new Promise((r) => setTimeout(r, 40))
424
+ expect(mockInstances).toHaveLength(3)
425
+ lastMockWS()._simulateClose()
426
+
427
+ // Should not reconnect again (max 2 attempts reached)
428
+ await new Promise((r) => setTimeout(r, 100))
429
+ expect(mockInstances).toHaveLength(3)
430
+
431
+ unmount()
432
+ })
433
+
434
+ it('reconnect() resets attempts and reconnects', async () => {
435
+ const client = makeClient()
436
+ let sub: UseSubscriptionResult | null = null
437
+
438
+ const unmount = withProvider(client, () => {
439
+ sub = useSubscription({
440
+ url: 'wss://example.com/ws',
441
+ onMessage: noop,
442
+ reconnect: false,
443
+ })
444
+ })
445
+
446
+ lastMockWS()._simulateOpen()
447
+ lastMockWS()._simulateClose()
448
+
449
+ expect(mockInstances).toHaveLength(1)
450
+
451
+ sub!.reconnect()
452
+ expect(mockInstances).toHaveLength(2)
453
+ expect(sub!.status()).toBe('connecting')
454
+
455
+ unmount()
456
+ })
457
+
458
+ it('enabled: false prevents connection', () => {
459
+ const client = makeClient()
460
+ let sub: UseSubscriptionResult | null = null
461
+
462
+ const unmount = withProvider(client, () => {
463
+ sub = useSubscription({
464
+ url: 'wss://example.com/ws',
465
+ onMessage: noop,
466
+ enabled: false,
467
+ })
468
+ })
469
+
470
+ expect(mockInstances).toHaveLength(0)
471
+ expect(sub!.status()).toBe('disconnected')
472
+ unmount()
473
+ })
474
+
475
+ it('reactive enabled signal controls connection', async () => {
476
+ const client = makeClient()
477
+ const enabled = signal(false)
478
+
479
+ const unmount = withProvider(client, () => {
480
+ useSubscription({
481
+ url: 'wss://example.com/ws',
482
+ onMessage: noop,
483
+ enabled: () => enabled(),
484
+ })
485
+ })
486
+
487
+ expect(mockInstances).toHaveLength(0)
488
+
489
+ enabled.set(true)
490
+
491
+ // Effect runs synchronously in Pyreon
492
+ expect(mockInstances).toHaveLength(1)
493
+ unmount()
494
+ })
495
+
496
+ it('reactive URL reconnects when URL changes', () => {
497
+ const client = makeClient()
498
+ const url = signal('wss://example.com/ws1')
499
+
500
+ const unmount = withProvider(client, () => {
501
+ useSubscription({
502
+ url: () => url(),
503
+ onMessage: noop,
504
+ })
505
+ })
506
+
507
+ expect(mockInstances).toHaveLength(1)
508
+ expect(lastMockWS().url).toBe('wss://example.com/ws1')
509
+
510
+ url.set('wss://example.com/ws2')
511
+
512
+ expect(mockInstances).toHaveLength(2)
513
+ expect(lastMockWS().url).toBe('wss://example.com/ws2')
514
+
515
+ unmount()
516
+ })
517
+
518
+ it('supports WebSocket protocols', () => {
519
+ const client = makeClient()
520
+
521
+ const unmount = withProvider(client, () => {
522
+ useSubscription({
523
+ url: 'wss://example.com/ws',
524
+ protocols: ['graphql-ws'],
525
+ onMessage: noop,
526
+ })
527
+ })
528
+
529
+ expect(lastMockWS().protocols).toEqual(['graphql-ws'])
530
+ unmount()
531
+ })
532
+
533
+ it('cleans up on unmount', () => {
534
+ const client = makeClient()
535
+
536
+ const unmount = withProvider(client, () => {
537
+ useSubscription({
538
+ url: 'wss://example.com/ws',
539
+ onMessage: noop,
540
+ })
541
+ })
542
+
543
+ lastMockWS()._simulateOpen()
544
+ const ws = lastMockWS()
545
+
546
+ unmount()
547
+ expect(ws.close).toHaveBeenCalled()
548
+ })
549
+
550
+ it('resets reconnect count on successful connection', async () => {
551
+ const client = makeClient()
552
+
553
+ const unmount = withProvider(client, () => {
554
+ useSubscription({
555
+ url: 'wss://example.com/ws',
556
+ onMessage: noop,
557
+ reconnect: true,
558
+ reconnectDelay: 10,
559
+ maxReconnectAttempts: 2,
560
+ })
561
+ })
562
+
563
+ // First connection then close
564
+ lastMockWS()._simulateOpen()
565
+ lastMockWS()._simulateClose()
566
+
567
+ // Reconnect 1
568
+ await new Promise((r) => setTimeout(r, 30))
569
+ expect(mockInstances).toHaveLength(2)
570
+
571
+ // This reconnect succeeds — should reset the counter
572
+ lastMockWS()._simulateOpen()
573
+ lastMockWS()._simulateClose()
574
+
575
+ // Should be able to reconnect again (counter was reset)
576
+ await new Promise((r) => setTimeout(r, 30))
577
+ expect(mockInstances).toHaveLength(3)
578
+
579
+ unmount()
580
+ })
581
+ })
@@ -1,7 +1,6 @@
1
1
  import { onUnmount } from '@pyreon/core'
2
- import { signal, effect, batch } from '@pyreon/reactivity'
3
2
  import type { Signal } from '@pyreon/reactivity'
4
- import { InfiniteQueryObserver } from '@tanstack/query-core'
3
+ import { batch, effect, signal } from '@pyreon/reactivity'
5
4
  import type {
6
5
  DefaultError,
7
6
  InfiniteData,
@@ -10,6 +9,7 @@ import type {
10
9
  QueryKey,
11
10
  QueryObserverResult,
12
11
  } from '@tanstack/query-core'
12
+ import { InfiniteQueryObserver } from '@tanstack/query-core'
13
13
  import { useQueryClient } from './query-client'
14
14
 
15
15
  export interface UseInfiniteQueryResult<TQueryFnData, TError = DefaultError> {
@@ -1,6 +1,6 @@
1
1
  import { onUnmount } from '@pyreon/core'
2
- import { signal } from '@pyreon/reactivity'
3
2
  import type { Signal } from '@pyreon/reactivity'
3
+ import { signal } from '@pyreon/reactivity'
4
4
  import type { MutationFilters, QueryFilters } from '@tanstack/query-core'
5
5
  import { useQueryClient } from './query-client'
6
6
 
@@ -1,13 +1,13 @@
1
1
  import { onUnmount } from '@pyreon/core'
2
- import { signal, batch } from '@pyreon/reactivity'
3
2
  import type { Signal } from '@pyreon/reactivity'
4
- import { MutationObserver } from '@tanstack/query-core'
3
+ import { batch, signal } from '@pyreon/reactivity'
5
4
  import type {
6
5
  DefaultError,
7
6
  MutateFunction,
8
7
  MutationObserverOptions,
9
8
  MutationObserverResult,
10
9
  } from '@tanstack/query-core'
10
+ import { MutationObserver } from '@tanstack/query-core'
11
11
  import { useQueryClient } from './query-client'
12
12
 
13
13
  export interface UseMutationResult<
@@ -1,13 +1,13 @@
1
1
  import { onUnmount } from '@pyreon/core'
2
- import { signal, effect } from '@pyreon/reactivity'
3
2
  import type { Signal } from '@pyreon/reactivity'
4
- import { QueriesObserver } from '@tanstack/query-core'
3
+ import { effect, signal } from '@pyreon/reactivity'
5
4
  import type {
6
5
  DefaultError,
7
6
  QueryKey,
8
7
  QueryObserverOptions,
9
8
  QueryObserverResult,
10
9
  } from '@tanstack/query-core'
10
+ import { QueriesObserver } from '@tanstack/query-core'
11
11
  import { useQueryClient } from './query-client'
12
12
 
13
13
  export type UseQueriesOptions<TQueryKey extends QueryKey = QueryKey> =
@@ -1,12 +1,11 @@
1
+ import type { Props, VNode, VNodeChild } from '@pyreon/core'
1
2
  import {
2
3
  createContext,
3
- pushContext,
4
- popContext,
5
4
  onUnmount,
5
+ popContext,
6
+ pushContext,
6
7
  useContext,
7
8
  } from '@pyreon/core'
8
- import type { VNodeChild, VNode } from '@pyreon/core'
9
- import type { Props } from '@pyreon/core'
10
9
  import { useQueryClient } from './query-client'
11
10
 
12
11
  // ─── Context ────────────────────────────────────────────────────────────────
package/src/use-query.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  import { onUnmount } from '@pyreon/core'
2
- import { signal, effect, batch } from '@pyreon/reactivity'
3
2
  import type { Signal } from '@pyreon/reactivity'
4
- import { QueryObserver } from '@tanstack/query-core'
3
+ import { batch, effect, signal } from '@pyreon/reactivity'
5
4
  import type {
6
5
  DefaultError,
7
6
  QueryKey,
8
7
  QueryObserverOptions,
9
8
  QueryObserverResult,
10
9
  } from '@tanstack/query-core'
10
+ import { QueryObserver } from '@tanstack/query-core'
11
11
  import { useQueryClient } from './query-client'
12
12
 
13
13
  export interface UseQueryResult<TData, TError = DefaultError> {