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