react-broadcast-sync 1.6.0 → 1.9.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/README.md +83 -13
- package/dist/BroadcastProvider.d.ts +2 -1
- 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 +17 -0
- package/package.json +6 -1
- package/.eslintignore +0 -5
- package/.github/workflows/ci.yml +0 -61
- package/.github/workflows/release.yml +0 -39
- package/.husky/pre-commit +0 -5
- package/.nvmrc +0 -1
- package/.prettierignore +0 -4
- package/.prettierrc +0 -9
- package/.releaserc.json +0 -22
- package/CHANGELOG.md +0 -97
- package/assets/react-broadcast-sync-logo.png +0 -0
- package/commitlint.config.js +0 -3
- package/demo/react-broadcast-sync-demo/README.md +0 -130
- package/demo/react-broadcast-sync-demo/index.html +0 -14
- package/demo/react-broadcast-sync-demo/package-lock.json +0 -6379
- package/demo/react-broadcast-sync-demo/package.json +0 -42
- package/demo/react-broadcast-sync-demo/public/assets/react-broadcast-sync-demo-video.gif +0 -0
- package/demo/react-broadcast-sync-demo/public/assets/react-broadcast-sync-logo.png +0 -0
- package/demo/react-broadcast-sync-demo/src/App.tsx +0 -277
- package/demo/react-broadcast-sync-demo/src/components/Counter.tsx +0 -63
- package/demo/react-broadcast-sync-demo/src/components/TextSync.tsx +0 -61
- package/demo/react-broadcast-sync-demo/src/components/TodoList.tsx +0 -186
- package/demo/react-broadcast-sync-demo/src/hooks/useCounterBroadcast.ts +0 -96
- package/demo/react-broadcast-sync-demo/src/hooks/useTextBroadcast.ts +0 -37
- package/demo/react-broadcast-sync-demo/src/hooks/useTodoBroadcast.ts +0 -249
- package/demo/react-broadcast-sync-demo/src/main.tsx +0 -11
- package/demo/react-broadcast-sync-demo/src/theme.ts +0 -62
- package/demo/react-broadcast-sync-demo/tsconfig.json +0 -26
- package/demo/react-broadcast-sync-demo/tsconfig.node.json +0 -10
- package/demo/react-broadcast-sync-demo/vite.config.ts +0 -12
- package/eslint.config.js +0 -38
- package/jest.config.cjs +0 -9
- package/jest.setup.ts +0 -1
- package/rollup.config.mjs +0 -98
- package/src/BroadcastProvider.tsx +0 -28
- package/src/hooks/useBroadcastChannel.ts +0 -513
- package/src/index.ts +0 -9
- package/src/tests/BroadcastProvider.test.tsx +0 -157
- package/src/tests/debug.test.ts +0 -273
- package/src/tests/messageUtils.test.ts +0 -252
- package/src/tests/useBroadcastChannel.same-tab.test.tsx +0 -992
- package/src/tests/useBroadcastChannel.test.tsx +0 -1243
- package/src/types/types.ts +0 -124
- package/src/utils/debug.ts +0 -80
- package/src/utils/messageUtils.ts +0 -122
- 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
|
-
});
|