react-broadcast-sync 1.2.0 → 1.3.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,4 +1,4 @@
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
 
@@ -6,15 +6,30 @@ let mockChannels: any[] = [];
6
6
 
7
7
  class MockBroadcastChannel {
8
8
  name: string;
9
- onmessage: ((event: MessageEvent) => void) | null = null;
9
+ _onmessage: ((event: MessageEvent) => void) | null = null;
10
+ closed = false;
10
11
  postMessage = jest.fn();
11
- close = jest.fn();
12
+ close = jest.fn(() => {
13
+ this.closed = true;
14
+ this._onmessage = null;
15
+ });
12
16
  addEventListener = jest.fn((event: string, callback: (event: MessageEvent) => void) => {
13
17
  if (event === 'message') {
14
- this.onmessage = callback;
18
+ this._onmessage = callback;
19
+ }
20
+ });
21
+ removeEventListener = jest.fn((event: string, callback: (event: MessageEvent) => void) => {
22
+ if (event === 'message' && this._onmessage === callback) {
23
+ this._onmessage = null;
15
24
  }
16
25
  });
17
- removeEventListener = jest.fn();
26
+
27
+ get onmessage() {
28
+ return this._onmessage;
29
+ }
30
+ set onmessage(fn) {
31
+ this._onmessage = fn;
32
+ }
18
33
 
19
34
  constructor(name: string) {
20
35
  this.name = name;
@@ -22,9 +37,8 @@ class MockBroadcastChannel {
22
37
  }
23
38
 
24
39
  simulateMessage(data: any) {
25
- if (this.onmessage) {
26
- this.onmessage({ data } as MessageEvent);
27
- }
40
+ if (this.closed || !this._onmessage) return;
41
+ this._onmessage({ data } as MessageEvent);
28
42
  }
29
43
  }
30
44
 
@@ -65,22 +79,17 @@ describe('useBroadcastChannel', () => {
65
79
  await waitForChannel();
66
80
  const channel = mockChannels[0];
67
81
 
68
- act(() => {
82
+ await act(async () => {
69
83
  result.current.postMessage('greeting', { text: 'hi' });
70
- });
71
-
72
- const incomingMessage = {
73
- id: 'external-1',
74
- type: 'greeting',
75
- message: { text: 'hi' },
76
- source: 'another-tab',
77
- timestamp: Date.now(),
78
- };
79
-
80
- act(() => {
84
+ const incomingMessage = {
85
+ id: 'external-1',
86
+ type: 'greeting',
87
+ message: { text: 'hi' },
88
+ source: 'another-tab',
89
+ timestamp: Date.now(),
90
+ };
81
91
  channel.simulateMessage(incomingMessage);
82
92
  });
83
-
84
93
  expect(result.current.messages.length).toBe(1);
85
94
  expect(result.current.messages[0].message).toEqual({ text: 'hi' });
86
95
  });
@@ -265,30 +274,38 @@ describe('useBroadcastChannel', () => {
265
274
 
266
275
  it('handles internal clear message (single id) from other source', async () => {
267
276
  const { result } = renderHook(() => useBroadcastChannel('test-channel'));
277
+
268
278
  await waitForChannel();
279
+
269
280
  const channel = mockChannels[0];
281
+ const externalSource = 'external-source';
282
+
283
+ const msg = {
284
+ id: 'to-clear',
285
+ type: 'test',
286
+ message: 'should be removed',
287
+ timestamp: Date.now(),
288
+ source: externalSource,
289
+ };
290
+
291
+ const internalClear = {
292
+ id: 'internal-id',
293
+ type: getInternalMessageType('CLEAR_SENT_MESSAGES', 'test-channel'),
294
+ message: { ids: ['to-clear'], types: [] },
295
+ timestamp: Date.now(),
296
+ source: externalSource,
297
+ };
270
298
 
271
299
  act(() => {
272
- channel.simulateMessage({
273
- id: 'to-clear',
274
- type: 'test',
275
- message: 'will be cleared',
276
- source: 'another-tab',
277
- timestamp: Date.now(),
278
- });
300
+ channel.simulateMessage(msg);
279
301
  });
280
302
 
303
+ // await waitFor(() => {
281
304
  expect(result.current.messages.length).toBe(1);
305
+ // });
282
306
 
283
- const internalType = getInternalMessageType('CLEAR_SENT_MESSAGES', 'test-channel');
284
307
  act(() => {
285
- channel.simulateMessage({
286
- id: 'internal-1',
287
- type: internalType,
288
- message: { ids: ['to-clear'], types: [] },
289
- source: 'another-tab',
290
- timestamp: Date.now(),
291
- });
308
+ channel.simulateMessage(internalClear);
292
309
  });
293
310
 
294
311
  expect(result.current.messages.length).toBe(0);
@@ -296,40 +313,58 @@ describe('useBroadcastChannel', () => {
296
313
 
297
314
  it('handles internal clear all messages (wildcard) from other source', async () => {
298
315
  const { result } = renderHook(() => useBroadcastChannel('test-channel'));
316
+
299
317
  await waitForChannel();
318
+
300
319
  const channel = mockChannels[0];
320
+ const externalSource = 'external-source';
301
321
 
302
- act(() => {
303
- channel.simulateMessage({
304
- id: 'msg1',
305
- type: 'test1',
306
- message: 'first',
307
- source: 'another-tab',
308
- timestamp: Date.now(),
309
- });
310
- channel.simulateMessage({
311
- id: 'msg2',
312
- type: 'test2',
313
- message: 'second',
314
- source: 'another-tab',
315
- timestamp: Date.now(),
316
- });
322
+ const msg1 = {
323
+ id: 'msg1',
324
+ type: 'type1',
325
+ message: 'first',
326
+ timestamp: Date.now(),
327
+ source: externalSource,
328
+ };
329
+
330
+ const msg2 = {
331
+ id: 'msg2',
332
+ type: 'type2',
333
+ message: 'second',
334
+ timestamp: Date.now(),
335
+ source: externalSource,
336
+ };
337
+
338
+ const internalClear = {
339
+ id: 'internal-id',
340
+ type: getInternalMessageType('CLEAR_SENT_MESSAGES', 'test-channel'),
341
+ message: { ids: [], types: [] }, // wildcard
342
+ timestamp: Date.now(),
343
+ source: externalSource, // ✅ MUST match msg1/msg2
344
+ };
345
+
346
+ await act(async () => {
347
+ channel.simulateMessage(msg1);
348
+ });
349
+ await act(async () => {
350
+ channel.simulateMessage(msg2);
317
351
  });
318
352
 
319
- expect(result.current.messages.length).toBe(2);
353
+ await act(() => Promise.resolve());
320
354
 
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',
328
- timestamp: Date.now(),
329
- });
355
+ await waitFor(() => {
356
+ expect(result.current.messages.length).toBe(2);
330
357
  });
331
358
 
332
- expect(result.current.messages.length).toBe(0);
359
+ await act(async () => {
360
+ channel.simulateMessage(internalClear);
361
+ });
362
+
363
+ await act(() => Promise.resolve());
364
+
365
+ await waitFor(() => {
366
+ expect(result.current.messages.length).toBe(0); // ✅ Both cleared
367
+ });
333
368
  });
334
369
 
335
370
  it('performs cleanup of expired messages', async () => {
@@ -361,12 +396,14 @@ describe('useBroadcastChannel', () => {
361
396
 
362
397
  it('handles registered types filtering', async () => {
363
398
  const { result } = renderHook(() =>
364
- useBroadcastChannel('test-channel', { registeredTypes: ['allowed-type'] })
399
+ useBroadcastChannel('test-channel', {
400
+ registeredTypes: ['allowed-type'],
401
+ })
365
402
  );
366
403
  await waitForChannel();
367
404
  const channel = mockChannels[0];
368
405
 
369
- act(() => {
406
+ await act(async () => {
370
407
  channel.simulateMessage({
371
408
  id: 'unregistered',
372
409
  type: 'unregistered-type',
@@ -378,7 +415,7 @@ describe('useBroadcastChannel', () => {
378
415
 
379
416
  expect(result.current.messages.length).toBe(0);
380
417
 
381
- act(() => {
418
+ await act(async () => {
382
419
  channel.simulateMessage({
383
420
  id: 'registered',
384
421
  type: 'allowed-type',
@@ -388,7 +425,9 @@ describe('useBroadcastChannel', () => {
388
425
  });
389
426
  });
390
427
 
391
- expect(result.current.messages.length).toBe(1);
428
+ await waitFor(() => {
429
+ expect(result.current.messages.length).toBe(1);
430
+ });
392
431
  });
393
432
 
394
433
  it('handles keepLatestMessage option', async () => {
@@ -455,8 +494,10 @@ describe('useBroadcastChannel', () => {
455
494
  await waitForChannel();
456
495
  const channel = mockChannels[0];
457
496
 
458
- channel.postMessage.mockImplementationOnce(() => {
459
- throw new Error('Failed to post message');
497
+ act(() => {
498
+ channel.postMessage.mockImplementationOnce(() => {
499
+ throw new Error('Failed to post message');
500
+ });
460
501
  });
461
502
 
462
503
  act(() => {
@@ -516,4 +557,354 @@ describe('useBroadcastChannel', () => {
516
557
  expect(sentMessage.expirationDate).toBeDefined();
517
558
  expect(sentMessage.expirationDate).toBeGreaterThan(Date.now());
518
559
  });
560
+
561
+ it('returns null if there are no messages', async () => {
562
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
563
+ await waitForChannel();
564
+ expect(result.current.getLatestMessage()).toBeNull();
565
+ });
566
+
567
+ it('returns the latest message if no options are provided', async () => {
568
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
569
+ await waitForChannel();
570
+ const channel = mockChannels[0];
571
+ const msg1 = {
572
+ id: '1',
573
+ type: 'typeA',
574
+ message: 'first',
575
+ source: 'source1',
576
+ timestamp: Date.now(),
577
+ };
578
+ const msg2 = {
579
+ id: '2',
580
+ type: 'typeB',
581
+ message: 'second',
582
+ source: 'source2',
583
+ timestamp: Date.now() + 1,
584
+ };
585
+ act(() => {
586
+ channel.simulateMessage(msg1);
587
+ channel.simulateMessage(msg2);
588
+ });
589
+ expect(result.current.getLatestMessage()).toEqual(expect.objectContaining(msg2));
590
+ });
591
+
592
+ it('returns the latest message matching type', async () => {
593
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
594
+ await waitForChannel();
595
+ const channel = mockChannels[0];
596
+ const msg1 = {
597
+ id: '1',
598
+ type: 'typeA',
599
+ message: 'first',
600
+ source: 'source1',
601
+ timestamp: Date.now(),
602
+ };
603
+ const msg2 = {
604
+ id: '2',
605
+ type: 'typeB',
606
+ message: 'second',
607
+ source: 'source2',
608
+ timestamp: Date.now() + 1,
609
+ };
610
+ const msg3 = {
611
+ id: '3',
612
+ type: 'typeA',
613
+ message: 'third',
614
+ source: 'source3',
615
+ timestamp: Date.now() + 2,
616
+ };
617
+ act(() => {
618
+ channel.simulateMessage(msg1);
619
+ channel.simulateMessage(msg2);
620
+ channel.simulateMessage(msg3);
621
+ });
622
+ expect(result.current.getLatestMessage({ type: 'typeA' })).toEqual(
623
+ expect.objectContaining(msg3)
624
+ );
625
+ });
626
+
627
+ it('returns the latest message matching source', async () => {
628
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
629
+ await waitForChannel();
630
+ const channel = mockChannels[0];
631
+ const msg1 = {
632
+ id: '1',
633
+ type: 'typeA',
634
+ message: 'first',
635
+ source: 'source1',
636
+ timestamp: Date.now(),
637
+ };
638
+ const msg2 = {
639
+ id: '2',
640
+ type: 'typeB',
641
+ message: 'second',
642
+ source: 'source2',
643
+ timestamp: Date.now() + 1,
644
+ };
645
+ const msg3 = {
646
+ id: '3',
647
+ type: 'typeA',
648
+ message: 'third',
649
+ source: 'source1',
650
+ timestamp: Date.now() + 2,
651
+ };
652
+ act(() => {
653
+ channel.simulateMessage(msg1);
654
+ channel.simulateMessage(msg2);
655
+ channel.simulateMessage(msg3);
656
+ });
657
+ expect(result.current.getLatestMessage({ source: 'source1' })).toEqual(
658
+ expect.objectContaining(msg3)
659
+ );
660
+ });
661
+
662
+ it('returns the latest message matching both type and source', async () => {
663
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
664
+ await waitForChannel();
665
+ const channel = mockChannels[0];
666
+ const msg1 = {
667
+ id: '1',
668
+ type: 'typeA',
669
+ message: 'first',
670
+ source: 'source1',
671
+ timestamp: Date.now(),
672
+ };
673
+ const msg2 = {
674
+ id: '2',
675
+ type: 'typeB',
676
+ message: 'second',
677
+ source: 'source2',
678
+ timestamp: Date.now() + 1,
679
+ };
680
+ const msg3 = {
681
+ id: '3',
682
+ type: 'typeA',
683
+ message: 'third',
684
+ source: 'source1',
685
+ timestamp: Date.now() + 2,
686
+ };
687
+ act(() => {
688
+ channel.simulateMessage(msg1);
689
+ channel.simulateMessage(msg2);
690
+ channel.simulateMessage(msg3);
691
+ });
692
+ expect(result.current.getLatestMessage({ type: 'typeA', source: 'source1' })).toEqual(
693
+ expect.objectContaining(msg3)
694
+ );
695
+ });
696
+
697
+ it('returns null if no message matches the filter', async () => {
698
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
699
+ await waitForChannel();
700
+ const channel = mockChannels[0];
701
+ const msg1 = {
702
+ id: '1',
703
+ type: 'typeA',
704
+ message: 'first',
705
+ source: 'source1',
706
+ timestamp: Date.now(),
707
+ };
708
+ act(() => {
709
+ channel.simulateMessage(msg1);
710
+ });
711
+ expect(result.current.getLatestMessage({ type: 'typeB' })).toBeNull();
712
+ expect(result.current.getLatestMessage({ source: 'source2' })).toBeNull();
713
+ expect(result.current.getLatestMessage({ type: 'typeB', source: 'source2' })).toBeNull();
714
+ });
715
+
716
+ it('works with keepLatestMessage=true (only one message)', async () => {
717
+ const { result } = renderHook(() =>
718
+ useBroadcastChannel('test-channel', { keepLatestMessage: true })
719
+ );
720
+ await waitForChannel();
721
+ const channel = mockChannels[0];
722
+ const msg1 = {
723
+ id: '1',
724
+ type: 'typeA',
725
+ message: 'first',
726
+ source: 'source1',
727
+ timestamp: Date.now(),
728
+ };
729
+ act(() => {
730
+ channel.simulateMessage(msg1);
731
+ });
732
+ expect(result.current.getLatestMessage()).toEqual(expect.objectContaining(msg1));
733
+ });
734
+
735
+ it('returns the latest message in correct order (last one)', async () => {
736
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
737
+ await waitForChannel();
738
+ const channel = mockChannels[0];
739
+ const msg1 = {
740
+ id: '1',
741
+ type: 'typeA',
742
+ message: 'first',
743
+ source: 'source1',
744
+ timestamp: Date.now(),
745
+ };
746
+ const msg2 = {
747
+ id: '2',
748
+ type: 'typeA',
749
+ message: 'second',
750
+ source: 'source1',
751
+ timestamp: Date.now() + 1,
752
+ };
753
+ act(() => {
754
+ channel.simulateMessage(msg1);
755
+ channel.simulateMessage(msg2);
756
+ });
757
+ expect(result.current.getLatestMessage({ type: 'typeA', source: 'source1' })).toEqual(
758
+ expect.objectContaining(msg2)
759
+ );
760
+ });
761
+
762
+ it('treats empty string filters as no filter (returns latest message)', async () => {
763
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
764
+ await waitForChannel();
765
+ const channel = mockChannels[0];
766
+ const msg1 = {
767
+ id: '1',
768
+ type: 'typeA',
769
+ message: 'first',
770
+ source: 'source1',
771
+ timestamp: 1000,
772
+ };
773
+ const msg2 = {
774
+ id: '2',
775
+ type: 'typeB',
776
+ message: 'second',
777
+ source: 'source2',
778
+ timestamp: 2000,
779
+ };
780
+ act(() => {
781
+ channel.simulateMessage(msg1);
782
+ channel.simulateMessage(msg2);
783
+ });
784
+ expect(result.current.getLatestMessage({ type: '', source: '' })).toEqual(
785
+ expect.objectContaining(msg2)
786
+ );
787
+ });
788
+
789
+ it('returns the last message in array if timestamps are the same', async () => {
790
+ jest.spyOn(Date, 'now').mockReturnValue(1000);
791
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
792
+ await waitForChannel();
793
+ const channel = mockChannels[0];
794
+ const msg1 = {
795
+ id: '1',
796
+ type: 'typeA',
797
+ message: 'first',
798
+ source: 'source1',
799
+ timestamp: 1000,
800
+ };
801
+ const msg2 = {
802
+ id: '2',
803
+ type: 'typeA',
804
+ message: 'second',
805
+ source: 'source1',
806
+ timestamp: 1000,
807
+ };
808
+ act(() => {
809
+ channel.simulateMessage(msg1);
810
+ channel.simulateMessage(msg2);
811
+ });
812
+ expect(result.current.getLatestMessage({ type: 'typeA', source: 'source1' })).toEqual(
813
+ expect.objectContaining(msg2)
814
+ );
815
+ jest.spyOn(Date, 'now').mockRestore?.();
816
+ });
817
+
818
+ it('returns the last matching message if multiple match the filters', async () => {
819
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
820
+ await waitForChannel();
821
+ const channel = mockChannels[0];
822
+ const msg1 = {
823
+ id: '1',
824
+ type: 'typeA',
825
+ message: 'first',
826
+ source: 'source1',
827
+ timestamp: 1000,
828
+ };
829
+ const msg2 = {
830
+ id: '2',
831
+ type: 'typeB',
832
+ message: 'second',
833
+ source: 'source2',
834
+ timestamp: 2000,
835
+ };
836
+ const msg3 = {
837
+ id: '3',
838
+ type: 'typeA',
839
+ message: 'third',
840
+ source: 'source1',
841
+ timestamp: 3000,
842
+ };
843
+ act(() => {
844
+ channel.simulateMessage(msg1);
845
+ channel.simulateMessage(msg2);
846
+ channel.simulateMessage(msg3);
847
+ });
848
+ // msg1 and msg3 both match typeA/source1 — expect msg3
849
+ expect(result.current.getLatestMessage({ type: 'typeA', source: 'source1' })).toEqual(
850
+ expect.objectContaining(msg3)
851
+ );
852
+ });
853
+
854
+ it('closes the BroadcastChannel and removes event listener', async () => {
855
+ const { result } = renderHook(() =>
856
+ useBroadcastChannel('test-channel', { sourceName: 'another-tab' })
857
+ );
858
+ await waitForChannel();
859
+ const channel = mockChannels[0];
860
+ expect(channel.close).not.toHaveBeenCalled();
861
+ result.current.closeChannel();
862
+ expect(channel.close).toHaveBeenCalled();
863
+ expect(channel.removeEventListener).toHaveBeenCalledWith('message', expect.any(Function));
864
+ });
865
+
866
+ it('is idempotent (safe to call multiple times)', async () => {
867
+ const { result } = renderHook(() =>
868
+ useBroadcastChannel('test-channel', { sourceName: 'another-tab' })
869
+ );
870
+ await waitForChannel();
871
+ const channel = mockChannels[0];
872
+ result.current.closeChannel();
873
+ expect(channel.close).toHaveBeenCalledTimes(1);
874
+ result.current.closeChannel();
875
+ // Should not throw or call close again
876
+ expect(channel.close).toHaveBeenCalledTimes(1);
877
+ });
878
+
879
+ it('does not send messages after closeChannel is called', async () => {
880
+ const { result } = renderHook(() =>
881
+ useBroadcastChannel('test-channel', { sourceName: 'another-tab' })
882
+ );
883
+ await waitForChannel();
884
+ const channel = mockChannels[0];
885
+ result.current.closeChannel();
886
+ act(() => {
887
+ result.current.postMessage('test', { data: 'should not send' });
888
+ });
889
+ expect(channel.postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'test' }));
890
+ });
891
+
892
+ it('does not receive messages after closeChannel is called', async () => {
893
+ const { result } = renderHook(() =>
894
+ useBroadcastChannel('test-channel', { sourceName: 'another-tab' })
895
+ );
896
+ await waitForChannel();
897
+ const channel = mockChannels[0];
898
+ result.current.closeChannel();
899
+ act(() => {
900
+ channel.simulateMessage({
901
+ id: 'should-not-receive',
902
+ type: 'test',
903
+ message: 'no',
904
+ source: 'external',
905
+ timestamp: Date.now(),
906
+ });
907
+ });
908
+ expect(result.current.messages).toHaveLength(0);
909
+ });
519
910
  });
@@ -70,6 +70,11 @@ export interface ClearSentMessagesOptions extends ClearOptions {
70
70
  sync?: boolean;
71
71
  }
72
72
 
73
+ export interface GetLatestMessageOptions {
74
+ source?: string;
75
+ type?: string;
76
+ }
77
+
73
78
  /**
74
79
  * Actions and state provided by the broadcast channel hook
75
80
  */
@@ -92,6 +97,12 @@ export interface BroadcastActions {
92
97
  /** Function to clear sent messages */
93
98
  clearSentMessages: (options?: ClearSentMessagesOptions) => void;
94
99
 
100
+ /** Function to get the latest message by source and type */
101
+ getLatestMessage: (options?: GetLatestMessageOptions) => BroadcastMessage | null;
102
+
103
+ /** Explicitly close the broadcast channel and remove listeners */
104
+ closeChannel: () => void;
105
+
95
106
  /** Current error state */
96
107
  error: string | null;
97
108
  }
@@ -40,6 +40,8 @@ export const debug = {
40
40
  duplicate: (messageId: string) => log('warn', 'Duplicate message ignored:', messageId),
41
41
  allSentCleared: () => log('info', 'All sent messages cleared'),
42
42
  allReceivedCleared: () => log('info', 'All received messages cleared'),
43
+ ignored: (messageType: string) =>
44
+ log('info', 'Message ignored due to type filter:', messageType),
43
45
  },
44
46
  cleanup: {
45
47
  started: () => log('info', 'Cleanup started'),