react-broadcast-sync 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +59 -11
  2. package/dist/index.cjs.js +1 -1
  3. package/dist/index.cjs.js.map +1 -1
  4. package/dist/index.esm.js +1 -1
  5. package/dist/index.esm.js.map +1 -1
  6. package/dist/types/types.d.ts +17 -0
  7. package/package.json +6 -1
  8. package/.eslintignore +0 -5
  9. package/.github/workflows/ci.yml +0 -61
  10. package/.github/workflows/release.yml +0 -39
  11. package/.husky/pre-commit +0 -5
  12. package/.nvmrc +0 -1
  13. package/.prettierignore +0 -4
  14. package/.prettierrc +0 -9
  15. package/.releaserc.json +0 -22
  16. package/CHANGELOG.md +0 -97
  17. package/assets/react-broadcast-sync-logo.png +0 -0
  18. package/commitlint.config.js +0 -3
  19. package/demo/react-broadcast-sync-demo/README.md +0 -130
  20. package/demo/react-broadcast-sync-demo/index.html +0 -14
  21. package/demo/react-broadcast-sync-demo/package-lock.json +0 -6379
  22. package/demo/react-broadcast-sync-demo/package.json +0 -42
  23. package/demo/react-broadcast-sync-demo/public/assets/react-broadcast-sync-demo-video.gif +0 -0
  24. package/demo/react-broadcast-sync-demo/public/assets/react-broadcast-sync-logo.png +0 -0
  25. package/demo/react-broadcast-sync-demo/src/App.tsx +0 -277
  26. package/demo/react-broadcast-sync-demo/src/components/Counter.tsx +0 -63
  27. package/demo/react-broadcast-sync-demo/src/components/TextSync.tsx +0 -61
  28. package/demo/react-broadcast-sync-demo/src/components/TodoList.tsx +0 -186
  29. package/demo/react-broadcast-sync-demo/src/hooks/useCounterBroadcast.ts +0 -96
  30. package/demo/react-broadcast-sync-demo/src/hooks/useTextBroadcast.ts +0 -37
  31. package/demo/react-broadcast-sync-demo/src/hooks/useTodoBroadcast.ts +0 -249
  32. package/demo/react-broadcast-sync-demo/src/main.tsx +0 -11
  33. package/demo/react-broadcast-sync-demo/src/theme.ts +0 -62
  34. package/demo/react-broadcast-sync-demo/tsconfig.json +0 -26
  35. package/demo/react-broadcast-sync-demo/tsconfig.node.json +0 -10
  36. package/demo/react-broadcast-sync-demo/vite.config.ts +0 -12
  37. package/eslint.config.js +0 -38
  38. package/jest.config.cjs +0 -9
  39. package/jest.setup.ts +0 -1
  40. package/rollup.config.mjs +0 -98
  41. package/src/BroadcastProvider.tsx +0 -28
  42. package/src/hooks/useBroadcastChannel.ts +0 -513
  43. package/src/index.ts +0 -9
  44. package/src/tests/BroadcastProvider.test.tsx +0 -157
  45. package/src/tests/debug.test.ts +0 -273
  46. package/src/tests/messageUtils.test.ts +0 -252
  47. package/src/tests/useBroadcastChannel.same-tab.test.tsx +0 -992
  48. package/src/tests/useBroadcastChannel.test.tsx +0 -1243
  49. package/src/types/types.ts +0 -124
  50. package/src/utils/debug.ts +0 -80
  51. package/src/utils/messageUtils.ts +0 -122
  52. package/tsconfig.json +0 -36
