react-broadcast-sync 1.2.1 → 1.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.
@@ -1,30 +1,61 @@
1
- import { renderHook, act } from '@testing-library/react';
1
+ import { renderHook, act, waitFor } from '@testing-library/react';
2
2
  import { useBroadcastChannel } from '../hooks/useBroadcastChannel';
3
3
  import { getInternalMessageType } from '../utils/messageUtils';
4
4
 
5
5
  let mockChannels: any[] = [];
6
6
 
7
7
  class MockBroadcastChannel {
8
+ static channels: Record<string, MockBroadcastChannel[]> = {};
9
+
8
10
  name: string;
9
- onmessage: ((event: MessageEvent) => void) | null = null;
10
- postMessage = jest.fn();
11
- close = jest.fn();
11
+ _onmessage: ((event: MessageEvent) => void) | null = null;
12
+ closed = false;
13
+
14
+ constructor(name: string) {
15
+ this.name = name;
16
+ if (!MockBroadcastChannel.channels[name]) {
17
+ MockBroadcastChannel.channels[name] = [];
18
+ }
19
+ MockBroadcastChannel.channels[name].push(this);
20
+ mockChannels.push(this); // keep your existing tracking
21
+ }
22
+
23
+ postMessage = jest.fn((data: any) => {
24
+ const others = MockBroadcastChannel.channels[this.name] || [];
25
+ for (const channel of others) {
26
+ if (channel !== this && !channel.closed && channel._onmessage) {
27
+ channel._onmessage({ data } as MessageEvent);
28
+ }
29
+ }
30
+ });
31
+
32
+ close = jest.fn(() => {
33
+ this.closed = true;
34
+ this._onmessage = null;
35
+ });
36
+
12
37
  addEventListener = jest.fn((event: string, callback: (event: MessageEvent) => void) => {
13
38
  if (event === 'message') {
14
- this.onmessage = callback;
39
+ this._onmessage = callback;
15
40
  }
16
41
  });
17
- removeEventListener = jest.fn();
18
42
 
19
- constructor(name: string) {
20
- this.name = name;
21
- mockChannels.push(this);
43
+ removeEventListener = jest.fn((event: string, callback: (event: MessageEvent) => void) => {
44
+ if (event === 'message' && this._onmessage === callback) {
45
+ this._onmessage = null;
46
+ }
47
+ });
48
+
49
+ get onmessage() {
50
+ return this._onmessage;
51
+ }
52
+ set onmessage(fn) {
53
+ this._onmessage = fn;
22
54
  }
23
55
 
24
56
  simulateMessage(data: any) {
25
- if (this.onmessage) {
26
- this.onmessage({ data } as MessageEvent);
27
- }
57
+ if (this.closed || !this._onmessage) return;
58
+ this._onmessage({ data } as MessageEvent);
28
59
  }
29
60
  }
30
61
 
@@ -47,6 +78,7 @@ beforeAll(() => {
47
78
 
48
79
  beforeEach(() => {
49
80
  mockChannels = [];
81
+ MockBroadcastChannel.channels = {};
50
82
  global.BroadcastChannel = MockBroadcastChannel as any;
51
83
  });
52
84
 
@@ -60,460 +92,1030 @@ afterAll(() => {
60
92
  });
61
93
 
62
94
  describe('useBroadcastChannel', () => {
63
- it('posts and receives message', async () => {
64
- const { result } = renderHook(() => useBroadcastChannel('test-channel'));
65
- await waitForChannel();
66
- const channel = mockChannels[0];
95
+ describe('Core Functionality', () => {
96
+ it('posts and receives message', async () => {
97
+ const { result: hook1 } = renderHook(() =>
98
+ useBroadcastChannel('test-channel', { sourceName: 'source-1' })
99
+ );
100
+ const { result: hook2 } = renderHook(() =>
101
+ useBroadcastChannel('test-channel', { sourceName: 'source-2' })
102
+ );
103
+ await waitForChannel();
104
+
105
+ act(() => {
106
+ hook1.current.postMessage('greeting', { text: 'hi' });
107
+ });
108
+
109
+ await waitFor(() => {
110
+ expect(hook2.current.messages.length).toBe(1);
111
+ });
67
112
 
68
- act(() => {
69
- result.current.postMessage('greeting', { text: 'hi' });
113
+ expect(hook2.current.messages[0].message).toEqual({ text: 'hi' });
114
+ expect(hook2.current.messages[0].source).toBe('source-1');
70
115
  });
71
116
 
72
- const incomingMessage = {
73
- id: 'external-1',
74
- type: 'greeting',
75
- message: { text: 'hi' },
76
- source: 'another-tab',
77
- timestamp: Date.now(),
78
- };
117
+ it('ignores self messages', async () => {
118
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
119
+ await waitForChannel();
79
120
 
80
- act(() => {
81
- channel.simulateMessage(incomingMessage);
121
+ act(() => {
122
+ result.current.postMessage('test', 'msg');
123
+ });
124
+
125
+ expect(result.current.messages).toEqual([]);
126
+ expect(result.current.sentMessages.length).toBe(1);
82
127
  });
83
128
 
84
- expect(result.current.messages.length).toBe(1);
85
- expect(result.current.messages[0].message).toEqual({ text: 'hi' });
129
+ it('creates message with expiration duration', async () => {
130
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
131
+ await waitForChannel();
132
+
133
+ act(() => {
134
+ result.current.postMessage('test', { data: 'test' }, { expirationDuration: 5000 });
135
+ });
136
+
137
+ const sentMessage = result.current.sentMessages[0];
138
+ expect(sentMessage.expirationDate).toBeDefined();
139
+ expect(sentMessage.expirationDate).toBeGreaterThan(Date.now());
140
+ });
86
141
  });
87
142
 
88
- it('ignores self messages', async () => {
89
- const { result } = renderHook(() => useBroadcastChannel('test-channel'));
90
- await waitForChannel();
91
- const channel = mockChannels[0];
143
+ describe('Message Clearing', () => {
144
+ it('clears a specific received message', async () => {
145
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
146
+ await waitForChannel();
147
+ const channel = mockChannels[0];
148
+
149
+ const msg = {
150
+ id: '123',
151
+ type: 'greeting',
152
+ message: 'hi',
153
+ source: 'external',
154
+ timestamp: Date.now(),
155
+ };
156
+
157
+ act(() => {
158
+ channel.simulateMessage(msg);
159
+ });
160
+
161
+ expect(result.current.messages.length).toBe(1);
162
+
163
+ act(() => {
164
+ result.current.clearReceivedMessages({ ids: ['123'] });
165
+ });
92
166
 
93
- act(() => {
94
- result.current.postMessage('test', 'msg');
95
- const selfMsg = result.current.sentMessages[0];
96
- channel.simulateMessage(selfMsg);
167
+ expect(result.current.messages.length).toBe(0);
97
168
  });
98
169
 
99
- expect(result.current.messages).toEqual([]);
100
- });
170
+ it('clears all received messages', async () => {
171
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
172
+ await waitForChannel();
173
+ const channel = mockChannels[0];
174
+
175
+ act(() => {
176
+ channel.simulateMessage({
177
+ id: '1',
178
+ type: 'a',
179
+ message: 'a',
180
+ source: 'x',
181
+ timestamp: Date.now(),
182
+ });
183
+ channel.simulateMessage({
184
+ id: '2',
185
+ type: 'b',
186
+ message: 'b',
187
+ source: 'x',
188
+ timestamp: Date.now(),
189
+ });
190
+ });
101
191
 
102
- it('clears a specific message', async () => {
103
- const { result } = renderHook(() => useBroadcastChannel('test-channel'));
104
- await waitForChannel();
105
- const channel = mockChannels[0];
106
-
107
- const msg = {
108
- id: '123',
109
- type: 'greeting',
110
- message: 'hi',
111
- source: 'external',
112
- timestamp: Date.now(),
113
- };
192
+ expect(result.current.messages.length).toBe(2);
193
+
194
+ act(() => {
195
+ result.current.clearReceivedMessages();
196
+ });
114
197
 
115
- act(() => {
116
- channel.simulateMessage(msg);
198
+ expect(result.current.messages.length).toBe(0);
117
199
  });
118
200
 
119
- expect(result.current.messages.length).toBe(1);
201
+ it('clears a specific sent message', async () => {
202
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
203
+ await waitForChannel();
204
+
205
+ act(() => {
206
+ result.current.postMessage('test-type', { key: 'value' });
207
+ });
208
+
209
+ const sentId = result.current.sentMessages[0]?.id;
210
+ expect(result.current.sentMessages.length).toBe(1);
120
211
 
121
- act(() => {
122
- result.current.clearReceivedMessages({ ids: ['123'] });
212
+ act(() => {
213
+ result.current.clearSentMessages({ ids: [sentId] });
214
+ });
215
+
216
+ expect(result.current.sentMessages.length).toBe(0);
123
217
  });
124
218
 
125
- expect(result.current.messages.length).toBe(0);
126
- });
219
+ it('handles internal clear message (single id) from other source', async () => {
220
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
127
221
 
128
- it('clears all messages', async () => {
129
- const { result } = renderHook(() => useBroadcastChannel('test-channel'));
130
- await waitForChannel();
131
- const channel = mockChannels[0];
222
+ await waitForChannel();
132
223
 
133
- act(() => {
134
- channel.simulateMessage({
135
- id: '1',
136
- type: 'a',
137
- message: 'a',
138
- source: 'x',
224
+ const channel = mockChannels[0];
225
+ const externalSource = 'external-source';
226
+
227
+ const msg = {
228
+ id: 'to-clear',
229
+ type: 'test',
230
+ message: 'should be removed',
139
231
  timestamp: Date.now(),
140
- });
141
- channel.simulateMessage({
142
- id: '2',
143
- type: 'b',
144
- message: 'b',
145
- source: 'x',
232
+ source: externalSource,
233
+ };
234
+
235
+ const internalClear = {
236
+ id: 'internal-id',
237
+ type: getInternalMessageType('CLEAR_SENT_MESSAGES', 'test-channel'),
238
+ message: { ids: ['to-clear'], types: [] },
146
239
  timestamp: Date.now(),
240
+ source: externalSource,
241
+ };
242
+
243
+ act(() => {
244
+ channel.simulateMessage(msg);
147
245
  });
148
- });
149
246
 
150
- expect(result.current.messages.length).toBe(2);
247
+ expect(result.current.messages.length).toBe(1);
248
+
249
+ act(() => {
250
+ channel.simulateMessage(internalClear);
251
+ });
151
252
 
152
- act(() => {
153
- result.current.clearReceivedMessages();
253
+ expect(result.current.messages.length).toBe(0);
154
254
  });
155
255
 
156
- expect(result.current.messages.length).toBe(0);
157
- });
256
+ it('handles internal clear all messages (wildcard) from other source', async () => {
257
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
258
+ await waitForChannel();
259
+ const channel = mockChannels[0];
260
+ const externalSource = 'external-source';
158
261
 
159
- it('clears sent message', async () => {
160
- const { result } = renderHook(() => useBroadcastChannel('test-channel'));
161
- await waitForChannel();
262
+ const msg1 = {
263
+ id: 'msg1',
264
+ type: 'type1',
265
+ message: 'first',
266
+ timestamp: Date.now(),
267
+ source: externalSource,
268
+ };
269
+ const msg2 = {
270
+ id: 'msg2',
271
+ type: 'type2',
272
+ message: 'second',
273
+ timestamp: Date.now(),
274
+ source: externalSource,
275
+ };
276
+ const internalClear = {
277
+ id: 'internal-id',
278
+ type: getInternalMessageType('CLEAR_SENT_MESSAGES', 'test-channel'),
279
+ message: { ids: [], types: [] }, // wildcard
280
+ timestamp: Date.now(),
281
+ source: externalSource,
282
+ };
162
283
 
163
- act(() => {
164
- result.current.postMessage('test-type', { key: 'value' });
165
- });
284
+ act(() => {
285
+ channel.simulateMessage(msg1);
286
+ channel.simulateMessage(msg2);
287
+ });
166
288
 
167
- const sentId = result.current.sentMessages[0]?.id;
168
- expect(result.current.sentMessages.length).toBe(1);
289
+ await waitFor(() => {
290
+ expect(result.current.messages.length).toBe(2);
291
+ });
169
292
 
170
- act(() => {
171
- result.current.clearSentMessages({ ids: [sentId] });
172
- });
293
+ act(() => {
294
+ channel.simulateMessage(internalClear);
295
+ });
173
296
 
174
- expect(result.current.sentMessages.length).toBe(0);
297
+ await waitFor(() => {
298
+ expect(result.current.messages.length).toBe(0);
299
+ });
300
+ });
175
301
  });
176
302
 
177
- it('handles channel not available error', () => {
178
- jest.useFakeTimers();
303
+ describe('Error Handling', () => {
304
+ it('handles channel not available error on postMessage', () => {
305
+ const original = global.BroadcastChannel;
306
+ delete (global as any).BroadcastChannel;
179
307
 
180
- const original = global.BroadcastChannel;
181
- // @ts-expect-error - Testing invalid input
182
- delete global.BroadcastChannel;
308
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
309
+
310
+ act(() => {
311
+ result.current.postMessage('test', { a: 1 });
312
+ });
183
313
 
184
- const { result } = renderHook(() => useBroadcastChannel('test-channel'));
314
+ expect(result.current.error).toBe(
315
+ 'BroadcastChannel is not supported in this browser. Please check browser compatibility.'
316
+ );
185
317
 
186
- act(() => {
187
- result.current.postMessage('test', { a: 1 });
318
+ global.BroadcastChannel = original;
188
319
  });
189
320
 
190
- expect(result.current.error).toBe(
191
- 'BroadcastChannel is not supported in this browser. Please check browser compatibility.'
192
- );
321
+ it('sets error when BroadcastChannel is not supported on initialization', () => {
322
+ const original = global.BroadcastChannel;
323
+ delete (global as any).BroadcastChannel;
193
324
 
194
- global.BroadcastChannel = original;
195
- jest.useRealTimers();
196
- });
325
+ const { result } = renderHook(() => useBroadcastChannel('unsupported-channel'));
197
326
 
198
- it('sets error when BroadcastChannel is not supported', () => {
199
- const original = global.BroadcastChannel;
200
- delete (global as any).BroadcastChannel;
327
+ expect(result.current.error).toBe(
328
+ 'BroadcastChannel is not supported in this browser. Please check browser compatibility.'
329
+ );
330
+ global.BroadcastChannel = original;
331
+ });
201
332
 
202
- const { result } = renderHook(() => useBroadcastChannel('unsupported-channel'));
333
+ it('handles error when posting message fails', async () => {
334
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
335
+ await waitForChannel();
336
+ const channel = mockChannels[0];
203
337
 
204
- expect(result.current.error).toBe(
205
- 'BroadcastChannel is not supported in this browser. Please check browser compatibility.'
206
- );
207
- global.BroadcastChannel = original;
208
- });
338
+ channel.postMessage.mockImplementationOnce(() => {
339
+ throw new Error('Failed to post message');
340
+ });
209
341
 
210
- it('handles invalid messages', async () => {
211
- const { result } = renderHook(() => useBroadcastChannel('test-channel'));
212
- await waitForChannel();
213
- const channel = mockChannels[0];
342
+ act(() => {
343
+ result.current.postMessage('test', { data: 'test' });
344
+ });
214
345
 
215
- act(() => {
216
- channel.simulateMessage({ invalid: 'message' });
346
+ expect(result.current.error).toBe('Failed to send message');
217
347
  });
218
348
 
219
- expect(result.current.messages.length).toBe(0);
220
- });
349
+ it('handles error when processing message fails', async () => {
350
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
351
+ await waitForChannel();
352
+ const channel = mockChannels[0];
221
353
 
222
- it('handles expired messages', async () => {
223
- const { result } = renderHook(() => useBroadcastChannel('test-channel'));
224
- await waitForChannel();
225
- const channel = mockChannels[0];
226
-
227
- const expiredMessage = {
228
- id: 'expired-1',
229
- type: 'test',
230
- message: 'expired',
231
- source: 'another-tab',
232
- timestamp: Date.now() - 1000000,
233
- expirationDate: Date.now() - 1000000,
234
- };
354
+ const badMessage: any = {};
355
+ Object.defineProperty(badMessage, 'id', {
356
+ get() {
357
+ throw new Error('fail');
358
+ },
359
+ });
360
+ badMessage.type = 'test';
361
+ badMessage.source = 'z';
362
+ badMessage.timestamp = Date.now();
235
363
 
236
- act(() => {
237
- channel.simulateMessage(expiredMessage);
364
+ act(() => {
365
+ channel.simulateMessage(badMessage);
366
+ });
367
+
368
+ await waitFor(() => {
369
+ expect(result.current.error).toBe('Error processing broadcast message');
370
+ });
238
371
  });
239
372
 
240
- expect(result.current.messages.length).toBe(0);
241
- });
373
+ it('handles invalid messages', async () => {
374
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
375
+ await waitForChannel();
376
+ const channel = mockChannels[0];
242
377
 
243
- it('handles duplicate messages within TTL', async () => {
244
- const { result } = renderHook(() =>
245
- useBroadcastChannel('test-channel', { deduplicationTTL: 5000 })
246
- );
247
- await waitForChannel();
248
- const channel = mockChannels[0];
249
-
250
- const message = {
251
- id: 'duplicate-1',
252
- type: 'test',
253
- message: 'duplicate',
254
- source: 'another-tab',
255
- timestamp: Date.now(),
256
- };
378
+ act(() => {
379
+ channel.simulateMessage({ invalid: 'message' });
380
+ });
257
381
 
258
- act(() => {
259
- channel.simulateMessage(message);
260
- channel.simulateMessage(message);
382
+ expect(result.current.messages.length).toBe(0);
261
383
  });
262
-
263
- expect(result.current.messages.length).toBe(1);
264
384
  });
265
385
 
266
- it('handles internal clear message (single id) from other source', async () => {
267
- const { result } = renderHook(() => useBroadcastChannel('test-channel'));
268
- await waitForChannel();
269
- const channel = mockChannels[0];
386
+ describe('Message Lifecycle & Cleanup', () => {
387
+ it('handles expired messages', async () => {
388
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
389
+ await waitForChannel();
390
+ const channel = mockChannels[0];
270
391
 
271
- act(() => {
272
- channel.simulateMessage({
273
- id: 'to-clear',
392
+ const expiredMessage = {
393
+ id: 'expired-1',
274
394
  type: 'test',
275
- message: 'will be cleared',
395
+ message: 'expired',
276
396
  source: 'another-tab',
277
- timestamp: Date.now(),
397
+ timestamp: Date.now() - 1000000,
398
+ expirationDate: Date.now() - 1000000,
399
+ };
400
+
401
+ act(() => {
402
+ channel.simulateMessage(expiredMessage);
278
403
  });
404
+
405
+ expect(result.current.messages.length).toBe(0);
279
406
  });
280
407
 
281
- expect(result.current.messages.length).toBe(1);
408
+ it('performs cleanup of expired messages', async () => {
409
+ jest.useFakeTimers();
410
+ const { result } = renderHook(() =>
411
+ useBroadcastChannel('test-channel', { cleaningInterval: 1000 })
412
+ );
413
+ await waitForChannel();
414
+ const channel = mockChannels[0];
415
+
416
+ act(() => {
417
+ channel.simulateMessage({
418
+ id: 'expired-2',
419
+ type: 'test',
420
+ message: 'expired',
421
+ source: 'another-tab',
422
+ timestamp: Date.now() - 1000000,
423
+ expirationDate: Date.now() - 1000000,
424
+ });
425
+ });
282
426
 
283
- const internalType = getInternalMessageType('CLEAR_SENT_MESSAGES', 'test-channel');
284
- act(() => {
285
- channel.simulateMessage({
286
- id: 'internal-1',
287
- type: internalType,
288
- message: { ids: ['to-clear'], types: [] },
427
+ act(() => {
428
+ jest.advanceTimersByTime(1000);
429
+ });
430
+
431
+ expect(result.current.messages.length).toBe(0);
432
+ jest.useRealTimers();
433
+ });
434
+
435
+ it('handles cleanup debounce', async () => {
436
+ jest.useFakeTimers();
437
+ const { result } = renderHook(() =>
438
+ useBroadcastChannel('test-channel', {
439
+ cleaningInterval: 1000,
440
+ cleanupDebounceMs: 500,
441
+ })
442
+ );
443
+ await waitForChannel();
444
+ const channel = mockChannels[0];
445
+
446
+ act(() => {
447
+ channel.simulateMessage({
448
+ id: 'expired-3',
449
+ type: 'test',
450
+ message: 'expired',
451
+ source: 'another-tab',
452
+ timestamp: Date.now() - 1000000,
453
+ expirationDate: Date.now() - 1000000,
454
+ });
455
+ });
456
+
457
+ act(() => {
458
+ jest.advanceTimersByTime(1500);
459
+ });
460
+
461
+ expect(result.current.messages.length).toBe(0);
462
+ jest.useRealTimers();
463
+ });
464
+
465
+ it('handles duplicate messages within TTL', async () => {
466
+ const { result } = renderHook(() =>
467
+ useBroadcastChannel('test-channel', { deduplicationTTL: 5000 })
468
+ );
469
+ await waitForChannel();
470
+ const channel = mockChannels[0];
471
+
472
+ const message = {
473
+ id: 'duplicate-1',
474
+ type: 'test',
475
+ message: 'duplicate',
289
476
  source: 'another-tab',
290
477
  timestamp: Date.now(),
478
+ };
479
+
480
+ act(() => {
481
+ channel.simulateMessage(message);
482
+ channel.simulateMessage(message);
291
483
  });
484
+
485
+ expect(result.current.messages.length).toBe(1);
292
486
  });
487
+ });
488
+
489
+ describe('Hook Options', () => {
490
+ it('handles registered types filtering', async () => {
491
+ const { result } = renderHook(() =>
492
+ useBroadcastChannel('test-channel', {
493
+ registeredTypes: ['allowed-type'],
494
+ })
495
+ );
496
+ await waitForChannel();
497
+ const channel = mockChannels[0];
498
+
499
+ act(() => {
500
+ channel.simulateMessage({
501
+ id: 'unregistered',
502
+ type: 'unregistered-type',
503
+ message: 'test',
504
+ source: 'another-tab',
505
+ timestamp: Date.now(),
506
+ });
507
+ });
508
+
509
+ expect(result.current.messages.length).toBe(0);
510
+
511
+ act(() => {
512
+ channel.simulateMessage({
513
+ id: 'registered',
514
+ type: 'allowed-type',
515
+ message: 'test',
516
+ source: 'another-tab',
517
+ timestamp: Date.now(),
518
+ });
519
+ });
293
520
 
294
- expect(result.current.messages.length).toBe(0);
521
+ await waitFor(() => {
522
+ expect(result.current.messages.length).toBe(1);
523
+ });
524
+ });
525
+
526
+ it('handles keepLatestMessage option', async () => {
527
+ const { result } = renderHook(() =>
528
+ useBroadcastChannel('test-channel', { keepLatestMessage: true })
529
+ );
530
+ await waitForChannel();
531
+ const channel = mockChannels[0];
532
+
533
+ act(() => {
534
+ channel.simulateMessage({
535
+ id: 'msg1',
536
+ type: 'test',
537
+ message: 'first',
538
+ source: 'another-tab',
539
+ timestamp: Date.now(),
540
+ });
541
+ channel.simulateMessage({
542
+ id: 'msg2',
543
+ type: 'test',
544
+ message: 'second',
545
+ source: 'another-tab',
546
+ timestamp: Date.now(),
547
+ });
548
+ });
549
+
550
+ expect(result.current.messages.length).toBe(1);
551
+ expect(result.current.messages[0].message).toBe('second');
552
+ });
295
553
  });
296
554
 
297
- it('handles internal clear all messages (wildcard) from other source', async () => {
298
- const { result } = renderHook(() => useBroadcastChannel('test-channel'));
299
- await waitForChannel();
300
- const channel = mockChannels[0];
555
+ describe('getLatestMessage', () => {
556
+ it('returns null if there are no messages', async () => {
557
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
558
+ await waitForChannel();
559
+ expect(result.current.getLatestMessage()).toBeNull();
560
+ });
561
+
562
+ it('returns the latest message if no options are provided', async () => {
563
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
564
+ await waitForChannel();
565
+ const channel = mockChannels[0];
566
+ const msg1 = {
567
+ id: '1',
568
+ type: 'typeA',
569
+ message: 'first',
570
+ source: 'source1',
571
+ timestamp: Date.now(),
572
+ };
573
+ const msg2 = {
574
+ id: '2',
575
+ type: 'typeB',
576
+ message: 'second',
577
+ source: 'source2',
578
+ timestamp: Date.now() + 1,
579
+ };
580
+ act(() => {
581
+ channel.simulateMessage(msg1);
582
+ channel.simulateMessage(msg2);
583
+ });
584
+ expect(result.current.getLatestMessage()).toEqual(expect.objectContaining(msg2));
585
+ });
301
586
 
302
- act(() => {
303
- channel.simulateMessage({
304
- id: 'msg1',
305
- type: 'test1',
587
+ it('returns the latest message matching type', async () => {
588
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
589
+ await waitForChannel();
590
+ const channel = mockChannels[0];
591
+ const msg1 = {
592
+ id: '1',
593
+ type: 'typeA',
306
594
  message: 'first',
307
- source: 'another-tab',
595
+ source: 'source1',
308
596
  timestamp: Date.now(),
597
+ };
598
+ const msg2 = {
599
+ id: '2',
600
+ type: 'typeB',
601
+ message: 'second',
602
+ source: 'source2',
603
+ timestamp: Date.now() + 1,
604
+ };
605
+ const msg3 = {
606
+ id: '3',
607
+ type: 'typeA',
608
+ message: 'third',
609
+ source: 'source3',
610
+ timestamp: Date.now() + 2,
611
+ };
612
+ act(() => {
613
+ channel.simulateMessage(msg1);
614
+ channel.simulateMessage(msg2);
615
+ channel.simulateMessage(msg3);
309
616
  });
310
- channel.simulateMessage({
311
- id: 'msg2',
312
- type: 'test2',
617
+ expect(result.current.getLatestMessage({ type: 'typeA' })).toEqual(
618
+ expect.objectContaining(msg3)
619
+ );
620
+ });
621
+
622
+ it('returns the latest message matching source', async () => {
623
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
624
+ await waitForChannel();
625
+ const channel = mockChannels[0];
626
+ const msg1 = {
627
+ id: '1',
628
+ type: 'typeA',
629
+ message: 'first',
630
+ source: 'source1',
631
+ timestamp: Date.now(),
632
+ };
633
+ const msg2 = {
634
+ id: '2',
635
+ type: 'typeB',
313
636
  message: 'second',
314
- source: 'another-tab',
637
+ source: 'source2',
638
+ timestamp: Date.now() + 1,
639
+ };
640
+ const msg3 = {
641
+ id: '3',
642
+ type: 'typeA',
643
+ message: 'third',
644
+ source: 'source1',
645
+ timestamp: Date.now() + 2,
646
+ };
647
+ act(() => {
648
+ channel.simulateMessage(msg1);
649
+ channel.simulateMessage(msg2);
650
+ channel.simulateMessage(msg3);
651
+ });
652
+ expect(result.current.getLatestMessage({ source: 'source1' })).toEqual(
653
+ expect.objectContaining(msg3)
654
+ );
655
+ });
656
+
657
+ it('returns the latest message matching both type and source', async () => {
658
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
659
+ await waitForChannel();
660
+ const channel = mockChannels[0];
661
+ const msg1 = {
662
+ id: '1',
663
+ type: 'typeA',
664
+ message: 'first',
665
+ source: 'source1',
315
666
  timestamp: Date.now(),
667
+ };
668
+ const msg2 = {
669
+ id: '2',
670
+ type: 'typeB',
671
+ message: 'second',
672
+ source: 'source2',
673
+ timestamp: Date.now() + 1,
674
+ };
675
+ const msg3 = {
676
+ id: '3',
677
+ type: 'typeA',
678
+ message: 'third',
679
+ source: 'source1',
680
+ timestamp: Date.now() + 2,
681
+ };
682
+ act(() => {
683
+ channel.simulateMessage(msg1);
684
+ channel.simulateMessage(msg2);
685
+ channel.simulateMessage(msg3);
316
686
  });
687
+ expect(result.current.getLatestMessage({ type: 'typeA', source: 'source1' })).toEqual(
688
+ expect.objectContaining(msg3)
689
+ );
317
690
  });
318
691
 
319
- expect(result.current.messages.length).toBe(2);
692
+ it('returns null if no message matches the filter', async () => {
693
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
694
+ await waitForChannel();
695
+ const channel = mockChannels[0];
696
+ const msg1 = {
697
+ id: '1',
698
+ type: 'typeA',
699
+ message: 'first',
700
+ source: 'source1',
701
+ timestamp: Date.now(),
702
+ };
703
+ act(() => {
704
+ channel.simulateMessage(msg1);
705
+ });
706
+ expect(result.current.getLatestMessage({ type: 'typeB' })).toBeNull();
707
+ expect(result.current.getLatestMessage({ source: 'source2' })).toBeNull();
708
+ expect(result.current.getLatestMessage({ type: 'typeB', source: 'source2' })).toBeNull();
709
+ });
320
710
 
321
- const internalTypeAll = getInternalMessageType('CLEAR_SENT_MESSAGES', 'test-channel');
322
- act(() => {
323
- channel.simulateMessage({
324
- id: 'internal-2',
325
- type: internalTypeAll,
326
- message: { ids: [], types: [] },
327
- source: 'another-tab',
711
+ it('works with keepLatestMessage=true (only one message)', async () => {
712
+ const { result } = renderHook(() =>
713
+ useBroadcastChannel('test-channel', { keepLatestMessage: true })
714
+ );
715
+ await waitForChannel();
716
+ const channel = mockChannels[0];
717
+ const msg1 = {
718
+ id: '1',
719
+ type: 'typeA',
720
+ message: 'first',
721
+ source: 'source1',
328
722
  timestamp: Date.now(),
723
+ };
724
+ act(() => {
725
+ channel.simulateMessage(msg1);
329
726
  });
727
+ expect(result.current.getLatestMessage()).toEqual(expect.objectContaining(msg1));
330
728
  });
331
729
 
332
- expect(result.current.messages.length).toBe(0);
333
- });
730
+ it('returns the latest message in correct order (last one)', async () => {
731
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
732
+ await waitForChannel();
733
+ const channel = mockChannels[0];
734
+ const msg1 = {
735
+ id: '1',
736
+ type: 'typeA',
737
+ message: 'first',
738
+ source: 'source1',
739
+ timestamp: Date.now(),
740
+ };
741
+ const msg2 = {
742
+ id: '2',
743
+ type: 'typeA',
744
+ message: 'second',
745
+ source: 'source1',
746
+ timestamp: Date.now() + 1,
747
+ };
748
+ act(() => {
749
+ channel.simulateMessage(msg1);
750
+ channel.simulateMessage(msg2);
751
+ });
752
+ expect(result.current.getLatestMessage({ type: 'typeA', source: 'source1' })).toEqual(
753
+ expect.objectContaining(msg2)
754
+ );
755
+ });
334
756
 
335
- it('performs cleanup of expired messages', async () => {
336
- jest.useFakeTimers();
337
- const { result } = renderHook(() =>
338
- useBroadcastChannel('test-channel', { cleaningInterval: 1000 })
339
- );
340
- await waitForChannel();
341
- const channel = mockChannels[0];
342
-
343
- act(() => {
344
- channel.simulateMessage({
345
- id: 'expired-2',
346
- type: 'test',
347
- message: 'expired',
348
- source: 'another-tab',
349
- timestamp: Date.now() - 1000000,
350
- expirationDate: Date.now() - 1000000,
757
+ it('treats empty string filters as no filter (returns latest message)', async () => {
758
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
759
+ await waitForChannel();
760
+ const channel = mockChannels[0];
761
+ const msg1 = {
762
+ id: '1',
763
+ type: 'typeA',
764
+ message: 'first',
765
+ source: 'source1',
766
+ timestamp: 1000,
767
+ };
768
+ const msg2 = {
769
+ id: '2',
770
+ type: 'typeB',
771
+ message: 'second',
772
+ source: 'source2',
773
+ timestamp: 2000,
774
+ };
775
+ act(() => {
776
+ channel.simulateMessage(msg1);
777
+ channel.simulateMessage(msg2);
351
778
  });
779
+ expect(result.current.getLatestMessage({ type: '', source: '' })).toEqual(
780
+ expect.objectContaining(msg2)
781
+ );
352
782
  });
353
783
 
354
- act(() => {
355
- jest.advanceTimersByTime(1000);
784
+ it('returns the last message in array if timestamps are the same', async () => {
785
+ jest.spyOn(Date, 'now').mockReturnValue(1000);
786
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
787
+ await waitForChannel();
788
+ const channel = mockChannels[0];
789
+ const msg1 = {
790
+ id: '1',
791
+ type: 'typeA',
792
+ message: 'first',
793
+ source: 'source1',
794
+ timestamp: 1000,
795
+ };
796
+ const msg2 = {
797
+ id: '2',
798
+ type: 'typeA',
799
+ message: 'second',
800
+ source: 'source1',
801
+ timestamp: 1000,
802
+ };
803
+ act(() => {
804
+ channel.simulateMessage(msg1);
805
+ channel.simulateMessage(msg2);
806
+ });
807
+ expect(result.current.getLatestMessage({ type: 'typeA', source: 'source1' })).toEqual(
808
+ expect.objectContaining(msg2)
809
+ );
810
+ jest.spyOn(Date, 'now').mockRestore?.();
356
811
  });
357
812
 
358
- expect(result.current.messages.length).toBe(0);
359
- jest.useRealTimers();
813
+ it('returns the last matching message if multiple match the filters', async () => {
814
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
815
+ await waitForChannel();
816
+ const channel = mockChannels[0];
817
+ const msg1 = {
818
+ id: '1',
819
+ type: 'typeA',
820
+ message: 'first',
821
+ source: 'source1',
822
+ timestamp: 1000,
823
+ };
824
+ const msg2 = {
825
+ id: '2',
826
+ type: 'typeB',
827
+ message: 'second',
828
+ source: 'source2',
829
+ timestamp: 2000,
830
+ };
831
+ const msg3 = {
832
+ id: '3',
833
+ type: 'typeA',
834
+ message: 'third',
835
+ source: 'source1',
836
+ timestamp: 3000,
837
+ };
838
+ act(() => {
839
+ channel.simulateMessage(msg1);
840
+ channel.simulateMessage(msg2);
841
+ channel.simulateMessage(msg3);
842
+ });
843
+ // msg1 and msg3 both match typeA/source1 — expect msg3
844
+ expect(result.current.getLatestMessage({ type: 'typeA', source: 'source1' })).toEqual(
845
+ expect.objectContaining(msg3)
846
+ );
847
+ });
360
848
  });
361
849
 
362
- it('handles registered types filtering', async () => {
363
- const { result } = renderHook(() =>
364
- useBroadcastChannel('test-channel', { registeredTypes: ['allowed-type'] })
365
- );
366
- await waitForChannel();
367
- const channel = mockChannels[0];
368
-
369
- act(() => {
370
- channel.simulateMessage({
371
- id: 'unregistered',
372
- type: 'unregistered-type',
373
- message: 'test',
374
- source: 'another-tab',
375
- timestamp: Date.now(),
376
- });
850
+ describe('Channel Management', () => {
851
+ it('closes the BroadcastChannel and removes event listener', async () => {
852
+ const { unmount } = renderHook(() =>
853
+ useBroadcastChannel('test-channel', { sourceName: 'another-tab' })
854
+ );
855
+ await waitForChannel();
856
+ const channel = mockChannels[0];
857
+ expect(channel.close).not.toHaveBeenCalled();
858
+
859
+ unmount();
860
+ expect(channel.close).toHaveBeenCalled();
861
+ expect(channel.removeEventListener).toHaveBeenCalledWith('message', expect.any(Function));
377
862
  });
378
863
 
379
- expect(result.current.messages.length).toBe(0);
864
+ it('is idempotent (safe to call multiple times)', async () => {
865
+ const { result } = renderHook(() =>
866
+ useBroadcastChannel('test-channel', { sourceName: 'another-tab' })
867
+ );
868
+ await waitForChannel();
869
+ const channel = mockChannels[0];
380
870
 
381
- act(() => {
382
- channel.simulateMessage({
383
- id: 'registered',
384
- type: 'allowed-type',
385
- message: 'test',
386
- source: 'another-tab',
387
- timestamp: Date.now(),
871
+ act(() => {
872
+ result.current.closeChannel();
388
873
  });
389
- });
874
+ expect(channel.close).toHaveBeenCalledTimes(1);
390
875
 
391
- expect(result.current.messages.length).toBe(1);
392
- });
876
+ act(() => {
877
+ result.current.closeChannel();
878
+ });
879
+ // Should not throw or call close again
880
+ expect(channel.close).toHaveBeenCalledTimes(1);
881
+ });
393
882
 
394
- it('handles keepLatestMessage option', async () => {
395
- const { result } = renderHook(() =>
396
- useBroadcastChannel('test-channel', { keepLatestMessage: true })
397
- );
398
- await waitForChannel();
399
- const channel = mockChannels[0];
883
+ it('does not send messages after closeChannel is called', async () => {
884
+ const { result } = renderHook(() =>
885
+ useBroadcastChannel('test-channel', { sourceName: 'another-tab' })
886
+ );
887
+ await waitForChannel();
888
+ const channel = mockChannels[0];
400
889
 
401
- act(() => {
402
- channel.simulateMessage({
403
- id: 'msg1',
404
- type: 'test',
405
- message: 'first',
406
- source: 'another-tab',
407
- timestamp: Date.now(),
890
+ act(() => {
891
+ result.current.closeChannel();
408
892
  });
409
- channel.simulateMessage({
410
- id: 'msg2',
411
- type: 'test',
412
- message: 'second',
413
- source: 'another-tab',
414
- timestamp: Date.now(),
893
+
894
+ act(() => {
895
+ result.current.postMessage('test', { data: 'should not send' });
415
896
  });
897
+ expect(channel.postMessage).not.toHaveBeenCalledWith(
898
+ expect.objectContaining({ type: 'test' })
899
+ );
416
900
  });
417
901
 
418
- expect(result.current.messages.length).toBe(1);
419
- expect(result.current.messages[0].message).toBe('second');
902
+ it('does not receive messages after closeChannel is called', async () => {
903
+ const { result } = renderHook(() =>
904
+ useBroadcastChannel('test-channel', { sourceName: 'another-tab' })
905
+ );
906
+ await waitForChannel();
907
+ const channel = mockChannels[0];
908
+
909
+ act(() => {
910
+ result.current.closeChannel();
911
+ });
912
+
913
+ act(() => {
914
+ channel.simulateMessage({
915
+ id: 'should-not-receive',
916
+ type: 'test',
917
+ message: 'no',
918
+ source: 'external',
919
+ timestamp: Date.now(),
920
+ });
921
+ });
922
+ expect(result.current.messages).toHaveLength(0);
923
+ });
420
924
  });
421
925
 
422
- it('handles cleanup debounce', async () => {
423
- jest.useFakeTimers();
424
- const { result } = renderHook(() =>
425
- useBroadcastChannel('test-channel', {
426
- cleaningInterval: 1000,
427
- cleanupDebounceMs: 500,
428
- })
429
- );
430
- await waitForChannel();
431
- const channel = mockChannels[0];
432
-
433
- act(() => {
434
- channel.simulateMessage({
435
- id: 'expired-3',
436
- type: 'test',
437
- message: 'expired',
438
- source: 'another-tab',
439
- timestamp: Date.now() - 1000000,
440
- expirationDate: Date.now() - 1000000,
926
+ describe('Ping Functionality', () => {
927
+ beforeEach(() => {
928
+ jest.useFakeTimers();
929
+ });
930
+
931
+ afterEach(() => {
932
+ jest.useRealTimers();
933
+ });
934
+
935
+ it('should return empty array when BroadcastChannel is not available', async () => {
936
+ const original = global.BroadcastChannel;
937
+ delete (global as any).BroadcastChannel;
938
+
939
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
940
+
941
+ let sources: string[] = [];
942
+ await act(async () => {
943
+ sources = await result.current.ping();
441
944
  });
945
+
946
+ expect(sources).toEqual([]);
947
+ expect(result.current.error).toBe(
948
+ 'BroadcastChannel is not supported in this browser. Please check browser compatibility.'
949
+ );
950
+
951
+ global.BroadcastChannel = original;
442
952
  });
443
953
 
444
- act(() => {
445
- jest.advanceTimersByTime(1500);
954
+ it('should collect active sources within timeout period', async () => {
955
+ const { result: hook1 } = renderHook(() =>
956
+ useBroadcastChannel('test-channel', { sourceName: 'source-1' })
957
+ );
958
+ renderHook(() => useBroadcastChannel('test-channel', { sourceName: 'source-2' }));
959
+ renderHook(() => useBroadcastChannel('test-channel', { sourceName: 'source-3' }));
960
+
961
+ await waitForChannel();
962
+
963
+ let activeSources: string[] = [];
964
+ await act(async () => {
965
+ const pingPromise = hook1.current.ping(100);
966
+ await Promise.resolve();
967
+ jest.advanceTimersByTime(100);
968
+ activeSources = await pingPromise;
969
+ });
970
+
971
+ expect(activeSources).toHaveLength(2);
972
+ expect(activeSources).toContain('source-2');
973
+ expect(activeSources).toContain('source-3');
974
+ expect(activeSources).not.toContain('source-1');
446
975
  });
447
- await Promise.resolve();
448
976
 
449
- expect(result.current.messages.length).toBe(0);
450
- jest.useRealTimers();
451
- });
977
+ it('should respect custom timeout duration', async () => {
978
+ const { result: hook1 } = renderHook(() =>
979
+ useBroadcastChannel('test-channel', { sourceName: 'source-1' })
980
+ );
981
+ renderHook(() => useBroadcastChannel('test-channel', { sourceName: 'source-2' }));
982
+
983
+ await waitForChannel();
452
984
 
453
- it('handles error when posting message fails', async () => {
454
- const { result } = renderHook(() => useBroadcastChannel('test-channel'));
455
- await waitForChannel();
456
- const channel = mockChannels[0];
985
+ let activeSources: string[] = [];
986
+ await act(async () => {
987
+ const pingPromise = hook1.current.ping(500);
988
+ await Promise.resolve();
989
+ jest.advanceTimersByTime(500);
990
+ activeSources = await pingPromise;
991
+ });
457
992
 
458
- channel.postMessage.mockImplementationOnce(() => {
459
- throw new Error('Failed to post message');
993
+ expect(activeSources).toHaveLength(1);
994
+ expect(activeSources).toContain('source-2');
460
995
  });
461
996
 
462
- act(() => {
463
- result.current.postMessage('test', { data: 'test' });
997
+ it('should handle multiple concurrent pings', async () => {
998
+ const { result: hook1 } = renderHook(() =>
999
+ useBroadcastChannel('test-channel', { sourceName: 'source-1' })
1000
+ );
1001
+ const { result: hook2 } = renderHook(() =>
1002
+ useBroadcastChannel('test-channel', { sourceName: 'source-2' })
1003
+ );
1004
+
1005
+ await waitForChannel();
1006
+
1007
+ let sources1: string[] = [];
1008
+ let sources2: string[] = [];
1009
+ await act(async () => {
1010
+ const p1 = hook1.current.ping(200);
1011
+ const p2 = hook2.current.ping(200);
1012
+ await Promise.resolve();
1013
+ jest.advanceTimersByTime(201);
1014
+ [sources1, sources2] = await Promise.all([p1, p2]);
1015
+ });
1016
+
1017
+ expect(sources1).toHaveLength(1);
1018
+ expect(sources1).toContain('source-2');
1019
+ expect(sources2).toHaveLength(1);
1020
+ expect(sources2).toContain('source-1');
464
1021
  });
465
1022
 
466
- expect(result.current.error).toBe('Failed to send message');
467
- });
1023
+ it('should not collect responses after timeout', async () => {
1024
+ const { result: hook1 } = renderHook(() =>
1025
+ useBroadcastChannel('test-channel', { sourceName: 'source-1' })
1026
+ );
1027
+ renderHook(() => useBroadcastChannel('test-channel', { sourceName: 'source-2' }));
1028
+
1029
+ await waitForChannel();
1030
+ const hook1Channel = mockChannels[0];
1031
+
1032
+ let activeSources: string[] = [];
1033
+ await act(async () => {
1034
+ const pingPromise = hook1.current.ping(500);
1035
+ await Promise.resolve();
1036
+ // Simulate a late response after timeout
1037
+ jest.advanceTimersByTime(501);
1038
+ await Promise.resolve();
1039
+ hook1Channel.simulateMessage({
1040
+ id: 'late-pong',
1041
+ type: getInternalMessageType('PONG', 'test-channel'),
1042
+ message: null,
1043
+ source: 'source-3',
1044
+ timestamp: Date.now(),
1045
+ });
1046
+ activeSources = await pingPromise;
1047
+ });
468
1048
 
469
- it('handles error when processing message fails', async () => {
470
- jest.useFakeTimers();
471
- const { result } = renderHook(() => useBroadcastChannel('test-channel'));
472
- await waitForChannel();
473
- const channel = mockChannels[0];
474
-
475
- const badMessage: any = {};
476
- Object.defineProperty(badMessage, 'id', {
477
- get() {
478
- throw new Error('fail');
479
- },
480
- });
481
- badMessage.type = 'test';
482
- badMessage.source = 'z';
483
- badMessage.timestamp = Date.now();
484
-
485
- await act(async () => {
486
- channel.simulateMessage(badMessage);
487
- await Promise.resolve();
488
- });
489
- await Promise.resolve();
490
- expect(result.current.error).toBe('Error processing broadcast message');
491
- jest.useRealTimers();
492
- });
1049
+ expect(activeSources).toHaveLength(1);
1050
+ expect(activeSources).toContain('source-2');
1051
+ expect(activeSources).not.toContain('source-3');
1052
+ });
493
1053
 
494
- it('handles error when BroadcastChannel initialization fails', () => {
495
- const originalBroadcastChannel = global.BroadcastChannel;
496
- delete (global as any).BroadcastChannel;
1054
+ it('should handle closed channels during ping', async () => {
1055
+ const { result: hook1 } = renderHook(() =>
1056
+ useBroadcastChannel('test-channel', { sourceName: 'source-1' })
1057
+ );
1058
+ const { result: hook2 } = renderHook(() =>
1059
+ useBroadcastChannel('test-channel', { sourceName: 'source-2' })
1060
+ );
497
1061
 
498
- const { result } = renderHook(() => useBroadcastChannel('test-channel'));
1062
+ await waitForChannel();
499
1063
 
500
- expect(result.current.error).toBe(
501
- 'BroadcastChannel is not supported in this browser. Please check browser compatibility.'
502
- );
1064
+ act(() => {
1065
+ hook2.current.closeChannel();
1066
+ });
503
1067
 
504
- global.BroadcastChannel = originalBroadcastChannel;
505
- });
1068
+ let activeSources: string[] = [];
1069
+ await act(async () => {
1070
+ const pingPromise = hook1.current.ping(100);
1071
+ await Promise.resolve();
1072
+ jest.advanceTimersByTime(150);
1073
+ activeSources = await pingPromise;
1074
+ });
506
1075
 
507
- it('creates message with expiration duration', async () => {
508
- const { result } = renderHook(() => useBroadcastChannel('test-channel'));
509
- await waitForChannel();
1076
+ expect(activeSources).toHaveLength(0);
1077
+ });
1078
+
1079
+ it('should ignore second ping call if one is in progress', async () => {
1080
+ const { result } = renderHook(() => useBroadcastChannel('test-channel', { sourceName: 'A' }));
1081
+ renderHook(() => useBroadcastChannel('test-channel', { sourceName: 'B' }));
510
1082
 
511
- act(() => {
512
- result.current.postMessage('test', { data: 'test' }, { expirationDuration: 5000 });
1083
+ await waitForChannel();
1084
+
1085
+ let res1: string[] = [];
1086
+ let res2: string[] = [];
1087
+ await act(async () => {
1088
+ const p1 = result.current.ping(200);
1089
+ await Promise.resolve();
1090
+ const p2 = result.current.ping(200);
1091
+ jest.advanceTimersByTime(201);
1092
+ [res1, res2] = await Promise.all([p1, p2]);
1093
+ });
1094
+
1095
+ expect(res1).toContain('B');
1096
+ expect(res2).toHaveLength(0);
513
1097
  });
514
1098
 
515
- const sentMessage = result.current.sentMessages[0];
516
- expect(sentMessage.expirationDate).toBeDefined();
517
- expect(sentMessage.expirationDate).toBeGreaterThan(Date.now());
1099
+ it('should expose isPingInProgress state', async () => {
1100
+ const { result } = renderHook(() =>
1101
+ useBroadcastChannel('test-channel', { sourceName: 'source-1' })
1102
+ );
1103
+
1104
+ await waitForChannel();
1105
+
1106
+ expect(result.current.isPingInProgress).toBe(false);
1107
+
1108
+ let sources: string[] = [];
1109
+ await act(async () => {
1110
+ const pingPromise = result.current.ping(300);
1111
+ await Promise.resolve();
1112
+ expect(result.current.isPingInProgress).toBe(true);
1113
+ jest.advanceTimersByTime(301);
1114
+ sources = await pingPromise;
1115
+ });
1116
+
1117
+ expect(Array.isArray(sources)).toBe(true);
1118
+ expect(result.current.isPingInProgress).toBe(false);
1119
+ });
518
1120
  });
519
1121
  });