react-broadcast-sync 1.2.1 → 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.
- package/CHANGELOG.md +14 -0
- package/README.md +92 -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 +13 -0
- package/dist/utils/debug.d.ts +4 -0
- package/dist/utils/messageUtils.d.ts +2 -2
- package/package.json +1 -1
- package/src/hooks/useBroadcastChannel.ts +143 -33
- package/src/tests/useBroadcastChannel.test.tsx +944 -342
- package/src/types/types.ts +19 -0
- package/src/utils/debug.ts +5 -0
- package/src/utils/messageUtils.ts +2 -2
|
@@ -3,9 +3,10 @@ import {
|
|
|
3
3
|
BroadcastActions,
|
|
4
4
|
BroadcastMessage,
|
|
5
5
|
BroadcastOptions,
|
|
6
|
-
ClearMessage,
|
|
7
6
|
ClearReceivedMessagesOptions,
|
|
8
7
|
ClearSentMessagesOptions,
|
|
8
|
+
GetLatestMessageOptions,
|
|
9
|
+
InternalMessage,
|
|
9
10
|
SendMessageOptions,
|
|
10
11
|
} from '../types/types';
|
|
11
12
|
import {
|
|
@@ -14,16 +15,27 @@ import {
|
|
|
14
15
|
isMessageExpired,
|
|
15
16
|
createMessage,
|
|
16
17
|
getInternalMessageType,
|
|
17
|
-
isInternalType,
|
|
18
18
|
isValidInternalClearMessage,
|
|
19
19
|
debounce,
|
|
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
|
+
|
|
27
39
|
export const useBroadcastChannel = (
|
|
28
40
|
channelName: string,
|
|
29
41
|
options: BroadcastOptions = {}
|
|
@@ -42,23 +54,34 @@ export const useBroadcastChannel = (
|
|
|
42
54
|
const [messages, setMessages] = useState<BroadcastMessage[]>([]);
|
|
43
55
|
const [sentMessages, setSentMessages] = useState<BroadcastMessage[]>([]);
|
|
44
56
|
const [error, setError] = useState<string | null>(null);
|
|
57
|
+
const [isPingInProgress, setIsPingInProgress] = useState(false);
|
|
45
58
|
|
|
46
59
|
// Refs
|
|
47
60
|
const channel = useRef<BroadcastChannel | null>(null);
|
|
48
61
|
const receivedMessageIds = useRef(new Map<string, number>());
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
62
|
+
const activeSourcesCollector = useRef<Set<string> | null>(null);
|
|
63
|
+
|
|
64
|
+
// Memoized values
|
|
65
|
+
const source = useMemo(() => sourceName || generateSourceName(), [sourceName]);
|
|
66
|
+
const internalTypes = useMemo(
|
|
67
|
+
() => ({
|
|
68
|
+
CLEAR_SENT_MESSAGES: getInternalMessageType(
|
|
69
|
+
INTERNAL_MESSAGE_TYPES.CLEAR_SENT_MESSAGES,
|
|
70
|
+
channelName,
|
|
71
|
+
namespace
|
|
72
|
+
),
|
|
73
|
+
PING: getInternalMessageType(INTERNAL_MESSAGE_TYPES.PING, channelName, namespace),
|
|
74
|
+
PONG: getInternalMessageType(INTERNAL_MESSAGE_TYPES.PONG, channelName, namespace),
|
|
75
|
+
}),
|
|
76
|
+
[channelName, namespace]
|
|
77
|
+
);
|
|
57
78
|
|
|
58
79
|
const resolvedChannelName = useMemo(() => {
|
|
59
80
|
return `${channelName}-${namespace}`;
|
|
60
81
|
}, [channelName, namespace]);
|
|
61
82
|
|
|
83
|
+
const stableRegisteredTypes = useMemo(() => registeredTypes, [JSON.stringify(registeredTypes)]);
|
|
84
|
+
|
|
62
85
|
const performCleanup = useCallback(() => {
|
|
63
86
|
setMessages(prev => prev.filter(msg => !isMessageExpired(msg)));
|
|
64
87
|
}, []);
|
|
@@ -77,11 +100,51 @@ export const useBroadcastChannel = (
|
|
|
77
100
|
setTimeout(() => setError(null), 3000);
|
|
78
101
|
}, []);
|
|
79
102
|
|
|
103
|
+
const ping = useCallback(
|
|
104
|
+
(timeoutMs: number = 300): Promise<string[]> => {
|
|
105
|
+
if (isPingInProgress) {
|
|
106
|
+
debug.ping.inProgress();
|
|
107
|
+
return Promise.resolve([]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!channel.current) {
|
|
111
|
+
const error =
|
|
112
|
+
'BroadcastChannel is not supported in this browser. Please check browser compatibility.';
|
|
113
|
+
debug.error(error);
|
|
114
|
+
setErrorMessage(error);
|
|
115
|
+
return Promise.resolve([]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Set synchronously before returning the promise
|
|
119
|
+
if (flushSyncFn) {
|
|
120
|
+
flushSyncFn(() => setIsPingInProgress(true));
|
|
121
|
+
} else {
|
|
122
|
+
setIsPingInProgress(true);
|
|
123
|
+
}
|
|
124
|
+
const collector = new Set<string>();
|
|
125
|
+
activeSourcesCollector.current = collector;
|
|
126
|
+
|
|
127
|
+
channel.current.postMessage(createMessage(internalTypes.PING, null, source));
|
|
128
|
+
|
|
129
|
+
return new Promise(resolve => {
|
|
130
|
+
setTimeout(() => {
|
|
131
|
+
setIsPingInProgress(false);
|
|
132
|
+
if (activeSourcesCollector.current === collector) {
|
|
133
|
+
activeSourcesCollector.current = null;
|
|
134
|
+
}
|
|
135
|
+
resolve(Array.from(collector));
|
|
136
|
+
}, timeoutMs);
|
|
137
|
+
});
|
|
138
|
+
},
|
|
139
|
+
[internalTypes.PING, source, setErrorMessage, isPingInProgress, flushSyncFn]
|
|
140
|
+
);
|
|
141
|
+
|
|
80
142
|
const postMessage = useCallback(
|
|
81
143
|
(messageType: string, messageContent: any, options: SendMessageOptions = {}) => {
|
|
82
144
|
const channelCurrent = channel.current;
|
|
83
145
|
if (!channelCurrent) {
|
|
84
|
-
const error =
|
|
146
|
+
const error =
|
|
147
|
+
'BroadcastChannel is not supported in this browser. Please check browser compatibility.';
|
|
85
148
|
debug.error(error);
|
|
86
149
|
setErrorMessage(error);
|
|
87
150
|
return;
|
|
@@ -150,6 +213,24 @@ export const useBroadcastChannel = (
|
|
|
150
213
|
}
|
|
151
214
|
}, []);
|
|
152
215
|
|
|
216
|
+
const getLatestMessage = useCallback(
|
|
217
|
+
(options: GetLatestMessageOptions = {}) => {
|
|
218
|
+
const { source, type } = options;
|
|
219
|
+
|
|
220
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
221
|
+
const msg = messages[i];
|
|
222
|
+
const sourceMatches = !source || msg.source === source;
|
|
223
|
+
const typeMatches = !type || msg.type === type;
|
|
224
|
+
if (sourceMatches && typeMatches) {
|
|
225
|
+
return msg;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return null;
|
|
230
|
+
},
|
|
231
|
+
[messages]
|
|
232
|
+
);
|
|
233
|
+
|
|
153
234
|
const handleMessage = useCallback(
|
|
154
235
|
(event: MessageEvent) => {
|
|
155
236
|
try {
|
|
@@ -158,23 +239,12 @@ export const useBroadcastChannel = (
|
|
|
158
239
|
|
|
159
240
|
if (!isValidMessage(message)) return;
|
|
160
241
|
if (message.source === source) return;
|
|
161
|
-
if (
|
|
162
|
-
!isInternalType(message.type) &&
|
|
163
|
-
registeredTypes.length > 0 &&
|
|
164
|
-
!registeredTypes.includes(message.type)
|
|
165
|
-
)
|
|
166
|
-
return;
|
|
167
|
-
if (isMessageExpired(message)) {
|
|
168
|
-
debug.message.expired(message.id);
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
242
|
if (isValidInternalClearMessage(message)) {
|
|
173
243
|
if (message.type === internalTypes.CLEAR_SENT_MESSAGES) {
|
|
174
244
|
const { ids = [], types = [] } = (message as any).message || {};
|
|
175
245
|
|
|
176
|
-
setMessages(prev =>
|
|
177
|
-
prev.filter(msg => {
|
|
246
|
+
setMessages(prev => [
|
|
247
|
+
...prev.filter(msg => {
|
|
178
248
|
// Only consider messages from the same sender
|
|
179
249
|
if (msg.source !== message.source) return true;
|
|
180
250
|
|
|
@@ -184,14 +254,35 @@ export const useBroadcastChannel = (
|
|
|
184
254
|
const idMatches = ids.length === 0 || ids.includes(msg.id);
|
|
185
255
|
const typeMatches = types.length === 0 || types.includes(msg.type);
|
|
186
256
|
|
|
187
|
-
// Remove when BOTH criteria match (wildcards included)
|
|
188
257
|
return !(idMatches && typeMatches);
|
|
189
|
-
})
|
|
190
|
-
);
|
|
258
|
+
}),
|
|
259
|
+
]);
|
|
191
260
|
|
|
192
|
-
// After handling we don't want to process this internal control packet further
|
|
193
261
|
return;
|
|
194
262
|
}
|
|
263
|
+
if (message.type === internalTypes.PING) {
|
|
264
|
+
channel.current?.postMessage(createMessage(internalTypes.PONG, null, source));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (message.type === internalTypes.PONG) {
|
|
269
|
+
const pongSource = message.source;
|
|
270
|
+
const collector = activeSourcesCollector.current;
|
|
271
|
+
|
|
272
|
+
if (collector && collector.has(pongSource) === false) {
|
|
273
|
+
collector.add(pongSource);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (stableRegisteredTypes.length > 0 && !stableRegisteredTypes.includes(message.type)) {
|
|
280
|
+
debug.message.ignored(message.type);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (isMessageExpired(message)) {
|
|
284
|
+
debug.message.expired(message.id);
|
|
285
|
+
return;
|
|
195
286
|
}
|
|
196
287
|
|
|
197
288
|
const now = Date.now();
|
|
@@ -209,9 +300,26 @@ export const useBroadcastChannel = (
|
|
|
209
300
|
setErrorMessage(error);
|
|
210
301
|
}
|
|
211
302
|
},
|
|
212
|
-
[
|
|
303
|
+
[
|
|
304
|
+
source,
|
|
305
|
+
stableRegisteredTypes,
|
|
306
|
+
keepLatestMessage,
|
|
307
|
+
setErrorMessage,
|
|
308
|
+
internalTypes,
|
|
309
|
+
deduplicationTTL,
|
|
310
|
+
]
|
|
213
311
|
);
|
|
214
312
|
|
|
313
|
+
const closeChannel = useCallback(() => {
|
|
314
|
+
const bc = channel.current;
|
|
315
|
+
if (bc && typeof bc.close === 'function') {
|
|
316
|
+
bc.removeEventListener('message', handleMessage);
|
|
317
|
+
bc.close();
|
|
318
|
+
debug.channel.closed(resolvedChannelName);
|
|
319
|
+
channel.current = null;
|
|
320
|
+
}
|
|
321
|
+
}, [handleMessage, resolvedChannelName]);
|
|
322
|
+
|
|
215
323
|
useEffect(() => {
|
|
216
324
|
if (typeof BroadcastChannel === 'undefined') {
|
|
217
325
|
const error =
|
|
@@ -227,9 +335,7 @@ export const useBroadcastChannel = (
|
|
|
227
335
|
|
|
228
336
|
bc.addEventListener('message', handleMessage);
|
|
229
337
|
return () => {
|
|
230
|
-
|
|
231
|
-
bc.close();
|
|
232
|
-
debug.channel.closed(resolvedChannelName);
|
|
338
|
+
closeChannel();
|
|
233
339
|
};
|
|
234
340
|
}, [resolvedChannelName, handleMessage, setErrorMessage]);
|
|
235
341
|
|
|
@@ -269,10 +375,14 @@ export const useBroadcastChannel = (
|
|
|
269
375
|
channelName: resolvedChannelName,
|
|
270
376
|
messages,
|
|
271
377
|
sentMessages,
|
|
378
|
+
isPingInProgress,
|
|
379
|
+
ping,
|
|
272
380
|
postMessage,
|
|
273
381
|
clearReceivedMessages,
|
|
274
382
|
clearSentMessages,
|
|
383
|
+
getLatestMessage,
|
|
275
384
|
error,
|
|
385
|
+
closeChannel,
|
|
276
386
|
};
|
|
277
387
|
};
|
|
278
388
|
|