react-broadcast-sync 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## [1.2.1](https://github.com/IdanShalem/react-broadcast-sync/compare/v1.2.0...v1.2.1) (2025-06-11)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * added unit testing for same tab scenarios ([5e3df1d](https://github.com/IdanShalem/react-broadcast-sync/commit/5e3df1deab456565a09f0dfda84b21b526f157ba))
7
+
1
8
  # [1.2.0](https://github.com/IdanShalem/react-broadcast-sync/compare/v1.1.4...v1.2.0) (2025-06-08)
2
9
 
3
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-broadcast-sync",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "A lightweight React hook and provider for syncing state across browser tabs using BroadcastChannel API.",
5
5
  "main": "dist/index.cjs.js",
6
6
  "module": "dist/index.esm.js",
@@ -0,0 +1,981 @@
1
+ import { renderHook, act } from '@testing-library/react';
2
+ import { useBroadcastChannel } from '../hooks/useBroadcastChannel';
3
+
4
+ let mockChannels: any[] = [];
5
+
6
+ class MockBroadcastChannel {
7
+ name: string;
8
+ onmessage: ((event: MessageEvent) => void) | null = null;
9
+ postMessage = jest.fn((data: any) => {
10
+ // Simulate broadcasting to all channels with the same name
11
+ const targetChannels = mockChannels.filter(
12
+ channel => channel.name === this.name && channel !== this
13
+ );
14
+
15
+ targetChannels.forEach(channel => {
16
+ if (channel.onmessage) {
17
+ // Use setTimeout to simulate async behavior
18
+ setTimeout(() => {
19
+ channel.onmessage!({ data } as MessageEvent);
20
+ }, 0);
21
+ }
22
+ });
23
+ });
24
+ close = jest.fn();
25
+ addEventListener = jest.fn((event: string, callback: (event: MessageEvent) => void) => {
26
+ if (event === 'message') {
27
+ this.onmessage = callback;
28
+ }
29
+ });
30
+ removeEventListener = jest.fn();
31
+
32
+ constructor(name: string) {
33
+ this.name = name;
34
+ mockChannels.push(this);
35
+ }
36
+
37
+ simulateMessage(data: any) {
38
+ if (this.onmessage) {
39
+ setTimeout(() => {
40
+ this.onmessage!({ data } as MessageEvent);
41
+ }, 0);
42
+ }
43
+ }
44
+ }
45
+
46
+ const waitForAsync = async () => {
47
+ // Wait for all async operations to complete
48
+ await act(async () => {
49
+ await new Promise(resolve => setTimeout(resolve, 10));
50
+ });
51
+ };
52
+
53
+ beforeAll(() => {
54
+ global.BroadcastChannel = MockBroadcastChannel as any;
55
+ });
56
+
57
+ beforeEach(() => {
58
+ mockChannels = [];
59
+ jest.clearAllMocks();
60
+ });
61
+
62
+ afterEach(() => {
63
+ jest.clearAllMocks();
64
+ });
65
+
66
+ afterAll(() => {
67
+ delete (global as any).BroadcastChannel;
68
+ });
69
+
70
+ describe('useBroadcastChannel - Same Tab Multi-Hook Behavior', () => {
71
+ const CHANNEL_NAME = 'test-channel';
72
+
73
+ // Common options to disable cleanup intervals in tests
74
+ const testOptions = {
75
+ cleaningInterval: 0, // Disable auto cleanup to avoid act warnings
76
+ deduplicationTTL: 60000, // Long TTL for tests
77
+ };
78
+
79
+ describe('1. Two Hooks with Different Source Names', () => {
80
+ it('should allow hooks with different sources to receive messages from each other', async () => {
81
+ const { result: hook1 } = renderHook(() =>
82
+ useBroadcastChannel(CHANNEL_NAME, {
83
+ ...testOptions,
84
+ sourceName: 'source-1',
85
+ })
86
+ );
87
+
88
+ const { result: hook2 } = renderHook(() =>
89
+ useBroadcastChannel(CHANNEL_NAME, {
90
+ ...testOptions,
91
+ sourceName: 'source-2',
92
+ })
93
+ );
94
+
95
+ await waitForAsync();
96
+
97
+ // Hook 1 sends a message
98
+ act(() => {
99
+ hook1.current.postMessage('greeting', { from: 'hook1' });
100
+ });
101
+
102
+ await waitForAsync();
103
+
104
+ // Hook 2 should receive the message
105
+ expect(hook2.current.messages).toHaveLength(1);
106
+ expect(hook2.current.messages[0].message).toEqual({ from: 'hook1' });
107
+ expect(hook2.current.messages[0].source).toBe('source-1');
108
+
109
+ // Hook 2 sends a message
110
+ act(() => {
111
+ hook2.current.postMessage('response', { from: 'hook2' });
112
+ });
113
+
114
+ await waitForAsync();
115
+
116
+ // Hook 1 should receive the message
117
+ expect(hook1.current.messages).toHaveLength(1);
118
+ expect(hook1.current.messages[0].message).toEqual({ from: 'hook2' });
119
+ expect(hook1.current.messages[0].source).toBe('source-2');
120
+ });
121
+
122
+ it('should not deduplicate messages from different sources', async () => {
123
+ const { result: hook1 } = renderHook(() =>
124
+ useBroadcastChannel(CHANNEL_NAME, {
125
+ ...testOptions,
126
+ sourceName: 'source-1',
127
+ })
128
+ );
129
+
130
+ const { result: hook2 } = renderHook(() =>
131
+ useBroadcastChannel(CHANNEL_NAME, {
132
+ ...testOptions,
133
+ sourceName: 'source-2',
134
+ })
135
+ );
136
+
137
+ await waitForAsync();
138
+
139
+ // Both hooks send identical message content
140
+ act(() => {
141
+ hook1.current.postMessage('test', { data: 'same' });
142
+ hook2.current.postMessage('test', { data: 'same' });
143
+ });
144
+
145
+ await waitForAsync();
146
+
147
+ // Both hooks should receive messages from the other source
148
+ expect(hook1.current.messages).toHaveLength(1);
149
+ expect(hook2.current.messages).toHaveLength(1);
150
+ expect(hook1.current.messages[0].source).toBe('source-2');
151
+ expect(hook2.current.messages[0].source).toBe('source-1');
152
+ });
153
+ });
154
+
155
+ describe('2. Two Hooks with Same Source Name', () => {
156
+ it('should filter self-originated messages correctly with same source', async () => {
157
+ const { result: hook1 } = renderHook(() =>
158
+ useBroadcastChannel(CHANNEL_NAME, {
159
+ ...testOptions,
160
+ sourceName: 'same-source',
161
+ })
162
+ );
163
+
164
+ const { result: hook2 } = renderHook(() =>
165
+ useBroadcastChannel(CHANNEL_NAME, {
166
+ ...testOptions,
167
+ sourceName: 'same-source',
168
+ })
169
+ );
170
+
171
+ await waitForAsync();
172
+
173
+ // Hook 1 sends a message
174
+ act(() => {
175
+ hook1.current.postMessage('test', { from: 'hook1' });
176
+ });
177
+
178
+ await waitForAsync();
179
+
180
+ // Neither hook should receive the message (filtered as self-originated)
181
+ expect(hook1.current.messages).toHaveLength(0);
182
+ expect(hook2.current.messages).toHaveLength(0);
183
+
184
+ // Hook 1 should have the message in sentMessages
185
+ expect(hook1.current.sentMessages).toHaveLength(1);
186
+ expect(hook2.current.sentMessages).toHaveLength(0);
187
+ });
188
+
189
+ it('should handle cleanup properly on unmount with same source', async () => {
190
+ const { result: hook1, unmount: unmount1 } = renderHook(() =>
191
+ useBroadcastChannel(CHANNEL_NAME, {
192
+ ...testOptions,
193
+ sourceName: 'same-source',
194
+ })
195
+ );
196
+
197
+ const { result: hook2 } = renderHook(() =>
198
+ useBroadcastChannel(CHANNEL_NAME, {
199
+ ...testOptions,
200
+ sourceName: 'same-source',
201
+ })
202
+ );
203
+
204
+ await waitForAsync();
205
+
206
+ // Verify both hooks are working (channel name includes namespace suffix)
207
+ expect(hook1.current.channelName).toBe(`${CHANNEL_NAME}-`);
208
+ expect(hook2.current.channelName).toBe(`${CHANNEL_NAME}-`);
209
+
210
+ // Unmount hook1
211
+ unmount1();
212
+
213
+ // Hook2 should still work normally
214
+ act(() => {
215
+ hook2.current.postMessage('after-unmount', { test: true });
216
+ });
217
+
218
+ expect(hook2.current.sentMessages).toHaveLength(1);
219
+ expect(hook2.current.error).toBeNull();
220
+ });
221
+ });
222
+
223
+ describe('3. Deduplication in Same Tab', () => {
224
+ it('should deduplicate identical messages from same source', async () => {
225
+ const { result: hook1 } = renderHook(() =>
226
+ useBroadcastChannel(CHANNEL_NAME, {
227
+ ...testOptions,
228
+ sourceName: 'test-source',
229
+ })
230
+ );
231
+
232
+ const { result: hook2 } = renderHook(() =>
233
+ useBroadcastChannel(CHANNEL_NAME, {
234
+ ...testOptions,
235
+ sourceName: 'test-source',
236
+ })
237
+ );
238
+
239
+ await waitForAsync();
240
+
241
+ // Simulate external message with same ID arriving multiple times
242
+ const duplicateMessage = {
243
+ id: 'duplicate-id',
244
+ type: 'test',
245
+ message: { data: 'test' },
246
+ source: 'external-source',
247
+ timestamp: Date.now(),
248
+ };
249
+
250
+ act(() => {
251
+ // Simulate the same message to both channels
252
+ mockChannels.forEach(channel => {
253
+ channel.simulateMessage(duplicateMessage);
254
+ channel.simulateMessage(duplicateMessage); // Send duplicate
255
+ });
256
+ });
257
+
258
+ await waitForAsync();
259
+
260
+ // Should only receive the message once due to deduplication
261
+ expect(hook1.current.messages).toHaveLength(1);
262
+ expect(hook2.current.messages).toHaveLength(1);
263
+ });
264
+ });
265
+
266
+ describe('4. Cleanup on Unmount', () => {
267
+ it('should not interfere with other hooks when one unmounts', async () => {
268
+ const { result: hook1, unmount: unmount1 } = renderHook(() =>
269
+ useBroadcastChannel(CHANNEL_NAME, {
270
+ ...testOptions,
271
+ sourceName: 'source-1',
272
+ })
273
+ );
274
+
275
+ const { result: hook2 } = renderHook(() =>
276
+ useBroadcastChannel(CHANNEL_NAME, {
277
+ ...testOptions,
278
+ sourceName: 'source-2',
279
+ })
280
+ );
281
+
282
+ await waitForAsync();
283
+
284
+ // Verify both hooks work initially
285
+ act(() => {
286
+ hook1.current.postMessage('test', { from: 'hook1' });
287
+ });
288
+
289
+ await waitForAsync();
290
+ expect(hook2.current.messages).toHaveLength(1);
291
+
292
+ // Unmount hook1
293
+ unmount1();
294
+
295
+ // Hook2 should continue working normally
296
+ act(() => {
297
+ hook2.current.postMessage('after-unmount', { from: 'hook2' });
298
+ });
299
+
300
+ expect(hook2.current.sentMessages).toHaveLength(1);
301
+ });
302
+ });
303
+
304
+ describe('5. Different keepLatestMessage Values', () => {
305
+ it('should handle different keepLatestMessage settings correctly', async () => {
306
+ const { result: hookWithHistory } = renderHook(() =>
307
+ useBroadcastChannel(CHANNEL_NAME, {
308
+ ...testOptions,
309
+ sourceName: 'source-history',
310
+ keepLatestMessage: false,
311
+ })
312
+ );
313
+
314
+ const { result: hookLatestOnly } = renderHook(() =>
315
+ useBroadcastChannel(CHANNEL_NAME, {
316
+ ...testOptions,
317
+ sourceName: 'source-latest',
318
+ keepLatestMessage: true,
319
+ })
320
+ );
321
+
322
+ await waitForAsync();
323
+
324
+ // Send multiple messages from external source
325
+ const externalMessages = [
326
+ {
327
+ id: 'msg-1',
328
+ type: 'test',
329
+ message: { count: 1 },
330
+ source: 'external',
331
+ timestamp: Date.now(),
332
+ },
333
+ {
334
+ id: 'msg-2',
335
+ type: 'test',
336
+ message: { count: 2 },
337
+ source: 'external',
338
+ timestamp: Date.now() + 1,
339
+ },
340
+ {
341
+ id: 'msg-3',
342
+ type: 'test',
343
+ message: { count: 3 },
344
+ source: 'external',
345
+ timestamp: Date.now() + 2,
346
+ },
347
+ ];
348
+
349
+ for (const msg of externalMessages) {
350
+ act(() => {
351
+ mockChannels.forEach(channel => {
352
+ channel.simulateMessage(msg);
353
+ });
354
+ });
355
+ await waitForAsync();
356
+ }
357
+
358
+ // Hook with history should have all messages
359
+ expect(hookWithHistory.current.messages).toHaveLength(3);
360
+
361
+ // Hook with keepLatestMessage should only have the last message
362
+ expect(hookLatestOnly.current.messages).toHaveLength(1);
363
+ expect(hookLatestOnly.current.messages[0].message.count).toBe(3);
364
+ });
365
+ });
366
+
367
+ describe('6. Different registeredTypes with Same Source', () => {
368
+ it('should filter messages based on registeredTypes independently', async () => {
369
+ const { result: hookTypeA } = renderHook(() =>
370
+ useBroadcastChannel(CHANNEL_NAME, {
371
+ ...testOptions,
372
+ sourceName: 'test-source',
373
+ registeredTypes: ['typeA'],
374
+ })
375
+ );
376
+
377
+ const { result: hookTypeB } = renderHook(() =>
378
+ useBroadcastChannel(CHANNEL_NAME, {
379
+ ...testOptions,
380
+ sourceName: 'test-source',
381
+ registeredTypes: ['typeB'],
382
+ })
383
+ );
384
+
385
+ await waitForAsync();
386
+
387
+ // Send messages of different types
388
+ const messages = [
389
+ {
390
+ id: 'msg-a',
391
+ type: 'typeA',
392
+ message: { data: 'for-a' },
393
+ source: 'external',
394
+ timestamp: Date.now(),
395
+ },
396
+ {
397
+ id: 'msg-b',
398
+ type: 'typeB',
399
+ message: { data: 'for-b' },
400
+ source: 'external',
401
+ timestamp: Date.now(),
402
+ },
403
+ {
404
+ id: 'msg-c',
405
+ type: 'typeC',
406
+ message: { data: 'for-neither' },
407
+ source: 'external',
408
+ timestamp: Date.now(),
409
+ },
410
+ ];
411
+
412
+ for (const msg of messages) {
413
+ act(() => {
414
+ mockChannels.forEach(channel => {
415
+ channel.simulateMessage(msg);
416
+ });
417
+ });
418
+ await waitForAsync();
419
+ }
420
+
421
+ // Each hook should only receive messages of its registered type
422
+ expect(hookTypeA.current.messages).toHaveLength(1);
423
+ expect(hookTypeA.current.messages[0].type).toBe('typeA');
424
+
425
+ expect(hookTypeB.current.messages).toHaveLength(1);
426
+ expect(hookTypeB.current.messages[0].type).toBe('typeB');
427
+ });
428
+ });
429
+
430
+ describe('7. Same Source, Same Type, Multiple Listeners', () => {
431
+ it('should handle multiple hooks with same source and overlapping types', async () => {
432
+ const { result: hook1 } = renderHook(() =>
433
+ useBroadcastChannel(CHANNEL_NAME, {
434
+ ...testOptions,
435
+ sourceName: 'same-source',
436
+ registeredTypes: ['shared-type'],
437
+ })
438
+ );
439
+
440
+ const { result: hook2 } = renderHook(() =>
441
+ useBroadcastChannel(CHANNEL_NAME, {
442
+ ...testOptions,
443
+ sourceName: 'same-source',
444
+ registeredTypes: ['shared-type'],
445
+ })
446
+ );
447
+
448
+ await waitForAsync();
449
+
450
+ // Send a message from external source
451
+ const message = {
452
+ id: 'shared-msg',
453
+ type: 'shared-type',
454
+ message: { data: 'shared' },
455
+ source: 'external',
456
+ timestamp: Date.now(),
457
+ };
458
+
459
+ act(() => {
460
+ mockChannels.forEach(channel => {
461
+ channel.simulateMessage(message);
462
+ });
463
+ });
464
+
465
+ await waitForAsync();
466
+
467
+ // Both hooks should receive the message
468
+ expect(hook1.current.messages).toHaveLength(1);
469
+ expect(hook2.current.messages).toHaveLength(1);
470
+ expect(hook1.current.messages[0].message).toEqual({ data: 'shared' });
471
+ expect(hook2.current.messages[0].message).toEqual({ data: 'shared' });
472
+ });
473
+ });
474
+
475
+ describe('8. Same Source, One with registeredTypes, One Without', () => {
476
+ it('should handle mixed type registration correctly', async () => {
477
+ const { result: hookSpecific } = renderHook(() =>
478
+ useBroadcastChannel(CHANNEL_NAME, {
479
+ ...testOptions,
480
+ sourceName: 'test-source',
481
+ registeredTypes: ['specific-type'],
482
+ })
483
+ );
484
+
485
+ const { result: hookAll } = renderHook(() =>
486
+ useBroadcastChannel(CHANNEL_NAME, {
487
+ ...testOptions,
488
+ sourceName: 'test-source',
489
+ registeredTypes: [], // Empty array means listen to all
490
+ })
491
+ );
492
+
493
+ await waitForAsync();
494
+
495
+ // Send messages of different types
496
+ const messages = [
497
+ {
498
+ id: 'msg-specific',
499
+ type: 'specific-type',
500
+ message: { data: 'specific' },
501
+ source: 'external',
502
+ timestamp: Date.now(),
503
+ },
504
+ {
505
+ id: 'msg-other',
506
+ type: 'other-type',
507
+ message: { data: 'other' },
508
+ source: 'external',
509
+ timestamp: Date.now(),
510
+ },
511
+ ];
512
+
513
+ for (const msg of messages) {
514
+ act(() => {
515
+ mockChannels.forEach(channel => {
516
+ channel.simulateMessage(msg);
517
+ });
518
+ });
519
+ await waitForAsync();
520
+ }
521
+
522
+ // Specific hook should only receive specific-type
523
+ expect(hookSpecific.current.messages).toHaveLength(1);
524
+ expect(hookSpecific.current.messages[0].type).toBe('specific-type');
525
+
526
+ // Hook listening to all should receive both
527
+ expect(hookAll.current.messages).toHaveLength(2);
528
+ });
529
+ });
530
+
531
+ describe('9. Rapid Message Burst Between Hooks', () => {
532
+ it('should handle rapid message bursts correctly', async () => {
533
+ const { result: sender } = renderHook(() =>
534
+ useBroadcastChannel(CHANNEL_NAME, {
535
+ ...testOptions,
536
+ sourceName: 'sender',
537
+ })
538
+ );
539
+
540
+ const { result: receiver } = renderHook(() =>
541
+ useBroadcastChannel(CHANNEL_NAME, {
542
+ ...testOptions,
543
+ sourceName: 'receiver',
544
+ })
545
+ );
546
+
547
+ await waitForAsync();
548
+
549
+ // Send rapid burst of messages
550
+ act(() => {
551
+ for (let i = 0; i < 10; i++) {
552
+ sender.current.postMessage('burst', { count: i });
553
+ }
554
+ });
555
+
556
+ await waitForAsync();
557
+
558
+ // Receiver should get all messages
559
+ expect(receiver.current.messages).toHaveLength(10);
560
+ expect(sender.current.sentMessages).toHaveLength(10);
561
+ });
562
+
563
+ it('should respect deduplication during rapid bursts', async () => {
564
+ const { result: hook } = renderHook(() =>
565
+ useBroadcastChannel(CHANNEL_NAME, {
566
+ ...testOptions,
567
+ sourceName: 'test-source',
568
+ })
569
+ );
570
+
571
+ await waitForAsync();
572
+
573
+ // Simulate rapid duplicate messages
574
+ const duplicateMessage = {
575
+ id: 'rapid-duplicate',
576
+ type: 'test',
577
+ message: { data: 'duplicate' },
578
+ source: 'external',
579
+ timestamp: Date.now(),
580
+ };
581
+
582
+ act(() => {
583
+ for (let i = 0; i < 5; i++) {
584
+ mockChannels.forEach(channel => {
585
+ channel.simulateMessage(duplicateMessage);
586
+ });
587
+ }
588
+ });
589
+
590
+ await waitForAsync();
591
+
592
+ // Should only receive one message due to deduplication
593
+ expect(hook.current.messages).toHaveLength(1);
594
+ });
595
+ });
596
+
597
+ describe('10. Source Reinitialization with Same Name', () => {
598
+ it('should handle remounting with same source name cleanly', async () => {
599
+ const { result: hook1, unmount } = renderHook(() =>
600
+ useBroadcastChannel(CHANNEL_NAME, {
601
+ ...testOptions,
602
+ sourceName: 'reusable-source',
603
+ keepLatestMessage: false,
604
+ })
605
+ );
606
+
607
+ await waitForAsync();
608
+
609
+ // Send initial message
610
+ act(() => {
611
+ hook1.current.postMessage('initial', { phase: 1 });
612
+ });
613
+
614
+ expect(hook1.current.sentMessages).toHaveLength(1);
615
+
616
+ // Unmount and remount with different config
617
+ unmount();
618
+
619
+ const { result: hook2 } = renderHook(() =>
620
+ useBroadcastChannel(CHANNEL_NAME, {
621
+ ...testOptions,
622
+ sourceName: 'reusable-source',
623
+ keepLatestMessage: true,
624
+ })
625
+ );
626
+
627
+ await waitForAsync();
628
+
629
+ // New instance should start fresh
630
+ expect(hook2.current.sentMessages).toHaveLength(0);
631
+ expect(hook2.current.messages).toHaveLength(0);
632
+
633
+ // Should work normally
634
+ act(() => {
635
+ hook2.current.postMessage('after-remount', { phase: 2 });
636
+ });
637
+
638
+ expect(hook2.current.sentMessages).toHaveLength(1);
639
+ });
640
+ });
641
+
642
+ describe('11. Hook Sends Message to Itself', () => {
643
+ it('should correctly handle self-originated messages', async () => {
644
+ const { result: hook } = renderHook(() =>
645
+ useBroadcastChannel(CHANNEL_NAME, {
646
+ ...testOptions,
647
+ sourceName: 'self-sender',
648
+ registeredTypes: ['self-type'],
649
+ })
650
+ );
651
+
652
+ await waitForAsync();
653
+
654
+ // Hook sends a message that matches its own registered types
655
+ act(() => {
656
+ hook.current.postMessage('self-type', { self: true });
657
+ });
658
+
659
+ await waitForAsync();
660
+
661
+ // Should not receive its own message
662
+ expect(hook.current.messages).toHaveLength(0);
663
+ // But should have it in sent messages
664
+ expect(hook.current.sentMessages).toHaveLength(1);
665
+ expect(hook.current.sentMessages[0].type).toBe('self-type');
666
+ });
667
+ });
668
+
669
+ describe('Additional Edge Cases', () => {
670
+ it('should handle clearMessagesByType coordination in same tab', async () => {
671
+ const { result: hook1 } = renderHook(() =>
672
+ useBroadcastChannel(CHANNEL_NAME, {
673
+ ...testOptions,
674
+ sourceName: 'source-1',
675
+ })
676
+ );
677
+
678
+ const { result: hook2 } = renderHook(() =>
679
+ useBroadcastChannel(CHANNEL_NAME, {
680
+ ...testOptions,
681
+ sourceName: 'source-2',
682
+ })
683
+ );
684
+
685
+ await waitForAsync();
686
+
687
+ // Send messages of different types from different sources
688
+ act(() => {
689
+ hook1.current.postMessage('typeA', { from: 'hook1' });
690
+ hook1.current.postMessage('typeB', { from: 'hook1' });
691
+ });
692
+
693
+ await waitForAsync();
694
+
695
+ // Both messages should be received by hook2
696
+ expect(hook2.current.messages).toHaveLength(2);
697
+
698
+ // Clear specific type from hook2
699
+ act(() => {
700
+ hook2.current.clearReceivedMessages({ types: ['typeA'] });
701
+ });
702
+
703
+ // Hook2 should only have typeB message left
704
+ expect(hook2.current.messages).toHaveLength(1);
705
+ expect(hook2.current.messages[0].type).toBe('typeB');
706
+
707
+ // Hook1's messages should be unaffected
708
+ expect(hook1.current.sentMessages).toHaveLength(2);
709
+ });
710
+
711
+ it('should handle namespace isolation correctly', async () => {
712
+ const { result: hook1 } = renderHook(() =>
713
+ useBroadcastChannel(CHANNEL_NAME, {
714
+ ...testOptions,
715
+ sourceName: 'source-1',
716
+ namespace: 'ns1',
717
+ })
718
+ );
719
+
720
+ const { result: hook2 } = renderHook(() =>
721
+ useBroadcastChannel(CHANNEL_NAME, {
722
+ ...testOptions,
723
+ sourceName: 'source-2',
724
+ namespace: 'ns2',
725
+ })
726
+ );
727
+
728
+ await waitForAsync();
729
+
730
+ // Hooks should be on different resolved channels
731
+ expect(hook1.current.channelName).toBe(`${CHANNEL_NAME}-ns1`);
732
+ expect(hook2.current.channelName).toBe(`${CHANNEL_NAME}-ns2`);
733
+
734
+ // Messages should not cross namespaces
735
+ act(() => {
736
+ hook1.current.postMessage('test', { from: 'hook1' });
737
+ });
738
+
739
+ await waitForAsync();
740
+
741
+ // Hook2 should not receive the message (different namespace)
742
+ expect(hook2.current.messages).toHaveLength(0);
743
+ expect(hook1.current.sentMessages).toHaveLength(1);
744
+ });
745
+
746
+ it('should handle different keepLatestMessage settings with same source name', async () => {
747
+ const { result: hookWithHistory } = renderHook(() =>
748
+ useBroadcastChannel(CHANNEL_NAME, {
749
+ ...testOptions,
750
+ sourceName: 'same-source',
751
+ keepLatestMessage: false,
752
+ })
753
+ );
754
+
755
+ const { result: hookLatestOnly } = renderHook(() =>
756
+ useBroadcastChannel(CHANNEL_NAME, {
757
+ ...testOptions,
758
+ sourceName: 'same-source',
759
+ keepLatestMessage: true,
760
+ })
761
+ );
762
+
763
+ await waitForAsync();
764
+
765
+ // Send multiple messages from external source
766
+ const externalMessages = [
767
+ {
768
+ id: 'msg-1',
769
+ type: 'test',
770
+ message: { count: 1 },
771
+ source: 'external',
772
+ timestamp: Date.now(),
773
+ },
774
+ {
775
+ id: 'msg-2',
776
+ type: 'test',
777
+ message: { count: 2 },
778
+ source: 'external',
779
+ timestamp: Date.now() + 1,
780
+ },
781
+ {
782
+ id: 'msg-3',
783
+ type: 'test',
784
+ message: { count: 3 },
785
+ source: 'external',
786
+ timestamp: Date.now() + 2,
787
+ },
788
+ ];
789
+
790
+ for (const msg of externalMessages) {
791
+ act(() => {
792
+ mockChannels.forEach(channel => {
793
+ channel.simulateMessage(msg);
794
+ });
795
+ });
796
+ await waitForAsync();
797
+ }
798
+
799
+ // Hook with history should have all messages
800
+ expect(hookWithHistory.current.messages).toHaveLength(3);
801
+
802
+ // Hook with keepLatestMessage should only have the last message
803
+ expect(hookLatestOnly.current.messages).toHaveLength(1);
804
+ expect(hookLatestOnly.current.messages[0].message.count).toBe(3);
805
+ });
806
+
807
+ it('should explicitly block self-originated messages even with matching registered types', async () => {
808
+ const { result: hook } = renderHook(() =>
809
+ useBroadcastChannel(CHANNEL_NAME, {
810
+ ...testOptions,
811
+ sourceName: 'self-sender',
812
+ registeredTypes: ['self-type', 'another-type'],
813
+ })
814
+ );
815
+
816
+ await waitForAsync();
817
+
818
+ // Hook sends messages that match its own registered types
819
+ act(() => {
820
+ hook.current.postMessage('self-type', { self: true });
821
+ hook.current.postMessage('another-type', { self: true });
822
+ });
823
+
824
+ await waitForAsync();
825
+
826
+ // Should not receive its own messages despite matching registered types
827
+ expect(hook.current.messages).toHaveLength(0);
828
+
829
+ // But should have them in sent messages
830
+ expect(hook.current.sentMessages).toHaveLength(2);
831
+ expect(hook.current.sentMessages[0].source).toBe('self-sender');
832
+ expect(hook.current.sentMessages[1].source).toBe('self-sender');
833
+
834
+ // Verify that source === sourceName is the blocking condition
835
+ expect(hook.current.sentMessages.every(msg => msg.source === 'self-sender')).toBe(true);
836
+ });
837
+
838
+ it('should handle combined config differences with same source name', async () => {
839
+ const { result: hookSpecificLatest } = renderHook(() =>
840
+ useBroadcastChannel(CHANNEL_NAME, {
841
+ ...testOptions,
842
+ sourceName: 'shared-source',
843
+ registeredTypes: ['typeA'],
844
+ keepLatestMessage: true,
845
+ })
846
+ );
847
+
848
+ const { result: hookAllHistory } = renderHook(() =>
849
+ useBroadcastChannel(CHANNEL_NAME, {
850
+ ...testOptions,
851
+ sourceName: 'shared-source',
852
+ registeredTypes: [], // Listen to all types
853
+ keepLatestMessage: false,
854
+ })
855
+ );
856
+
857
+ await waitForAsync();
858
+
859
+ // Send multiple messages of different types
860
+ const messages = [
861
+ {
862
+ id: 'msg-a1',
863
+ type: 'typeA',
864
+ message: { data: 'a1' },
865
+ source: 'external',
866
+ timestamp: Date.now(),
867
+ },
868
+ {
869
+ id: 'msg-b1',
870
+ type: 'typeB',
871
+ message: { data: 'b1' },
872
+ source: 'external',
873
+ timestamp: Date.now() + 1,
874
+ },
875
+ {
876
+ id: 'msg-a2',
877
+ type: 'typeA',
878
+ message: { data: 'a2' },
879
+ source: 'external',
880
+ timestamp: Date.now() + 2,
881
+ },
882
+ ];
883
+
884
+ for (const msg of messages) {
885
+ act(() => {
886
+ mockChannels.forEach(channel => {
887
+ channel.simulateMessage(msg);
888
+ });
889
+ });
890
+ await waitForAsync();
891
+ }
892
+
893
+ // Hook with specific type + keepLatest should only have latest typeA
894
+ expect(hookSpecificLatest.current.messages).toHaveLength(1);
895
+ expect(hookSpecificLatest.current.messages[0].type).toBe('typeA');
896
+ expect(hookSpecificLatest.current.messages[0].message.data).toBe('a2');
897
+
898
+ // Hook listening to all + history should have all messages
899
+ expect(hookAllHistory.current.messages).toHaveLength(3);
900
+ expect(hookAllHistory.current.messages.map(m => m.type)).toEqual(['typeA', 'typeB', 'typeA']);
901
+ });
902
+
903
+ it('should respect deduplication TTL for duplicate message IDs', async () => {
904
+ const { result: hook } = renderHook(() =>
905
+ useBroadcastChannel(CHANNEL_NAME, {
906
+ ...testOptions,
907
+ deduplicationTTL: 1000, // 1 second TTL
908
+ })
909
+ );
910
+
911
+ await waitForAsync();
912
+
913
+ // Send the same message multiple times within TTL window
914
+ const duplicateMessage = {
915
+ id: 'duplicate-id',
916
+ type: 'test',
917
+ message: { data: 'duplicate' },
918
+ source: 'external',
919
+ timestamp: Date.now(),
920
+ };
921
+
922
+ act(() => {
923
+ mockChannels.forEach(channel => {
924
+ channel.simulateMessage(duplicateMessage);
925
+ channel.simulateMessage(duplicateMessage); // Immediate duplicate
926
+ });
927
+ });
928
+
929
+ await waitForAsync();
930
+
931
+ // Should only receive one message due to deduplication
932
+ expect(hook.current.messages).toHaveLength(1);
933
+ expect(hook.current.messages[0].message.data).toBe('duplicate');
934
+ });
935
+
936
+ it('should drop messages that just cross expiration boundary', async () => {
937
+ const { result: hook } = renderHook(() =>
938
+ useBroadcastChannel(CHANNEL_NAME, {
939
+ ...testOptions,
940
+ })
941
+ );
942
+
943
+ await waitForAsync();
944
+
945
+ const now = Date.now();
946
+
947
+ // Message that just expired
948
+ const expiredMessage = {
949
+ id: 'just-expired',
950
+ type: 'test',
951
+ message: { data: 'expired' },
952
+ source: 'external',
953
+ timestamp: now - 1000,
954
+ expirationDate: now - 10, // Expired 10ms ago (more buffer to avoid timing issues)
955
+ };
956
+
957
+ // Message that's still valid
958
+ const validMessage = {
959
+ id: 'still-valid',
960
+ type: 'test',
961
+ message: { data: 'valid' },
962
+ source: 'external',
963
+ timestamp: now - 1000,
964
+ expirationDate: now + 5000, // Expires in 5 seconds (more buffer)
965
+ };
966
+
967
+ act(() => {
968
+ mockChannels.forEach(channel => {
969
+ channel.simulateMessage(expiredMessage);
970
+ channel.simulateMessage(validMessage);
971
+ });
972
+ });
973
+
974
+ await waitForAsync();
975
+
976
+ // Should only receive the valid message
977
+ expect(hook.current.messages).toHaveLength(1);
978
+ expect(hook.current.messages[0].message.data).toBe('valid');
979
+ });
980
+ });
981
+ });