@pyreon/query 0.10.0 → 0.11.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,857 @@
1
+ import { signal } from "@pyreon/reactivity"
2
+ import { mount } from "@pyreon/runtime-dom"
3
+ import { QueryClient } from "@tanstack/query-core"
4
+ import { QueryClientProvider, type UseSSEResult, useSSE } from "../index"
5
+
6
+ // ─── Mock EventSource ───────────────────────────────────────────────────────
7
+
8
+ type MockESListener = ((event: unknown) => void) | null
9
+
10
+ interface MockEventSource {
11
+ url: string
12
+ withCredentials: boolean
13
+ readyState: number
14
+ onopen: MockESListener
15
+ onmessage: MockESListener
16
+ onerror: MockESListener
17
+ close: ReturnType<typeof vi.fn>
18
+ addEventListener: ReturnType<typeof vi.fn>
19
+ removeEventListener: ReturnType<typeof vi.fn>
20
+ // Test helpers
21
+ _simulateOpen: () => void
22
+ _simulateMessage: (data: string, lastEventId?: string) => void
23
+ _simulateNamedEvent: (name: string, data: string) => void
24
+ _simulateError: (closed?: boolean) => void
25
+ _namedListeners: Map<string, EventListener[]>
26
+ }
27
+
28
+ let mockInstances: MockEventSource[] = []
29
+
30
+ class MockEventSourceClass {
31
+ static CONNECTING = 0
32
+ static OPEN = 1
33
+ static CLOSED = 2
34
+
35
+ url: string
36
+ withCredentials: boolean
37
+ readyState = 0
38
+ onopen: MockESListener = null
39
+ onmessage: MockESListener = null
40
+ onerror: MockESListener = null
41
+
42
+ _namedListeners = new Map<string, EventListener[]>()
43
+
44
+ close = vi.fn(() => {
45
+ this.readyState = MockEventSourceClass.CLOSED
46
+ })
47
+
48
+ addEventListener = vi.fn((type: string, listener: EventListener) => {
49
+ const listeners = this._namedListeners.get(type) ?? []
50
+ listeners.push(listener)
51
+ this._namedListeners.set(type, listeners)
52
+ })
53
+
54
+ removeEventListener = vi.fn((type: string, listener: EventListener) => {
55
+ const listeners = this._namedListeners.get(type) ?? []
56
+ const idx = listeners.indexOf(listener)
57
+ if (idx >= 0) listeners.splice(idx, 1)
58
+ })
59
+
60
+ constructor(url: string, init?: { withCredentials?: boolean }) {
61
+ this.url = url
62
+ this.withCredentials = init?.withCredentials ?? false
63
+ this.readyState = MockEventSourceClass.CONNECTING
64
+ mockInstances.push(this as unknown as MockEventSource)
65
+ }
66
+
67
+ _simulateOpen() {
68
+ this.readyState = MockEventSourceClass.OPEN
69
+ this.onopen?.({ type: "open" })
70
+ }
71
+
72
+ _simulateMessage(data: string, lastEventId = "") {
73
+ this.onmessage?.({ type: "message", data, lastEventId } as unknown as MessageEvent)
74
+ }
75
+
76
+ _simulateNamedEvent(name: string, data: string) {
77
+ const listeners = this._namedListeners.get(name) ?? []
78
+ for (const listener of listeners) {
79
+ listener({ type: name, data } as unknown as Event)
80
+ }
81
+ }
82
+
83
+ _simulateError(closed = false) {
84
+ if (closed) {
85
+ this.readyState = MockEventSourceClass.CLOSED
86
+ }
87
+ this.onerror?.({ type: "error" })
88
+ }
89
+ }
90
+
91
+ // Install mock
92
+ const OriginalEventSource = globalThis.EventSource
93
+ beforeAll(() => {
94
+ ;(globalThis as any).EventSource = MockEventSourceClass
95
+ })
96
+ afterAll(() => {
97
+ globalThis.EventSource = OriginalEventSource
98
+ })
99
+
100
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
101
+
102
+ function makeClient() {
103
+ return new QueryClient({
104
+ defaultOptions: { queries: { retry: false, gcTime: Infinity } },
105
+ })
106
+ }
107
+
108
+ function withProvider(client: QueryClient, component: () => void): () => void {
109
+ const el = document.createElement("div")
110
+ document.body.appendChild(el)
111
+ const unmount = mount(
112
+ <QueryClientProvider client={client}>
113
+ {() => {
114
+ component()
115
+ return null
116
+ }}
117
+ </QueryClientProvider>,
118
+ el,
119
+ )
120
+ return () => {
121
+ unmount()
122
+ el.remove()
123
+ }
124
+ }
125
+
126
+ function lastMockES(): MockEventSource {
127
+ return mockInstances[mockInstances.length - 1]!
128
+ }
129
+
130
+ // ─── Tests ───────────────────────────────────────────────────────────────────
131
+
132
+ describe("useSSE", () => {
133
+ beforeEach(() => {
134
+ mockInstances = []
135
+ })
136
+
137
+ it("connects to the EventSource URL", () => {
138
+ const client = makeClient()
139
+ let sse: UseSSEResult<string> | null = null
140
+
141
+ const unmount = withProvider(client, () => {
142
+ sse = useSSE({ url: "http://example.com/events" })
143
+ })
144
+
145
+ expect(mockInstances).toHaveLength(1)
146
+ expect(lastMockES().url).toBe("http://example.com/events")
147
+ expect(sse!.status()).toBe("connecting")
148
+
149
+ unmount()
150
+ })
151
+
152
+ it("status transitions to connected on open", () => {
153
+ const client = makeClient()
154
+ let sse: UseSSEResult<string> | null = null
155
+
156
+ const unmount = withProvider(client, () => {
157
+ sse = useSSE({ url: "http://example.com/events" })
158
+ })
159
+
160
+ lastMockES()._simulateOpen()
161
+ expect(sse!.status()).toBe("connected")
162
+
163
+ unmount()
164
+ })
165
+
166
+ it("updates data signal on message", () => {
167
+ const client = makeClient()
168
+ let sse: UseSSEResult<string> | null = null
169
+
170
+ const unmount = withProvider(client, () => {
171
+ sse = useSSE({ url: "http://example.com/events" })
172
+ })
173
+
174
+ lastMockES()._simulateOpen()
175
+ lastMockES()._simulateMessage("hello")
176
+
177
+ expect(sse!.data()).toBe("hello")
178
+
179
+ lastMockES()._simulateMessage("world")
180
+ expect(sse!.data()).toBe("world")
181
+
182
+ unmount()
183
+ })
184
+
185
+ it("calls onMessage with parsed data and queryClient", () => {
186
+ const client = makeClient()
187
+ const messages: string[] = []
188
+
189
+ const unmount = withProvider(client, () => {
190
+ useSSE({
191
+ url: "http://example.com/events",
192
+ onMessage: (data, qc) => {
193
+ messages.push(data)
194
+ expect(qc).toBe(client)
195
+ },
196
+ })
197
+ })
198
+
199
+ lastMockES()._simulateOpen()
200
+ lastMockES()._simulateMessage("hello")
201
+ lastMockES()._simulateMessage("world")
202
+
203
+ expect(messages).toEqual(["hello", "world"])
204
+ unmount()
205
+ })
206
+
207
+ it("parses messages with parse option", () => {
208
+ const client = makeClient()
209
+ let sse: UseSSEResult<{ value: number }> | null = null
210
+
211
+ const unmount = withProvider(client, () => {
212
+ sse = useSSE({
213
+ url: "http://example.com/events",
214
+ parse: JSON.parse,
215
+ })
216
+ })
217
+
218
+ lastMockES()._simulateOpen()
219
+ lastMockES()._simulateMessage('{"value":42}')
220
+
221
+ expect(sse!.data()).toEqual({ value: 42 })
222
+
223
+ unmount()
224
+ })
225
+
226
+ it("invalidates queries on message", () => {
227
+ const client = makeClient()
228
+ const invalidateSpy = vi.spyOn(client, "invalidateQueries")
229
+
230
+ const unmount = withProvider(client, () => {
231
+ useSSE({
232
+ url: "http://example.com/events",
233
+ onMessage: (_data, qc) => {
234
+ qc.invalidateQueries({ queryKey: ["orders"] })
235
+ },
236
+ })
237
+ })
238
+
239
+ lastMockES()._simulateOpen()
240
+ lastMockES()._simulateMessage("order-updated")
241
+
242
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["orders"] })
243
+ unmount()
244
+ })
245
+
246
+ it("listens to named events", () => {
247
+ const client = makeClient()
248
+ let sse: UseSSEResult<string> | null = null
249
+
250
+ const unmount = withProvider(client, () => {
251
+ sse = useSSE({
252
+ url: "http://example.com/events",
253
+ events: "order-update",
254
+ })
255
+ })
256
+
257
+ lastMockES()._simulateOpen()
258
+
259
+ // Named event listener should be attached
260
+ expect(lastMockES().addEventListener).toHaveBeenCalledWith("order-update", expect.any(Function))
261
+
262
+ // Generic onmessage should NOT be set
263
+ expect(lastMockES().onmessage).toBeNull()
264
+
265
+ // Simulate named event
266
+ lastMockES()._simulateNamedEvent("order-update", "data1")
267
+ expect(sse!.data()).toBe("data1")
268
+
269
+ unmount()
270
+ })
271
+
272
+ it("listens to multiple named events", () => {
273
+ const client = makeClient()
274
+ let sse: UseSSEResult<string> | null = null
275
+
276
+ const unmount = withProvider(client, () => {
277
+ sse = useSSE({
278
+ url: "http://example.com/events",
279
+ events: ["order-update", "user-update"],
280
+ })
281
+ })
282
+
283
+ lastMockES()._simulateOpen()
284
+
285
+ expect(lastMockES().addEventListener).toHaveBeenCalledTimes(2)
286
+
287
+ lastMockES()._simulateNamedEvent("order-update", "order1")
288
+ expect(sse!.data()).toBe("order1")
289
+
290
+ lastMockES()._simulateNamedEvent("user-update", "user1")
291
+ expect(sse!.data()).toBe("user1")
292
+
293
+ unmount()
294
+ })
295
+
296
+ it("close() disconnects and sets status", () => {
297
+ const client = makeClient()
298
+ let sse: UseSSEResult<string> | null = null
299
+
300
+ const unmount = withProvider(client, () => {
301
+ sse = useSSE({ url: "http://example.com/events" })
302
+ })
303
+
304
+ lastMockES()._simulateOpen()
305
+ sse!.close()
306
+
307
+ expect(sse!.status()).toBe("disconnected")
308
+ expect(lastMockES().close).toHaveBeenCalled()
309
+ unmount()
310
+ })
311
+
312
+ it("status transitions to error on error", () => {
313
+ const client = makeClient()
314
+ let sse: UseSSEResult<string> | null = null
315
+ const errors: Event[] = []
316
+
317
+ const unmount = withProvider(client, () => {
318
+ sse = useSSE({
319
+ url: "http://example.com/events",
320
+ reconnect: false,
321
+ onError: (e) => errors.push(e),
322
+ })
323
+ })
324
+
325
+ lastMockES()._simulateError()
326
+ expect(sse!.status()).toBe("error")
327
+ expect(sse!.error()).not.toBeNull()
328
+ expect(errors).toHaveLength(1)
329
+ unmount()
330
+ })
331
+
332
+ it("auto-reconnects when EventSource closes", async () => {
333
+ const client = makeClient()
334
+
335
+ const unmount = withProvider(client, () => {
336
+ useSSE({
337
+ url: "http://example.com/events",
338
+ reconnect: true,
339
+ reconnectDelay: 50,
340
+ })
341
+ })
342
+
343
+ expect(mockInstances).toHaveLength(1)
344
+ lastMockES()._simulateOpen()
345
+
346
+ // Simulate error with CLOSED readyState (browser gave up)
347
+ lastMockES()._simulateError(true)
348
+
349
+ // Wait for reconnect
350
+ await new Promise((r) => setTimeout(r, 100))
351
+
352
+ expect(mockInstances).toHaveLength(2)
353
+ unmount()
354
+ })
355
+
356
+ it("does not reconnect when reconnect is false", async () => {
357
+ const client = makeClient()
358
+
359
+ const unmount = withProvider(client, () => {
360
+ useSSE({
361
+ url: "http://example.com/events",
362
+ reconnect: false,
363
+ })
364
+ })
365
+
366
+ lastMockES()._simulateOpen()
367
+ lastMockES()._simulateError(true)
368
+
369
+ await new Promise((r) => setTimeout(r, 100))
370
+ expect(mockInstances).toHaveLength(1)
371
+ unmount()
372
+ })
373
+
374
+ it("does not reconnect after intentional close()", async () => {
375
+ const client = makeClient()
376
+ let sse: UseSSEResult<string> | null = null
377
+
378
+ const unmount = withProvider(client, () => {
379
+ sse = useSSE({
380
+ url: "http://example.com/events",
381
+ reconnect: true,
382
+ reconnectDelay: 50,
383
+ })
384
+ })
385
+
386
+ lastMockES()._simulateOpen()
387
+ sse!.close()
388
+
389
+ await new Promise((r) => setTimeout(r, 100))
390
+ expect(mockInstances).toHaveLength(1)
391
+ unmount()
392
+ })
393
+
394
+ it("respects maxReconnectAttempts", async () => {
395
+ const client = makeClient()
396
+
397
+ const unmount = withProvider(client, () => {
398
+ useSSE({
399
+ url: "http://example.com/events",
400
+ reconnect: true,
401
+ reconnectDelay: 10,
402
+ maxReconnectAttempts: 2,
403
+ })
404
+ })
405
+
406
+ // First connection
407
+ lastMockES()._simulateOpen()
408
+ lastMockES()._simulateError(true)
409
+
410
+ // Reconnect 1
411
+ await new Promise((r) => setTimeout(r, 30))
412
+ expect(mockInstances).toHaveLength(2)
413
+ lastMockES()._simulateError(true)
414
+
415
+ // Reconnect 2
416
+ await new Promise((r) => setTimeout(r, 40))
417
+ expect(mockInstances).toHaveLength(3)
418
+ lastMockES()._simulateError(true)
419
+
420
+ // Should not reconnect again (max 2 attempts reached)
421
+ await new Promise((r) => setTimeout(r, 100))
422
+ expect(mockInstances).toHaveLength(3)
423
+
424
+ unmount()
425
+ })
426
+
427
+ it("reconnect() resets attempts and reconnects", () => {
428
+ const client = makeClient()
429
+ let sse: UseSSEResult<string> | null = null
430
+
431
+ const unmount = withProvider(client, () => {
432
+ sse = useSSE({
433
+ url: "http://example.com/events",
434
+ reconnect: false,
435
+ })
436
+ })
437
+
438
+ lastMockES()._simulateOpen()
439
+ lastMockES()._simulateError(true)
440
+
441
+ expect(mockInstances).toHaveLength(1)
442
+
443
+ sse!.reconnect()
444
+ expect(mockInstances).toHaveLength(2)
445
+ expect(sse!.status()).toBe("connecting")
446
+
447
+ unmount()
448
+ })
449
+
450
+ it("enabled: false prevents connection", () => {
451
+ const client = makeClient()
452
+ let sse: UseSSEResult<string> | null = null
453
+
454
+ const unmount = withProvider(client, () => {
455
+ sse = useSSE({
456
+ url: "http://example.com/events",
457
+ enabled: false,
458
+ })
459
+ })
460
+
461
+ expect(mockInstances).toHaveLength(0)
462
+ expect(sse!.status()).toBe("disconnected")
463
+ unmount()
464
+ })
465
+
466
+ it("reactive enabled signal controls connection", () => {
467
+ const client = makeClient()
468
+ const enabled = signal(false)
469
+
470
+ const unmount = withProvider(client, () => {
471
+ useSSE({
472
+ url: "http://example.com/events",
473
+ enabled: () => enabled(),
474
+ })
475
+ })
476
+
477
+ expect(mockInstances).toHaveLength(0)
478
+
479
+ enabled.set(true)
480
+
481
+ // Effect runs synchronously in Pyreon
482
+ expect(mockInstances).toHaveLength(1)
483
+ unmount()
484
+ })
485
+
486
+ it("reactive URL reconnects when URL changes", () => {
487
+ const client = makeClient()
488
+ const url = signal("http://example.com/events1")
489
+
490
+ const unmount = withProvider(client, () => {
491
+ useSSE({ url: () => url() })
492
+ })
493
+
494
+ expect(mockInstances).toHaveLength(1)
495
+ expect(lastMockES().url).toBe("http://example.com/events1")
496
+
497
+ url.set("http://example.com/events2")
498
+
499
+ expect(mockInstances).toHaveLength(2)
500
+ expect(lastMockES().url).toBe("http://example.com/events2")
501
+
502
+ unmount()
503
+ })
504
+
505
+ it("passes withCredentials to EventSource", () => {
506
+ const client = makeClient()
507
+
508
+ const unmount = withProvider(client, () => {
509
+ useSSE({
510
+ url: "http://example.com/events",
511
+ withCredentials: true,
512
+ })
513
+ })
514
+
515
+ expect(lastMockES().withCredentials).toBe(true)
516
+ unmount()
517
+ })
518
+
519
+ it("withCredentials defaults to false", () => {
520
+ const client = makeClient()
521
+
522
+ const unmount = withProvider(client, () => {
523
+ useSSE({ url: "http://example.com/events" })
524
+ })
525
+
526
+ expect(lastMockES().withCredentials).toBe(false)
527
+ unmount()
528
+ })
529
+
530
+ it("cleans up on unmount", () => {
531
+ const client = makeClient()
532
+
533
+ const unmount = withProvider(client, () => {
534
+ useSSE({ url: "http://example.com/events" })
535
+ })
536
+
537
+ lastMockES()._simulateOpen()
538
+ const es = lastMockES()
539
+
540
+ unmount()
541
+ expect(es.close).toHaveBeenCalled()
542
+ })
543
+
544
+ it("message handler that throws does not crash the subscription", () => {
545
+ const client = makeClient()
546
+ let sse: UseSSEResult<string> | null = null
547
+
548
+ const unmount = withProvider(client, () => {
549
+ sse = useSSE({
550
+ url: "http://example.com/events",
551
+ onMessage: () => {
552
+ throw new Error("handler boom")
553
+ },
554
+ })
555
+ })
556
+
557
+ lastMockES()._simulateOpen()
558
+
559
+ // Should not throw — the error is caught internally
560
+ expect(() => lastMockES()._simulateMessage("test")).not.toThrow()
561
+
562
+ // Subscription should still be connected
563
+ expect(sse!.status()).toBe("connected")
564
+
565
+ unmount()
566
+ })
567
+
568
+ it("no reconnect attempts after unmount", async () => {
569
+ const client = makeClient()
570
+
571
+ const unmount = withProvider(client, () => {
572
+ useSSE({
573
+ url: "http://example.com/events",
574
+ reconnect: true,
575
+ reconnectDelay: 20,
576
+ })
577
+ })
578
+
579
+ lastMockES()._simulateOpen()
580
+ unmount()
581
+
582
+ // Wait longer than reconnectDelay — should not create new connections
583
+ await new Promise((r) => setTimeout(r, 80))
584
+ expect(mockInstances).toHaveLength(1)
585
+ })
586
+
587
+ it("data() starts as null", () => {
588
+ const client = makeClient()
589
+ let sse: UseSSEResult<string> | null = null
590
+
591
+ const unmount = withProvider(client, () => {
592
+ sse = useSSE({ url: "http://example.com/events" })
593
+ })
594
+
595
+ expect(sse!.data()).toBeNull()
596
+ unmount()
597
+ })
598
+
599
+ it("error() starts as null and is set on error", () => {
600
+ const client = makeClient()
601
+ let sse: UseSSEResult<string> | null = null
602
+
603
+ const unmount = withProvider(client, () => {
604
+ sse = useSSE({
605
+ url: "http://example.com/events",
606
+ reconnect: false,
607
+ })
608
+ })
609
+
610
+ expect(sse!.error()).toBeNull()
611
+
612
+ lastMockES()._simulateError()
613
+ expect(sse!.error()).not.toBeNull()
614
+
615
+ unmount()
616
+ })
617
+
618
+ it("error is cleared on successful open", () => {
619
+ const client = makeClient()
620
+ let sse: UseSSEResult<string> | null = null
621
+
622
+ const unmount = withProvider(client, () => {
623
+ sse = useSSE({
624
+ url: "http://example.com/events",
625
+ reconnect: false,
626
+ })
627
+ })
628
+
629
+ lastMockES()._simulateError()
630
+ expect(sse!.error()).not.toBeNull()
631
+
632
+ // Manually reconnect
633
+ sse!.reconnect()
634
+ lastMockES()._simulateOpen()
635
+
636
+ expect(sse!.error()).toBeNull()
637
+ expect(sse!.status()).toBe("connected")
638
+
639
+ unmount()
640
+ })
641
+
642
+ // ── lastEventId ──────────────────────────────────────────────────────────
643
+
644
+ it("lastEventId starts as empty string", () => {
645
+ const client = makeClient()
646
+ let sse: UseSSEResult<string> | null = null
647
+
648
+ const unmount = withProvider(client, () => {
649
+ sse = useSSE({ url: "http://example.com/events" })
650
+ })
651
+
652
+ expect(sse!.lastEventId()).toBe("")
653
+ unmount()
654
+ })
655
+
656
+ it("lastEventId is updated from message events", () => {
657
+ const client = makeClient()
658
+ let sse: UseSSEResult<string> | null = null
659
+
660
+ const unmount = withProvider(client, () => {
661
+ sse = useSSE({ url: "http://example.com/events" })
662
+ })
663
+
664
+ lastMockES()._simulateOpen()
665
+ lastMockES()._simulateMessage("hello", "evt-1")
666
+
667
+ expect(sse!.lastEventId()).toBe("evt-1")
668
+
669
+ lastMockES()._simulateMessage("world", "evt-2")
670
+ expect(sse!.lastEventId()).toBe("evt-2")
671
+
672
+ unmount()
673
+ })
674
+
675
+ it("lastEventId is not updated when lastEventId is empty", () => {
676
+ const client = makeClient()
677
+ let sse: UseSSEResult<string> | null = null
678
+
679
+ const unmount = withProvider(client, () => {
680
+ sse = useSSE({ url: "http://example.com/events" })
681
+ })
682
+
683
+ lastMockES()._simulateOpen()
684
+ lastMockES()._simulateMessage("hello", "evt-1")
685
+ expect(sse!.lastEventId()).toBe("evt-1")
686
+
687
+ // Message with empty lastEventId should not overwrite
688
+ lastMockES()._simulateMessage("world", "")
689
+ expect(sse!.lastEventId()).toBe("evt-1")
690
+
691
+ unmount()
692
+ })
693
+
694
+ // ── onOpen callback ──────────────────────────────────────────────────────
695
+
696
+ it("calls onOpen when connection opens", () => {
697
+ const client = makeClient()
698
+ const openEvents: Event[] = []
699
+
700
+ const unmount = withProvider(client, () => {
701
+ useSSE({
702
+ url: "http://example.com/events",
703
+ onOpen: (event) => openEvents.push(event),
704
+ })
705
+ })
706
+
707
+ lastMockES()._simulateOpen()
708
+ expect(openEvents).toHaveLength(1)
709
+ expect(openEvents[0]!.type).toBe("open")
710
+
711
+ unmount()
712
+ })
713
+
714
+ it("onOpen is called on each reconnection open", async () => {
715
+ const client = makeClient()
716
+ const openEvents: Event[] = []
717
+
718
+ const unmount = withProvider(client, () => {
719
+ useSSE({
720
+ url: "http://example.com/events",
721
+ reconnect: true,
722
+ reconnectDelay: 10,
723
+ onOpen: (event) => openEvents.push(event),
724
+ })
725
+ })
726
+
727
+ lastMockES()._simulateOpen()
728
+ expect(openEvents).toHaveLength(1)
729
+
730
+ // Trigger reconnect
731
+ lastMockES()._simulateError(true)
732
+ await new Promise((r) => setTimeout(r, 30))
733
+
734
+ lastMockES()._simulateOpen()
735
+ expect(openEvents).toHaveLength(2)
736
+
737
+ unmount()
738
+ })
739
+
740
+ // ── readyState ───────────────────────────────────────────────────────────
741
+
742
+ it("readyState starts as CLOSED (2)", () => {
743
+ const client = makeClient()
744
+ let sse: UseSSEResult<string> | null = null
745
+
746
+ const unmount = withProvider(client, () => {
747
+ sse = useSSE({ url: "http://example.com/events", enabled: false })
748
+ })
749
+
750
+ expect(sse!.readyState()).toBe(2)
751
+ unmount()
752
+ })
753
+
754
+ it("readyState transitions to CONNECTING (0) then OPEN (1)", () => {
755
+ const client = makeClient()
756
+ let sse: UseSSEResult<string> | null = null
757
+
758
+ const unmount = withProvider(client, () => {
759
+ sse = useSSE({ url: "http://example.com/events" })
760
+ })
761
+
762
+ // After connect() but before open, readyState should be CONNECTING
763
+ expect(sse!.readyState()).toBe(0)
764
+
765
+ lastMockES()._simulateOpen()
766
+ expect(sse!.readyState()).toBe(1)
767
+
768
+ unmount()
769
+ })
770
+
771
+ it("readyState becomes CLOSED (2) after close()", () => {
772
+ const client = makeClient()
773
+ let sse: UseSSEResult<string> | null = null
774
+
775
+ const unmount = withProvider(client, () => {
776
+ sse = useSSE({ url: "http://example.com/events" })
777
+ })
778
+
779
+ lastMockES()._simulateOpen()
780
+ expect(sse!.readyState()).toBe(1)
781
+
782
+ sse!.close()
783
+ expect(sse!.readyState()).toBe(2)
784
+
785
+ unmount()
786
+ })
787
+
788
+ it("readyState reflects CLOSED on terminal error", () => {
789
+ const client = makeClient()
790
+ let sse: UseSSEResult<string> | null = null
791
+
792
+ const unmount = withProvider(client, () => {
793
+ sse = useSSE({
794
+ url: "http://example.com/events",
795
+ reconnect: false,
796
+ })
797
+ })
798
+
799
+ lastMockES()._simulateOpen()
800
+ lastMockES()._simulateError(true)
801
+ expect(sse!.readyState()).toBe(2)
802
+
803
+ unmount()
804
+ })
805
+
806
+ it("resets reconnect count on successful connection", async () => {
807
+ const client = makeClient()
808
+
809
+ const unmount = withProvider(client, () => {
810
+ useSSE({
811
+ url: "http://example.com/events",
812
+ reconnect: true,
813
+ reconnectDelay: 10,
814
+ maxReconnectAttempts: 2,
815
+ })
816
+ })
817
+
818
+ // First connection then error-close
819
+ lastMockES()._simulateOpen()
820
+ lastMockES()._simulateError(true)
821
+
822
+ // Reconnect 1
823
+ await new Promise((r) => setTimeout(r, 30))
824
+ expect(mockInstances).toHaveLength(2)
825
+
826
+ // This reconnect succeeds — should reset the counter
827
+ lastMockES()._simulateOpen()
828
+ lastMockES()._simulateError(true)
829
+
830
+ // Should be able to reconnect again (counter was reset)
831
+ await new Promise((r) => setTimeout(r, 30))
832
+ expect(mockInstances).toHaveLength(3)
833
+
834
+ unmount()
835
+ })
836
+
837
+ it("removes named event listeners on close", () => {
838
+ const client = makeClient()
839
+ let sse: UseSSEResult<string> | null = null
840
+
841
+ const unmount = withProvider(client, () => {
842
+ sse = useSSE({
843
+ url: "http://example.com/events",
844
+ events: ["order-update", "user-update"],
845
+ })
846
+ })
847
+
848
+ const es = lastMockES()
849
+ lastMockES()._simulateOpen()
850
+ sse!.close()
851
+
852
+ expect(es.removeEventListener).toHaveBeenCalledWith("order-update", expect.any(Function))
853
+ expect(es.removeEventListener).toHaveBeenCalledWith("user-update", expect.any(Function))
854
+
855
+ unmount()
856
+ })
857
+ })