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.
@@ -3,10 +3,10 @@ import {
3
3
  BroadcastActions,
4
4
  BroadcastMessage,
5
5
  BroadcastOptions,
6
- ClearMessage,
7
6
  ClearReceivedMessagesOptions,
8
7
  ClearSentMessagesOptions,
9
8
  GetLatestMessageOptions,
9
+ InternalMessage,
10
10
  SendMessageOptions,
11
11
  } from '../types/types';
12
12
  import {
@@ -20,10 +20,32 @@ import {
20
20
  } from '../utils/messageUtils';
21
21
  import { debug } from '../utils/debug';
22
22
 
23
- const INTERNAL_MESSAGE_TYPES: Record<string, ClearMessage> = {
23
+ const INTERNAL_MESSAGE_TYPES: Record<string, InternalMessage> = {
24
24
  CLEAR_SENT_MESSAGES: 'CLEAR_SENT_MESSAGES',
25
+ PING: 'PING',
26
+ PONG: 'PONG',
25
27
  } as const;
26
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
+ */
27
49
  export const useBroadcastChannel = (
28
50
  channelName: string,
29
51
  options: BroadcastOptions = {}
@@ -36,16 +58,25 @@ export const useBroadcastChannel = (
36
58
  namespace = '',
37
59
  deduplicationTTL = 5 * 60 * 1000,
38
60
  cleanupDebounceMs = 0,
61
+ batchingDelayMs = 20,
62
+ excludedBatchMessageTypes = [],
39
63
  } = options;
40
64
 
41
65
  // State
42
66
  const [messages, setMessages] = useState<BroadcastMessage[]>([]);
43
67
  const [sentMessages, setSentMessages] = useState<BroadcastMessage[]>([]);
44
68
  const [error, setError] = useState<string | null>(null);
69
+ const [isPingInProgress, setIsPingInProgress] = useState(false);
45
70
 
46
71
  // Refs
47
72
  const channel = useRef<BroadcastChannel | null>(null);
48
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
49
80
  const source = useMemo(() => sourceName || generateSourceName(), [sourceName]);
50
81
  const internalTypes = useMemo(
51
82
  () => ({
@@ -54,12 +85,15 @@ export const useBroadcastChannel = (
54
85
  channelName,
55
86
  namespace
56
87
  ),
88
+ PING: getInternalMessageType(INTERNAL_MESSAGE_TYPES.PING, channelName, namespace),
89
+ PONG: getInternalMessageType(INTERNAL_MESSAGE_TYPES.PONG, channelName, namespace),
57
90
  }),
58
91
  [channelName, namespace]
59
92
  );
60
93
 
61
94
  const resolvedChannelName = useMemo(() => {
62
- return `${channelName}-${namespace}`;
95
+ // Only append dash if namespace is non-empty
96
+ return namespace ? `${channelName}-${namespace}` : channelName;
63
97
  }, [channelName, namespace]);
64
98
 
65
99
  const stableRegisteredTypes = useMemo(() => registeredTypes, [JSON.stringify(registeredTypes)]);
@@ -82,6 +116,45 @@ export const useBroadcastChannel = (
82
116
  setTimeout(() => setError(null), 3000);
83
117
  }, []);
84
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(error);
130
+ setErrorMessage(error);
131
+ return Promise.resolve([]);
132
+ }
133
+
134
+ // Set synchronously before returning the promise
135
+ if (flushSyncFn) {
136
+ flushSyncFn(() => setIsPingInProgress(true));
137
+ } else {
138
+ setIsPingInProgress(true);
139
+ }
140
+ const collector = new Set<string>();
141
+ activeSourcesCollector.current = collector;
142
+
143
+ channel.current.postMessage(createMessage(internalTypes.PING, null, source));
144
+
145
+ return new Promise(resolve => {
146
+ setTimeout(() => {
147
+ setIsPingInProgress(false);
148
+ if (activeSourcesCollector.current === collector) {
149
+ activeSourcesCollector.current = null;
150
+ }
151
+ resolve(Array.from(collector));
152
+ }, timeoutMs);
153
+ });
154
+ },
155
+ [internalTypes.PING, source, setErrorMessage, isPingInProgress, flushSyncFn]
156
+ );
157
+
85
158
  const postMessage = useCallback(
86
159
  (messageType: string, messageContent: any, options: SendMessageOptions = {}) => {
87
160
  const channelCurrent = channel.current;
@@ -93,18 +166,45 @@ export const useBroadcastChannel = (
93
166
  return;
94
167
  }
95
168
 
96
- try {
97
- const message = createMessage(messageType, messageContent, source, options);
98
- channelCurrent.postMessage(message);
99
- debug.message.sent(message);
100
- setSentMessages(prev => [...prev, message]);
101
- } catch (e) {
102
- const error = 'Failed to send message';
103
- debug.error(error);
104
- setErrorMessage(error);
169
+ const message = createMessage(messageType, messageContent, source, options);
170
+ // Only batch if batchingDelayMs > 0 and messageType is not excluded
171
+ const shouldSendImmediately =
172
+ !batchingDelayMs || batchingDelayMs < 0 || excludedBatchMessageTypes.includes(messageType);
173
+ if (shouldSendImmediately) {
174
+ try {
175
+ channelCurrent.postMessage(message);
176
+ } catch (e) {
177
+ const error = 'Failed to send message';
178
+ debug.error(error);
179
+ setErrorMessage(error);
180
+ batchingErrorRef.current = true;
181
+ }
182
+ } else {
183
+ batchingMessagesRef.current.push(message);
184
+ if (!batchingTimeoutRef.current) {
185
+ batchingTimeoutRef.current = setTimeout(() => {
186
+ if (batchingErrorRef.current) {
187
+ batchingMessagesRef.current = [];
188
+ batchingTimeoutRef.current = null;
189
+ return;
190
+ }
191
+ try {
192
+ channelCurrent.postMessage(batchingMessagesRef.current);
193
+ } catch (e) {
194
+ const error = 'Failed to send message';
195
+ debug.error(error);
196
+ setErrorMessage(error);
197
+ batchingErrorRef.current = true;
198
+ }
199
+ batchingMessagesRef.current = [];
200
+ batchingTimeoutRef.current = null;
201
+ }, batchingDelayMs);
202
+ }
105
203
  }
204
+ debug.message.sent(message);
205
+ setSentMessages(prev => [...prev, message]);
106
206
  },
107
- [source, setErrorMessage]
207
+ [source, setErrorMessage, batchingDelayMs, excludedBatchMessageTypes]
108
208
  );
109
209
 
110
210
  const clearReceivedMessages = useCallback((options: ClearReceivedMessagesOptions = {}) => {
@@ -197,12 +297,25 @@ export const useBroadcastChannel = (
197
297
  const idMatches = ids.length === 0 || ids.includes(msg.id);
198
298
  const typeMatches = types.length === 0 || types.includes(msg.type);
199
299
 
200
- // Remove when BOTH criteria match (wildcards included)
201
300
  return !(idMatches && typeMatches);
202
301
  }),
203
302
  ]);
