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