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.
- package/CHANGELOG.md +14 -0
- package/README.md +26 -0
- package/dist/hooks/useBroadcastChannel.d.ts +10 -0
- package/dist/index.cjs.js +1 -1
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.esm.js +1 -1
- package/dist/index.esm.js.map +1 -1
- package/dist/types/types.d.ts +9 -0
- package/dist/utils/debug.d.ts +3 -0
- package/dist/utils/messageUtils.d.ts +2 -2
- package/package.json +1 -1
- package/src/hooks/useBroadcastChannel.ts +165 -18
- package/src/tests/BroadcastProvider.test.tsx +1 -1
- package/src/tests/useBroadcastChannel.same-tab.test.tsx +15 -4
- package/src/tests/useBroadcastChannel.test.tsx +1024 -691
- package/src/types/types.ts +14 -0
- package/src/utils/debug.ts +3 -0
- package/src/utils/messageUtils.ts +2 -2
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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',
|
|
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 [
|
|
416
|
+
for (const [key, timestamp] of receivedMessageIds.current.entries()) {
|
|
295
417
|
if (now - timestamp >= deduplicationTTL) {
|
|
296
|
-
receivedMessageIds.current.delete(
|
|
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
|
-
|
|
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
|
-
|
|
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();
|