204
303
 
205
- // After handling we don't want to process this internal control packet further
304
+ return;
305
+ }
306
+ if (message.type === internalTypes.PING) {
307
+ channel.current?.postMessage(createMessage(internalTypes.PONG, null, source));
308
+ return;
309
+ }
310
+
311
+ if (message.type === internalTypes.PONG) {
312
+ const pongSource = message.source;
313
+ const collector = activeSourcesCollector.current;
314
+
315
+ if (collector && collector.has(pongSource) === false) {
316
+ collector.add(pongSource);
317
+ }
318
+
206
319
  return;
207
320
  }
208
321
  }
@@ -263,7 +376,16 @@ export const useBroadcastChannel = (
263
376
  channel.current = bc;
264
377
  debug.channel.created(resolvedChannelName);
265
378
 
266
- bc.addEventListener('message', handleMessage);
379
+ bc.addEventListener('message', (event: MessageEvent) => {
380
+ // event.data may be a single message or an array of messages (batch)
381
+ if (Array.isArray(event.data)) {
382
+ event.data.forEach((message: BroadcastMessage) => {
383
+ handleMessage({ data: message } as MessageEvent<BroadcastMessage>);
384
+ });
385
+ } else {
386
+ handleMessage(event);
387
+ }
388
+ });
267
389
  return () => {
268
390
  closeChannel();
269
391
  };
@@ -291,9 +413,9 @@ export const useBroadcastChannel = (
291
413
  useEffect(() => {
292
414
  const interval = setInterval(() => {
293
415
  const now = Date.now();
294
- for (const [id, timestamp] of receivedMessageIds.current.entries()) {
416
+ for (const [key, timestamp] of receivedMessageIds.current.entries()) {
295
417
  if (now - timestamp >= deduplicationTTL) {
296
- receivedMessageIds.current.delete(id);
418
+ receivedMessageIds.current.delete(key);
297
419
  }
298
420
  }
299
421
  }, 60 * 1000);
@@ -301,10 +423,35 @@ export const useBroadcastChannel = (
301
423
  return () => clearInterval(interval);
302
424
  }, [deduplicationTTL]);
303
425
 
426
+ useEffect(() => {
427
+ return () => {
428
+ // Always flush any unsent batched messages on unmount
429
+ if (batchingMessagesRef.current.length > 0 && channel.current && !batchingErrorRef.current) {
430
+ try {
431
+ channel.current.postMessage(batchingMessagesRef.current);
432
+ } catch (e) {
433
+ const error = 'Failed to send message';
434
+ debug.error(error);
435
+ setErrorMessage(error);
436
+ }
437
+ batchingMessagesRef.current = [];
438
+ // Allow event loop to process delivery for tests
439
+ setTimeout(() => {}, 0);
440
+ }
441
+ if (batchingTimeoutRef.current) {
442
+ clearTimeout(batchingTimeoutRef.current);
443
+ batchingTimeoutRef.current = null;
444
+ }
445
+ batchingErrorRef.current = false;
446
+ };
447
+ }, []);
448
+
304
449
  return {
305
450
  channelName: resolvedChannelName,
306
451
  messages,
307
452
  sentMessages,
453
+ isPingInProgress,
454
+ ping,
308
455
  postMessage,
309
456
  clearReceivedMessages,
310
457
  clearSentMessages,
@@ -65,7 +65,7 @@ describe('BroadcastProvider', () => {
65
65
 
66
66
  // The provider creates two channels - one in the provider and one in useBroadcastChannel
67
67
  expect(mockChannels).toHaveLength(2);
68
- expect(mockChannels[0].name).toBe('test-channel-');
68
+ expect(mockChannels[0].name).toBe('test-channel');
69
69
  expect(mockChannels[1].name).toBe('test-channel');
70
70
  });
71
71
 
@@ -16,7 +16,12 @@ class MockBroadcastChannel {
16
16
  if (channel.onmessage) {
17
17
  // Use setTimeout to simulate async behavior
18
18
  setTimeout(() => {
19
- channel.onmessage!({ data } as MessageEvent);
19
+ // Support both single and batched (array) messages
20
+ if (Array.isArray(data)) {
21
+ data.forEach(msg => channel.onmessage!({ data: msg } as MessageEvent));
22
+ } else {
23
+ channel.onmessage!({ data } as MessageEvent);
24
+ }
20
25
  }, 0);
21
26
  }
22
27
  });
@@ -37,7 +42,12 @@ class MockBroadcastChannel {
37
42
  simulateMessage(data: any) {
38
43
  if (this.onmessage) {
39
44
  setTimeout(() => {
40
- this.onmessage!({ data } as MessageEvent);
45
+ // Support both single and batched (array) messages
46
+ if (Array.isArray(data)) {
47
+ data.forEach(msg => this.onmessage!({ data: msg } as MessageEvent));
48
+ } else {
49
+ this.onmessage!({ data } as MessageEvent);
50
+ }
41
51
  }, 0);
42
52
  }
43
53
  }
@@ -74,6 +84,7 @@ describe('useBroadcastChannel - Same Tab Multi-Hook Behavior', () => {
74
84
  const testOptions = {
75
85
  cleaningInterval: 0, // Disable auto cleanup to avoid act warnings
76
86
  deduplicationTTL: 60000, // Long TTL for tests
87
+ batchingDelayMs: 0, // Disable batching for immediate delivery in tests
77
88
  };
78
89
 
79
90
  describe('1. Two Hooks with Different Source Names', () => {
@@ -204,8 +215,8 @@ describe('useBroadcastChannel - Same Tab Multi-Hook Behavior', () => {
204
215
  await waitForAsync();
205
216
 
206
217
  // Verify both hooks are working (channel name includes namespace suffix)
207
- expect(hook1.current.channelName).toBe(`${CHANNEL_NAME}-`);
208
- expect(hook2.current.channelName).toBe(`${CHANNEL_NAME}-`);
218
+ expect(hook1.current.channelName).toBe(`${CHANNEL_NAME}`);
219
+ expect(hook2.current.channelName).toBe(`${CHANNEL_NAME}`);
209
220
 
210
221
  // Unmount hook1
211
222
  unmount1();