react-broadcast-sync 1.5.1 → 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 (54) hide show
  1. package/README.md +59 -11
  2. package/dist/BroadcastProvider.d.ts +2 -7
  3. package/dist/index.cjs.js +1 -1
  4. package/dist/index.cjs.js.map +1 -1
  5. package/dist/index.esm.js +1 -1
  6. package/dist/index.esm.js.map +1 -1
  7. package/dist/types/types.d.ts +17 -0
  8. package/dist/utils/debug.d.ts +7 -1
  9. package/package.json +6 -1
  10. package/.eslintignore +0 -5
  11. package/.github/workflows/ci.yml +0 -61
  12. package/.github/workflows/release.yml +0 -39
  13. package/.husky/pre-commit +0 -5
  14. package/.nvmrc +0 -1
  15. package/.prettierignore +0 -4
  16. package/.prettierrc +0 -9
  17. package/.releaserc.json +0 -22
  18. package/CHANGELOG.md +0 -90
  19. package/assets/react-broadcast-sync-logo.png +0 -0
  20. package/commitlint.config.js +0 -3
  21. package/demo/react-broadcast-sync-demo/README.md +0 -130
  22. package/demo/react-broadcast-sync-demo/index.html +0 -14
  23. package/demo/react-broadcast-sync-demo/package-lock.json +0 -6379
  24. package/demo/react-broadcast-sync-demo/package.json +0 -42
  25. package/demo/react-broadcast-sync-demo/public/assets/react-broadcast-sync-demo-video.gif +0 -0
  26. package/demo/react-broadcast-sync-demo/public/assets/react-broadcast-sync-logo.png +0 -0
  27. package/demo/react-broadcast-sync-demo/src/App.tsx +0 -277
  28. package/demo/react-broadcast-sync-demo/src/components/Counter.tsx +0 -63
  29. package/demo/react-broadcast-sync-demo/src/components/TextSync.tsx +0 -61
  30. package/demo/react-broadcast-sync-demo/src/components/TodoList.tsx +0 -186
  31. package/demo/react-broadcast-sync-demo/src/hooks/useCounterBroadcast.ts +0 -96
  32. package/demo/react-broadcast-sync-demo/src/hooks/useTextBroadcast.ts +0 -37
  33. package/demo/react-broadcast-sync-demo/src/hooks/useTodoBroadcast.ts +0 -249
  34. package/demo/react-broadcast-sync-demo/src/main.tsx +0 -11
  35. package/demo/react-broadcast-sync-demo/src/theme.ts +0 -62
  36. package/demo/react-broadcast-sync-demo/tsconfig.json +0 -26
  37. package/demo/react-broadcast-sync-demo/tsconfig.node.json +0 -10
  38. package/demo/react-broadcast-sync-demo/vite.config.ts +0 -12
  39. package/eslint.config.js +0 -38
  40. package/jest.config.cjs +0 -9
  41. package/jest.setup.ts +0 -1
  42. package/rollup.config.mjs +0 -98
  43. package/src/BroadcastProvider.tsx +0 -80
  44. package/src/hooks/useBroadcastChannel.ts +0 -464
  45. package/src/index.ts +0 -9
  46. package/src/tests/BroadcastProvider.test.tsx +0 -163
  47. package/src/tests/debug.test.ts +0 -239
  48. package/src/tests/messageUtils.test.ts +0 -252
  49. package/src/tests/useBroadcastChannel.same-tab.test.tsx +0 -992
  50. package/src/tests/useBroadcastChannel.test.tsx +0 -1243
  51. package/src/types/types.ts +0 -124
  52. package/src/utils/debug.ts +0 -54
  53. package/src/utils/messageUtils.ts +0 -122
  54. package/tsconfig.json +0 -36