@@ -1,513 +0,0 @@
1
- import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
2
- import {
3
- BroadcastActions,
4
- BroadcastMessage,
5
- BroadcastOptions,
6
- ClearReceivedMessagesOptions,
7
- ClearSentMessagesOptions,
8
- GetLatestMessageOptions,
9
- InternalMessage,
10
- SendMessageOptions,
11
- } from '../types/types';
12
- import {
13
- generateSourceName,
14
- isValidMessage,
15
- isMessageExpired,
16
- createMessage,
17
- getInternalMessageType,
18
- isValidInternalClearMessage,
19
- debounce,
20
- } from '../utils/messageUtils';
21
- import { debug } from '../utils/debug';
22
-
23
- const INTERNAL_MESSAGE_TYPES: Record<string, InternalMessage> = {
24
- CLEAR_SENT_MESSAGES: 'CLEAR_SENT_MESSAGES',
25
- PING: 'PING',
26
- PONG: 'PONG',
27
- } as const;
28
-
29
- let flushSyncFn: undefined | ((cb: () => void) => void) = undefined;
30
- if (process.env.NODE_ENV === 'test') {
31
- try {
32
- // eslint-disable-next-line @typescript-eslint/no-var-requires
33
- flushSyncFn = require('react-dom').flushSync;
34
- } catch {
35
- // ignore error in test env
36
- }
37
- }
38
-
39
- /**
40
- * useBroadcastChannel hook
41
- *
42
- * Note: When batching is enabled (batchingDelayMs > 0), messages sent over the channel may be received as either:
43
- * - a single BroadcastMessage (object), or
44
- * - an array of BroadcastMessage (batch)
45
- *
46
- * The event handler in this hook supports both formats. If you listen to the channel directly, always check:
47
- * if (Array.isArray(event.data)) { ... } else { ... }
48
- */
49
- export const useBroadcastChannel = (
50
- channelName: string,
51
- options: BroadcastOptions = {}
52
- ): BroadcastActions => {
53
- const {
54
- sourceName,
55
- cleaningInterval = 1000,
56
- keepLatestMessage = false,
57
- registeredTypes = [],
58
- namespace = '',
59
- deduplicationTTL = 5 * 60 * 1000,
60
- cleanupDebounceMs = 0,
61
- batchingDelayMs = 20,
62
- excludedBatchMessageTypes = [],
63
- } = options;
64
-
65
- // State
66
- const [messages, setMessages] = useState<BroadcastMessage[]>([]);
67
- const [sentMessages, setSentMessages] = useState<BroadcastMessage[]>([]);
68
- const [error, setError] = useState<string | null>(null);
69
- const [isPingInProgress, setIsPingInProgress] = useState(false);
70
-
71
- // Refs
72
- const channel = useRef<BroadcastChannel | null>(null);
73
- const receivedMessageIds = useRef(new Map<string, number>());
74
- const activeSourcesCollector = useRef<Set<string> | null>(null);
75
- const batchingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
76
- const batchingMessagesRef = useRef<BroadcastMessage[]>([]);
77
- const batchingErrorRef = useRef(false);
78
-
79
- // Memoized values
80
- const source = useMemo(() => sourceName || generateSourceName(), [sourceName]);
81
- const internalTypes = useMemo(
82
- () => ({
83
- CLEAR_SENT_MESSAGES: getInternalMessageType(
84
- INTERNAL_MESSAGE_TYPES.CLEAR_SENT_MESSAGES,
85
- channelName,
86
- namespace
87
- ),
88
- PING: getInternalMessageType(INTERNAL_MESSAGE_TYPES.PING, channelName, namespace),
89
- PONG: getInternalMessageType(INTERNAL_MESSAGE_TYPES.PONG, channelName, namespace),
90
- }),
91
- [channelName, namespace]
92
- );
93
-
94
- const resolvedChannelName = useMemo(() => {
95
- // Only append dash if namespace is non-empty
96
- return namespace ? `${channelName}-${namespace}` : channelName;
97
- }, [channelName, namespace]);
98
-
99
- const stableRegisteredTypes = useMemo(() => registeredTypes, [JSON.stringify(registeredTypes)]);
100
-
101
- const performCleanup = useCallback(() => {
102
- setMessages(prev => prev.filter(msg => !isMessageExpired(msg)));
103
- }, []);
104
-
105
- const debouncedCleanup = useRef<{
106
- (...args: Parameters<typeof performCleanup>): void;
107
- cancel: () => void;
108
- }>(
109
- cleanupDebounceMs > 0
110
- ? debounce(performCleanup, cleanupDebounceMs)
111
- : Object.assign(performCleanup, { cancel: () => {} })
112
- ).current;
113
-
114
- const setErrorMessage = useCallback((error: string) => {
115
- setError(error);
116
- setTimeout(() => setError(null), 3000);
117
- }, []);
118
-
119
- const ping = useCallback(
120
- (timeoutMs: number = 300): Promise<string[]> => {
121
- if (isPingInProgress) {
122
- debug.ping.inProgress();
123
- return Promise.resolve([]);
124
- }
125
-
126
- if (!channel.current) {
127
- const error =
128
- 'BroadcastChannel is not supported in this browser. Please check browser compatibility.';
129
- debug.error({
130
- action: 'ping',
131
- channelName: resolvedChannelName,
132
- originalError: error,
133
- });
134
- setErrorMessage(error);
135
- return Promise.resolve([]);
136
- }
137
-
138
- // Set synchronously before returning the promise
139
- if (flushSyncFn) {
140
- flushSyncFn(() => setIsPingInProgress(true));
141
- } else {
142
- setIsPingInProgress(true);
143
- }
144
- const collector = new Set<string>();
145
- activeSourcesCollector.current = collector;
146
-
147
- channel.current.postMessage(createMessage(internalTypes.PING, null, source));
148
-
149
- return new Promise(resolve => {
150
- setTimeout(() => {
151
- setIsPingInProgress(false);
152
- if (activeSourcesCollector.current === collector) {
153
- activeSourcesCollector.current = null;
154
- }
155
- resolve(Array.from(collector));
156
- }, timeoutMs);
157
- });
158
- },
159
- [internalTypes.PING, source, setErrorMessage, isPingInProgress, flushSyncFn]
160
- );
161
-
162
- const postMessage = useCallback(
163
- (messageType: string, messageContent: any, options: SendMessageOptions = {}) => {
164
- const channelCurrent = channel.current;
165
- if (!channelCurrent) {
166
- const error =
167
- 'BroadcastChannel is not supported in this browser. Please check browser compatibility.';
168
- debug.error({
169
- action: 'postMessage',
170
- channelName: resolvedChannelName,
171
- type: messageType,
172
- originalError: error,
173
- });
174
- setErrorMessage(error);
175
- return;
176
- }
177
-
178
- const message = createMessage(messageType, messageContent, source, options);
179
- // Only batch if batchingDelayMs > 0 and messageType is not excluded
180
- const shouldSendImmediately =
181
- !batchingDelayMs || batchingDelayMs < 0 || excludedBatchMessageTypes.includes(messageType);
182
- if (shouldSendImmediately) {
183
- try {
184
- channelCurrent.postMessage(message);
185
- } catch (e) {
186
- const error = 'Failed to send message';
187
- debug.error({
188
- action: 'postMessage',
189
- channelName: resolvedChannelName,
190
- type: messageType,
191
- originalError: error,
192
- });
193
- setErrorMessage(error);
194
- batchingErrorRef.current = true;
195
- }
196
- } else {
197
- batchingMessagesRef.current.push(message);
198
- if (!batchingTimeoutRef.current) {
199
- batchingTimeoutRef.current = setTimeout(() => {
200
- if (batchingErrorRef.current) {
201
- batchingMessagesRef.current = [];
202
- batchingTimeoutRef.current = null;
203
- return;
204
- }
205
- try {
206
- channelCurrent.postMessage(batchingMessagesRef.current);
207
- } catch (e) {
208
- const error = 'Failed to send message';
209
- debug.error({
210
- action: 'postMessage',
211
- channelName: resolvedChannelName,
212
- type: messageType,
213
- originalError: error,
214
- });
215
- setErrorMessage(error);
216
- batchingErrorRef.current = true;
217
- }
218
- batchingMessagesRef.current = [];
219
- batchingTimeoutRef.current = null;
220
- }, batchingDelayMs);
221
- }
222
- }
223
- debug.message.sent(message);
224
- setSentMessages(prev => [...prev, message]);
225
- },
226
- [source, setErrorMessage, batchingDelayMs, excludedBatchMessageTypes]
227
- );
228
-
229
- const clearReceivedMessages = useCallback((options: ClearReceivedMessagesOptions = {}) => {
230
- const hasFilters = Boolean(
231
- (options.ids && options.ids.length) ||
232
- (options.types && options.types.length) ||
233
- (options.sources && options.sources.length)
234
- );
235
-
236
- setMessages(prev =>
237
- hasFilters
238
- ? prev.filter(
239
- msg =>
240
- !(options.ids && options.ids.includes(msg.id)) &&
241
- !(options.types && options.types.includes(msg.type)) &&
242
- !(options.sources && options.sources.includes(msg.source))
243
- )
244
- : []
245
- );
246
- if (!hasFilters) {
247
- debug.message.allReceivedCleared();
248
- }
249
- }, []);
250
-
251
- const clearSentMessages = useCallback((options: ClearSentMessagesOptions = {}) => {
252
- const { ids = [], types = [], sync = false } = options ?? {};
253
- setSentMessages(prev =>
254
- ids.length > 0 || types.length > 0
255
- ? prev.filter(msg => {
256
- // Only consider messages from the same sender
257
- if (msg.source !== source) return true;
258
-
259
- // Decide whether this message should be cleared:
260
- // - If the ids array is empty, treat as wildcard (match all ids)
261
- // - If the types array is empty, treat as wildcard (match all types)
262
- const idMatches = ids.length === 0 || ids.includes(msg.id);
263
- const typeMatches = types.length === 0 || types.includes(msg.type);
264
-
265
- // Remove when BOTH criteria match (wildcards included)
266
- return !(idMatches && typeMatches);
267
- })
268
- : []
269
- );
270
- if (ids.length === 0 && types.length === 0) {
271
- debug.message.allSentCleared();
272
- }
273
- if (sync) {
274
- channel.current?.postMessage(
275
- createMessage(
276
- internalTypes.CLEAR_SENT_MESSAGES,
277
- { ids: options.ids ?? [], types: options.types ?? [] },
278
- source
279
- )
280
- );
281
- }
282
- }, []);
283
-
284
- const getLatestMessage = useCallback(
285
- (options: GetLatestMessageOptions = {}) => {
286
- const { source, type } = options;
287
-
288
- for (let i = messages.length - 1; i >= 0; i--) {
289
- const msg = messages[i];
290
- const sourceMatches = !source || msg.source === source;
291
- const typeMatches = !type || msg.type === type;
292
- if (sourceMatches && typeMatches) {
293
- return msg;
294
- }
295
- }
296
-
297
- return null;
298
- },
299
- [messages]
300
- );
301
-
302
- const handleMessage = useCallback(
303
- (event: MessageEvent) => {
304
- try {
305
- const message: BroadcastMessage = event.data;
306
- debug.message.received(message);
307
-
308
- if (!isValidMessage(message)) return;
309
- if (message.source === source) return;
310
- if (isValidInternalClearMessage(message)) {
311
- if (message.type === internalTypes.CLEAR_SENT_MESSAGES) {
312
- const { ids = [], types = [] } = (message as any).message || {};
313
-
314
- setMessages(prev => [
315
- ...prev.filter(msg => {
316
- // Only consider messages from the same sender
317
- if (msg.source !== message.source) return true;
318
-
319
- // Decide whether this message should be cleared:
320
- // - If the ids array is empty, treat as wildcard (match all ids)
321
- // - If the types array is empty, treat as wildcard (match all types)
322
- const idMatches = ids.length === 0 || ids.includes(msg.id);
323
- const typeMatches = types.length === 0 || types.includes(msg.type);
324
-
325
- return !(idMatches && typeMatches);
326
- }),
327
- ]);
328
-
329
- return;
330
- }
331
- if (message.type === internalTypes.PING) {
332
- channel.current?.postMessage(createMessage(internalTypes.PONG, null, source));
333
- return;
334
- }
335
-
336
- if (message.type === internalTypes.PONG) {
337
- const pongSource = message.source;
338
- const collector = activeSourcesCollector.current;
339
-
340
- if (collector && collector.has(pongSource) === false) {
341
- collector.add(pongSource);
342
- }
343
-
344
- return;
345
- }
346
- }
347
- if (stableRegisteredTypes.length > 0 && !stableRegisteredTypes.includes(message.type)) {
348
- debug.message.ignored(message.type);
349
- return;
350
- }
351
- if (isMessageExpired(message)) {
352
- debug.message.expired(message.id);
353
- return;
354
- }
355
-
356
- const now = Date.now();
357
- const receivedAt = receivedMessageIds.current.get(message.id);
358
- if (receivedAt && now - receivedAt < deduplicationTTL) {
359
- debug.message.duplicate(message.id);
360
- return;
361
- }
362
-
363
- receivedMessageIds.current.set(message.id, now);
364
- setMessages(prev => (keepLatestMessage ? [message] : [...prev, message]));
365
- } catch (e) {
366
- const error = 'Error processing broadcast message';
367
- debug.error({
368
- action: 'handleMessage',
369
- channelName: resolvedChannelName,
370
- originalError: error,
371
- });
372
- setErrorMessage(error);
373
- }
374
- },
375
- [
376
- source,
377
- stableRegisteredTypes,
378
- keepLatestMessage,
379
- setErrorMessage,
380
- internalTypes,
381
- deduplicationTTL,
382
- ]
383
- );
384
-
385
- const closeChannel = useCallback(() => {
386
- const bc = channel.current;
387
- if (bc && typeof bc.close === 'function') {
388
- bc.removeEventListener('message', handleMessage);
389
- bc.close();
390
- debug.channel.closed(resolvedChannelName);
391
- channel.current = null;
392
- }
393
- }, [handleMessage, resolvedChannelName]);
394
-
395
- useEffect(() => {
396
- if (typeof BroadcastChannel === 'undefined') {
397
- const error =
398
- 'BroadcastChannel is not supported in this browser. Please check browser compatibility.';
399
- debug.error({
400
- action: 'useBroadcastChannel',
401
- channelName: resolvedChannelName,
402
- originalError: error,
403
- });
404
- setErrorMessage(error);
405
- return;
406
- }
407
-
408
- let bc: BroadcastChannel | null = null;
409
- try {
410
- bc = new BroadcastChannel(resolvedChannelName);
411
- } catch (e) {
412
- const error = 'Failed to create BroadcastChannel';
413
- debug.error({
414
- action: 'useBroadcastChannel',
415
- channelName: resolvedChannelName,
416
- originalError: e instanceof Error ? e : String(e),
417
- });
418
- setErrorMessage(error);
419
- return;
420
- }
421
- channel.current = bc;
422
- debug.channel.created(resolvedChannelName);
423
-
424
- bc.addEventListener('message', (event: MessageEvent) => {
425
- // event.data may be a single message or an array of messages (batch)
426
- if (Array.isArray(event.data)) {
427
- event.data.forEach((message: BroadcastMessage) => {
428
- handleMessage({ data: message } as MessageEvent<BroadcastMessage>);
429
- });
430
- } else {
431
- handleMessage(event);
432
- }
433
- });
434
- return () => {
435
- closeChannel();
436
- };
437
- }, [resolvedChannelName, handleMessage, setErrorMessage]);
438
-
439
- useEffect(() => {
440
- if (cleaningInterval <= 0) return;
441
-
442
- const interval = setInterval(() => {
443
- debug.cleanup.started();
444
- const before = messages.length;
445
- debouncedCleanup();
446
- debug.cleanup.completed(before - messages.length);
447
- }, cleaningInterval);
448
-
449
- return () => {
450
- clearInterval(interval);
451
- // Cancel any pending debounced cleanup on unmount
452
- if (cleanupDebounceMs > 0) {
453
- debouncedCleanup.cancel?.();
454
- }
455
- };
456
- }, [cleaningInterval, debouncedCleanup, cleanupDebounceMs]);
457
-
458
- useEffect(() => {
459
- const interval = setInterval(() => {
460
- const now = Date.now();
461
- for (const [key, timestamp] of receivedMessageIds.current.entries()) {
462
- if (now - timestamp >= deduplicationTTL) {
463
- receivedMessageIds.current.delete(key);
464
- }
465
- }
466
- }, 60 * 1000);
467
-
468
- return () => clearInterval(interval);
469
- }, [deduplicationTTL]);
470
-
471
- useEffect(() => {
472
- return () => {
473
- // Always flush any unsent batched messages on unmount
474
- if (batchingMessagesRef.current.length > 0 && channel.current && !batchingErrorRef.current) {
475
- try {
476
- channel.current.postMessage(batchingMessagesRef.current);
477
- } catch (e) {
478
- const error = 'Failed to send message';
479
- debug.error({
480
- action: 'useBroadcastChannel',
481
- channelName: resolvedChannelName,
482
- originalError: e instanceof Error ? e : String(e),
483
- });
484
- setErrorMessage(error);
485
- }
486
- batchingMessagesRef.current = [];
487
- // Allow event loop to process delivery for tests
488
- setTimeout(() => {}, 0);
489
- }
490
- if (batchingTimeoutRef.current) {
491
- clearTimeout(batchingTimeoutRef.current);
492
- batchingTimeoutRef.current = null;
493
- }
494
- batchingErrorRef.current = false;
495
- };
496
- }, []);
497
-
498
- return {
499
- channelName: resolvedChannelName,
500
- messages,
501
- sentMessages,
502
- isPingInProgress,
503
- ping,
504
- postMessage,
505
- clearReceivedMessages,
506
- clearSentMessages,
507
- getLatestMessage,
508
- error,
509
- closeChannel,
510
- };
511
- };
512
-
513
- export default useBroadcastChannel;
package/src/index.ts DELETED
@@ -1,9 +0,0 @@
1
- export { useBroadcastChannel } from './hooks/useBroadcastChannel';
2
- export { BroadcastProvider, useBroadcastProvider } from './BroadcastProvider';
3
-
4
- export type {
5
- BroadcastOptions,
6
- SendMessageOptions,
7
- BroadcastMessage,
8
- BroadcastActions,
9
- } from './types/types';
@@ -1,157 +0,0 @@
1
- import { render, screen, act } from '@testing-library/react';
2
- import '@testing-library/jest-dom';
3
- import { BroadcastProvider, useBroadcastProvider } from '../BroadcastProvider';
4
-
5
- // Mock BroadcastChannel
6
- let mockChannels: any[] = [];
7
-
8
- class MockBroadcastChannel {
9
- name: string;
10
- onmessage: ((event: MessageEvent) => void) | null = null;
11
- postMessage = jest.fn();
12
- close = jest.fn();
13
- addEventListener = jest.fn((event: string, callback: (event: MessageEvent) => void) => {
14
- if (event === 'message') {
15
- this.onmessage = callback;
16
- }
17
- });
18
- removeEventListener = jest.fn();
19
-
20
- constructor(name: string) {
21
- this.name = name;
22
- mockChannels.push(this);
23
- }
24
-
25
- simulateMessage(data: any) {
26
- if (this.onmessage) {
27
- this.onmessage({ data } as MessageEvent);
28
- }
29
- }
30
- }
31
-
32
- // Test component that uses the provider
33
- const TestComponent = () => {
34
- const { messages, error } = useBroadcastProvider();
35
- return (
36
- <div>
37
- <div data-testid="error">{error}</div>
38
- <div data-testid="messages">
39
- {messages.map(msg => (
40
- <div key={msg.id} data-testid={`message-${msg.id}`}>
41
- {msg.message.text}
42
- </div>
43
- ))}
44
- </div>
45
- </div>
46
- );
47
- };
48
-
49
- describe('BroadcastProvider', () => {
50
- beforeEach(() => {
51
- mockChannels = [];
52
- global.BroadcastChannel = MockBroadcastChannel as any;
53
- });
54
-
55
- afterEach(() => {
56
- jest.clearAllMocks();
57
- });
58
-
59
- it('creates a broadcast channel with the provided name', () => {
60
- render(
61
- <BroadcastProvider channelName="test-channel">
62
- <TestComponent />
63
- </BroadcastProvider>
64
- );
65
-
66
- // The provider delegates to useBroadcastChannel which creates one channel
67
- expect(mockChannels).toHaveLength(1);
68
- expect(mockChannels[0].name).toBe('test-channel');
69
- });
70
-
71
- it('provides context values to children', () => {
72
- render(
73
- <BroadcastProvider channelName="test-channel">
74
- <TestComponent />
75
- </BroadcastProvider>
76
- );
77
-
78
- expect(screen.getByTestId('error')).toHaveTextContent('');
79
- expect(screen.getByTestId('messages')).toBeEmptyDOMElement();
80
- });
81
-
82
- it('handles messages through the provider', () => {
83
- render(
84
- <BroadcastProvider channelName="test-channel">
85
- <TestComponent />
86
- </BroadcastProvider>
87
- );
88
-
89
- const message = {
90
- id: 'test-1',
91
- type: 'test',
92
- message: { text: 'Hello' },
93
- source: 'other-tab',
94
- timestamp: Date.now(),
95
- };
96
-
97
- act(() => {
98
- mockChannels[0].simulateMessage(message);
99
- });
100
-
101
- expect(screen.getByTestId('message-test-1')).toHaveTextContent('Hello');
102
- });
103
-
104
- it('handles errors when BroadcastChannel is not supported', () => {
105
- const originalBroadcastChannel = global.BroadcastChannel;
106
- delete (global as any).BroadcastChannel;
107
-
108
- render(
109
- <BroadcastProvider channelName="test-channel">
110
- <TestComponent />
111
- </BroadcastProvider>
112
- );
113
-
114
- expect(screen.getByTestId('error')).toHaveTextContent(
115
- 'BroadcastChannel is not supported in this browser. Please check browser compatibility.'
116
- );
117
-
118
- // Restore BroadcastChannel for other tests
119
- global.BroadcastChannel = originalBroadcastChannel;
120
- });
121
-
122
- it('cleans up channel on unmount', () => {
123
- const { unmount } = render(
124
- <BroadcastProvider channelName="test-channel">
125
- <TestComponent />
126
- </BroadcastProvider>
127
- );
128
-
129
- unmount();
130
-
131
- expect(mockChannels[0].close).toHaveBeenCalled();
132
- });
133
-
134
- it('propagates child component errors (no internal error boundary)', () => {
135
- const ThrowComponent = () => {
136
- throw new Error('Test error');
137
- };
138
-
139
- expect(() => {
140
- render(
141
- <BroadcastProvider channelName="test-channel">
142
- <ThrowComponent />
143
- </BroadcastProvider>
144
- );
145
- }).toThrow('Test error');
146
- });
147
-
148
- it('throws error when useBroadcastProvider is used outside provider', () => {
149
- const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
150
-
151
- expect(() => {
152
- render(<TestComponent />);
153
- }).toThrow('useBroadcastProvider must be used within a BroadcastProvider');
154
-
155
- consoleError.mockRestore();
156
- });
157
- });