react-broadcast-sync 1.3.0 → 1.4.0

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