@pyreon/query 0.9.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.
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +164 -2
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +74 -7
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +14 -7
- package/src/index.ts +25 -19
- package/src/query-client.ts +5 -5
- package/src/tests/query.test.tsx +254 -268
- package/src/tests/sse.test.tsx +857 -0
- package/src/tests/subscription.test.tsx +200 -82
- package/src/use-infinite-query.ts +11 -19
- package/src/use-is-fetching.ts +5 -5
- package/src/use-mutation.ts +12 -21
- package/src/use-queries.ts +20 -19
- package/src/use-query-error-reset-boundary.ts +8 -11
- package/src/use-query.ts +10 -17
- package/src/use-sse.ts +266 -0
- package/src/use-subscription.ts +27 -39
- package/src/use-suspense-query.ts +18 -34
|
@@ -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
|
+
})
|