@@ -1,80 +0,0 @@
1
- import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react';
2
- import { useBroadcastChannel } from './hooks/useBroadcastChannel';
3
- import { BroadcastMessage } from './types/types';
4
-
5
- interface BroadcastContextValue {
6
- messages: BroadcastMessage[];
7
- error: string | null;
8
- channel: BroadcastChannel | null;
9
- }
10
-
11
- const BroadcastChannelContext = createContext<BroadcastContextValue | undefined>(undefined);
12
-
13
- interface ErrorBoundaryProps {
14
- children: React.ReactNode;
15
- }
16
-
17
- class ErrorBoundary extends React.Component<ErrorBoundaryProps, { hasError: boolean }> {
18
- state = { hasError: false };
19
-
20
- static getDerivedStateFromError() {
21
- return { hasError: true };
22
- }
23
-
24
- componentDidCatch(error: Error) {
25
- console.error('BroadcastChannel error:', error);
26
- }
27
-
28
- render() {
29
- if (this.state.hasError) {
30
- return <div>Error occurred in BroadcastProvider</div>;
31
- }
32
- return this.props.children;
33
- }
34
- }
35
-
36
- interface BroadcastProviderProps {
37
- channelName: string;
38
- children: React.ReactNode;
39
- }
40
-
41
- export const BroadcastProvider: React.FC<BroadcastProviderProps> = ({ children, channelName }) => {
42
- const { messages, error: hookError } = useBroadcastChannel(channelName);
43
-
44
- const [error, setError] = React.useState<string | null>(null);
45
- const channel = useRef<BroadcastChannel | null>(null);
46
-
47
- useEffect(() => {
48
- if (typeof BroadcastChannel === 'undefined') {
49
- setError(
50
- 'BroadcastChannel is not supported in this browser. Please check browser compatibility.'
51
- );
52
- return;
53
- }
54
- const bc = new BroadcastChannel(channelName);
55
- channel.current = bc;
56
-
57
- return () => {
58
- bc.close();
59
- };
60
- }, [channelName]);
61
-
62
- const value = useMemo(
63
- () => ({ messages, error: error || hookError, channel: channel.current }),
64
- [messages, error, hookError]
65
- );
66
-
67
- return (
68
- <ErrorBoundary>
69
- <BroadcastChannelContext.Provider value={value}>{children}</BroadcastChannelContext.Provider>
70
- </ErrorBoundary>
71
- );
72
- };
73
-
74
- export const useBroadcastProvider = (): BroadcastContextValue => {
75
- const context = useContext(BroadcastChannelContext);
76
- if (!context) {
77
- throw new Error('useBroadcastProvider must be used within a BroadcastProvider');
78
- }
79
- return context;
80
- };
@@ -1,464 +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(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
-
158
- const postMessage = useCallback(
159
- (messageType: string, messageContent: any, options: SendMessageOptions = {}) => {
160
- const channelCurrent = channel.current;
161
- if (!channelCurrent) {
162
- const error =
163
- 'BroadcastChannel is not supported in this browser. Please check browser compatibility.';
164
- debug.error(error);
165
- setErrorMessage(error);
166
- return;
167
- }
168
-
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
- }
203
- }
204
- debug.message.sent(message);
205
- setSentMessages(prev => [...prev, message]);
206
- },
207
- [source, setErrorMessage, batchingDelayMs, excludedBatchMessageTypes]
208
- );
209
-
210
- const clearReceivedMessages = useCallback((options: ClearReceivedMessagesOptions = {}) => {
211
- const hasFilters = Boolean(
212
- (options.ids && options.ids.length) ||
213
- (options.types && options.types.length) ||
214
- (options.sources && options.sources.length)
215
- );
216
-
217
- setMessages(prev =>
218
- hasFilters
219
- ? prev.filter(
220
- msg =>
221
- !(options.ids && options.ids.includes(msg.id)) &&
222
- !(options.types && options.types.includes(msg.type)) &&
223
- !(options.sources && options.sources.includes(msg.source))
224
- )
225
- : []
226
- );
227
- }, []);
228
-
229
- const clearSentMessages = useCallback((options: ClearSentMessagesOptions = {}) => {
230
- const { ids = [], types = [], sync = false } = options ?? {};
231
- setSentMessages(prev =>
232
- ids.length > 0 || types.length > 0
233
- ? prev.filter(msg => {
234
- // Only consider messages from the same sender
235
- if (msg.source !== source) return true;
236
-
237
- // Decide whether this message should be cleared:
238
- // - If the ids array is empty, treat as wildcard (match all ids)
239
- // - If the types array is empty, treat as wildcard (match all types)
240
- const idMatches = ids.length === 0 || ids.includes(msg.id);
241
- const typeMatches = types.length === 0 || types.includes(msg.type);
242
-
243
- // Remove when BOTH criteria match (wildcards included)
244
- return !(idMatches && typeMatches);
245
- })
246
- : []
247
- );
248
- if (sync) {
249
- channel.current?.postMessage(
250
- createMessage(
251
- internalTypes.CLEAR_SENT_MESSAGES,
252
- { ids: options.ids ?? [], types: options.types ?? [] },
253
- source
254
- )
255
- );
256
- }
257
- }, []);
258
-
259
- const getLatestMessage = useCallback(
260
- (options: GetLatestMessageOptions = {}) => {
261
- const { source, type } = options;
262
-
263
- for (let i = messages.length - 1; i >= 0; i--) {
264
- const msg = messages[i];
265
- const sourceMatches = !source || msg.source === source;
266
- const typeMatches = !type || msg.type === type;
267
- if (sourceMatches && typeMatches) {
268
- return msg;
269
- }
270
- }
271
-
272
- return null;
273
- },
274
- [messages]
275
- );
276
-
277
- const handleMessage = useCallback(
278
- (event: MessageEvent) => {
279
- try {
280
- const message: BroadcastMessage = event.data;
281
- debug.message.received(message);
282
-
283
- if (!isValidMessage(message)) return;
284
- if (message.source === source) return;
285
- if (isValidInternalClearMessage(message)) {
286
- if (message.type === internalTypes.CLEAR_SENT_MESSAGES) {
287
- const { ids = [], types = [] } = (message as any).message || {};
288
-
289
- setMessages(prev => [
290
- ...prev.filter(msg => {
291
- // Only consider messages from the same sender
292
- if (msg.source !== message.source) return true;
293
-
294
- // Decide whether this message should be cleared:
295
- // - If the ids array is empty, treat as wildcard (match all ids)
296
- // - If the types array is empty, treat as wildcard (match all types)
297
- const idMatches = ids.length === 0 || ids.includes(msg.id);
298
- const typeMatches = types.length === 0 || types.includes(msg.type);
299
-
300
- return !(idMatches && typeMatches);
301
- }),
302
- ]);
303
-
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
-
319
- return;
320
- }
321
- }
322
- if (stableRegisteredTypes.length > 0 && !stableRegisteredTypes.includes(message.type)) {
323
- debug.message.ignored(message.type);
324
- return;
325
- }
326
- if (isMessageExpired(message)) {
327
- debug.message.expired(message.id);
328
- return;
329
- }
330
-
331
- const now = Date.now();
332
- const receivedAt = receivedMessageIds.current.get(message.id);
333
- if (receivedAt && now - receivedAt < deduplicationTTL) {
334
- debug.message.duplicate(message.id);
335
- return;
336
- }
337
-
338
- receivedMessageIds.current.set(message.id, now);
339
- setMessages(prev => (keepLatestMessage ? [message] : [...prev, message]));
340
- } catch (e) {
341
- const error = 'Error processing broadcast message';
342
- debug.error(error);
343
- setErrorMessage(error);
344
- }
345
- },
346
- [
347
- source,
348
- stableRegisteredTypes,
349
- keepLatestMessage,
350
- setErrorMessage,
351
- internalTypes,
352
- deduplicationTTL,
353
- ]
354
- );
355
-
356
- const closeChannel = useCallback(() => {
357
- const bc = channel.current;
358
- if (bc && typeof bc.close === 'function') {
359
- bc.removeEventListener('message', handleMessage);
360
- bc.close();
361
- debug.channel.closed(resolvedChannelName);
362
- channel.current = null;
363
- }
364
- }, [handleMessage, resolvedChannelName]);
365
-
366
- useEffect(() => {
367
- if (typeof BroadcastChannel === 'undefined') {
368
- const error =
369
- 'BroadcastChannel is not supported in this browser. Please check browser compatibility.';
370
- debug.error(error);
371
- setErrorMessage(error);
372
- return;
373
- }
374
-
375
- const bc = new BroadcastChannel(resolvedChannelName);
376
- channel.current = bc;
377
- debug.channel.created(resolvedChannelName);
378
-
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
- });
389
- return () => {
390
- closeChannel();
391
- };
392
- }, [resolvedChannelName, handleMessage, setErrorMessage]);
393
-
394
- useEffect(() => {
395
- if (cleaningInterval <= 0) return;
396
-
397
- const interval = setInterval(() => {
398
- debug.cleanup.started();
399
- const before = messages.length;
400
- debouncedCleanup();
401
- debug.cleanup.completed(before - messages.length);
402
- }, cleaningInterval);
403
-
404
- return () => {
405
- clearInterval(interval);
406
- // Cancel any pending debounced cleanup on unmount
407
- if (cleanupDebounceMs > 0) {
408
- debouncedCleanup.cancel?.();
409
- }
410
- };
411
- }, [cleaningInterval, debouncedCleanup, cleanupDebounceMs]);
412
-
413
- useEffect(() => {
414
- const interval = setInterval(() => {
415
- const now = Date.now();
416
- for (const [key, timestamp] of receivedMessageIds.current.entries()) {
417
- if (now - timestamp >= deduplicationTTL) {
418
- receivedMessageIds.current.delete(key);
419
- }
420
- }
421
- }, 60 * 1000);
422
-
423
- return () => clearInterval(interval);
424
- }, [deduplicationTTL]);
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
-
449
- return {
450
- channelName: resolvedChannelName,
451
- messages,
452
- sentMessages,
453
- isPingInProgress,
454
- ping,
455
- postMessage,
456
- clearReceivedMessages,
457
- clearSentMessages,
458
- getLatestMessage,
459
- error,
460
- closeChannel,
461
- };
462
- };
463
-
464
- 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';