react-broadcast-sync 1.0.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.
@@ -0,0 +1,232 @@
1
+ import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
2
+ import { BroadcastActions, BroadcastMessage, BroadcastOptions, SendMessageOptions } from '../types/types';
3
+ import {
4
+ generateSourceName,
5
+ isValidMessage,
6
+ isMessageExpired,
7
+ createMessage,
8
+ getInternalMessageType,
9
+ isInternalType,
10
+ isValidInternalClearMessage,
11
+ debounce,
12
+ } from '../utils/messageUtils';
13
+ import { debug } from '../utils/debug';
14
+
15
+ const INTERNAL_MESSAGE_TYPES = {
16
+ CLEAR_MESSAGE: 'CLEAR_MESSAGE',
17
+ CLEAR_ALL_MESSAGES: 'CLEAR_ALL_MESSAGES',
18
+ } as const;
19
+
20
+ export const useBroadcastChannel = (
21
+ channelName: string,
22
+ options: BroadcastOptions = {}
23
+ ): BroadcastActions => {
24
+ const {
25
+ sourceName,
26
+ cleaningInterval = 1000,
27
+ keepLatestMessage = false,
28
+ registeredTypes = [],
29
+ namespace = '',
30
+ deduplicationTTL = 5 * 60 * 1000,
31
+ cleanupDebounceMs = 0
32
+ } = options;
33
+
34
+ // State
35
+ const [messages, setMessages] = useState<BroadcastMessage[]>([]);
36
+ const [sentMessages, setSentMessages] = useState<BroadcastMessage[]>([]);
37
+ const [error, setError] = useState<string | null>(null);
38
+
39
+ // Refs
40
+ const channel = useRef<BroadcastChannel | null>(null);
41
+ const receivedMessageIds = useRef(new Map<string, number>());
42
+ const source = useRef(sourceName || generateSourceName()).current;
43
+ const internalTypes = useRef({
44
+ CLEAR_MESSAGE: getInternalMessageType(INTERNAL_MESSAGE_TYPES.CLEAR_MESSAGE, channelName, namespace),
45
+ CLEAR_ALL_MESSAGES: getInternalMessageType(INTERNAL_MESSAGE_TYPES.CLEAR_ALL_MESSAGES, channelName, namespace),
46
+ }).current;
47
+
48
+ const resolvedChannelName = useMemo(() => {
49
+ return `${channelName}-${namespace}`;
50
+ }, [channelName, namespace]);
51
+
52
+ const performCleanup = useCallback(() => {
53
+ setMessages(prev => prev.filter(msg => !isMessageExpired(msg)));
54
+ }, []);
55
+
56
+ const debouncedCleanup = useRef<{
57
+ (...args: Parameters<typeof performCleanup>): void;
58
+ cancel: () => void;
59
+ }>(
60
+ cleanupDebounceMs > 0
61
+ ? debounce(performCleanup, cleanupDebounceMs)
62
+ : Object.assign(performCleanup, { cancel: () => {} })
63
+ ).current;
64
+
65
+ const setErrorMessage = useCallback((error: string) => {
66
+ setError(error);
67
+ setTimeout(() => setError(null), 3000);
68
+ }, []);
69
+
70
+ const clearMessage = useCallback((id: string) => {
71
+ setMessages(prev => prev.filter(msg => msg.id !== id));
72
+ setSentMessages(prev => prev.filter(msg => msg.id !== id));
73
+ channel.current?.postMessage({
74
+ id,
75
+ type: internalTypes.CLEAR_MESSAGE,
76
+ source
77
+ });
78
+ debug.message.cleared(id);
79
+ }, [source, internalTypes.CLEAR_MESSAGE]);
80
+
81
+ const clearAllMessages = useCallback(() => {
82
+ setMessages([]);
83
+ setSentMessages([]);
84
+ channel.current?.postMessage({
85
+ id: internalTypes.CLEAR_ALL_MESSAGES,
86
+ type: internalTypes.CLEAR_ALL_MESSAGES,
87
+ source
88
+ });
89
+ }, [source, internalTypes.CLEAR_ALL_MESSAGES]);
90
+
91
+ const clearSentMessage = useCallback((id: string) => {
92
+ setSentMessages(prev => prev.filter(msg => msg.id !== id));
93
+ channel.current?.postMessage({
94
+ id,
95
+ type: internalTypes.CLEAR_MESSAGE,
96
+ source
97
+ });
98
+ }, [source, internalTypes.CLEAR_MESSAGE]);
99
+
100
+ const postMessage = useCallback((
101
+ messageType: string,
102
+ messageContent: any,
103
+ options: SendMessageOptions = {}
104
+ ) => {
105
+ const channelCurrent = channel.current;
106
+ if (!channelCurrent) {
107
+ const error = 'Channel not available';
108
+ debug.error(error);
109
+ setErrorMessage(error);
110
+ return;
111
+ }
112
+
113
+ try {
114
+ const message = createMessage(messageType, messageContent, source, options);
115
+ channelCurrent.postMessage(message);
116
+ debug.message.sent(message);
117
+ setSentMessages(prev => [...prev, message]);
118
+ } catch (e) {
119
+ const error = 'Failed to send message';
120
+ debug.error(error);
121
+ setErrorMessage(error);
122
+ }
123
+ }, [source, setErrorMessage]);
124
+
125
+ const handleMessage = useCallback((event: MessageEvent) => {
126
+ try {
127
+ const message: BroadcastMessage = event.data;
128
+ debug.message.received(message);
129
+
130
+ if (!isValidMessage(message)) return;
131
+ if (message.source === source) return;
132
+ if (!isInternalType(message.type) && registeredTypes.length > 0 && !registeredTypes.includes(message.type)) return;
133
+ if (isMessageExpired(message)) {
134
+ debug.message.expired(message.id);
135
+ return;
136
+ }
137
+
138
+ if (isValidInternalClearMessage(message)) {
139
+ if (message.type === internalTypes.CLEAR_MESSAGE) {
140
+ setMessages(prev =>
141
+ prev.filter(msg => !(msg.id === message.id && msg.source === message.source))
142
+ );
143
+ return;
144
+ }
145
+
146
+ if (message.type === internalTypes.CLEAR_ALL_MESSAGES) {
147
+ setMessages(prev => prev.filter(msg => msg.source !== message.source));
148
+ return;
149
+ }
150
+ }
151
+
152
+ const now = Date.now();
153
+ const receivedAt = receivedMessageIds.current.get(message.id);
154
+ if (receivedAt && now - receivedAt < deduplicationTTL) {
155
+ debug.message.duplicate(message.id);
156
+ return;
157
+ }
158
+
159
+ receivedMessageIds.current.set(message.id, now);
160
+ setMessages(prev => keepLatestMessage ? [message] : [...prev, message]);
161
+ } catch (e) {
162
+ const error = 'Error processing broadcast message';
163
+ debug.error(error);
164
+ setErrorMessage(error);
165
+ }
166
+ }, [source, registeredTypes, keepLatestMessage, setErrorMessage, internalTypes, deduplicationTTL]);
167
+
168
+ useEffect(() => {
169
+ if (typeof BroadcastChannel === 'undefined') {
170
+ const error = 'BroadcastChannel is not supported in this browser. Please check browser compatibility.';
171
+ debug.error(error);
172
+ setErrorMessage(error);
173
+ return;
174
+ }
175
+
176
+ const bc = new BroadcastChannel(resolvedChannelName);
177
+ channel.current = bc;
178
+ debug.channel.created(resolvedChannelName);
179
+
180
+ bc.addEventListener('message', handleMessage);
181
+ return () => {
182
+ bc.removeEventListener('message', handleMessage);
183
+ bc.close();
184
+ debug.channel.closed(resolvedChannelName);
185
+ };
186
+ }, [resolvedChannelName, handleMessage, setErrorMessage]);
187
+
188
+ useEffect(() => {
189
+ if (cleaningInterval <= 0) return;
190
+
191
+ const interval = setInterval(() => {
192
+ debug.cleanup.started();
193
+ const before = messages.length;
194
+ debouncedCleanup();
195
+ debug.cleanup.completed(before - messages.length);
196
+ }, cleaningInterval);
197
+
198
+ return () => {
199
+ clearInterval(interval);
200
+ // Cancel any pending debounced cleanup on unmount
201
+ if (cleanupDebounceMs > 0) {
202
+ debouncedCleanup.cancel?.();
203
+ }
204
+ };
205
+ }, [cleaningInterval, debouncedCleanup, cleanupDebounceMs]);
206
+
207
+ useEffect(() => {
208
+ const interval = setInterval(() => {
209
+ const now = Date.now();
210
+ for (const [id, timestamp] of receivedMessageIds.current.entries()) {
211
+ if (now - timestamp >= deduplicationTTL) {
212
+ receivedMessageIds.current.delete(id);
213
+ }
214
+ }
215
+ }, 60 * 1000);
216
+
217
+ return () => clearInterval(interval);
218
+ }, [deduplicationTTL]);
219
+
220
+ return {
221
+ channelName: resolvedChannelName,
222
+ messages,
223
+ sentMessages,
224
+ postMessage,
225
+ clearMessage,
226
+ clearAllMessages,
227
+ clearSentMessage,
228
+ error,
229
+ };
230
+ };
231
+
232
+ export default useBroadcastChannel;
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
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';
@@ -0,0 +1,163 @@
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 creates two channels - one in the provider and one in useBroadcastChannel
67
+ expect(mockChannels).toHaveLength(2);
68
+ expect(mockChannels[0].name).toBe('test-channel-');
69
+ expect(mockChannels[1].name).toBe('test-channel');
70
+ });
71
+
72
+ it('provides context values to children', () => {
73
+ render(
74
+ <BroadcastProvider channelName="test-channel">
75
+ <TestComponent />
76
+ </BroadcastProvider>
77
+ );
78
+
79
+ expect(screen.getByTestId('error')).toHaveTextContent('');
80
+ expect(screen.getByTestId('messages')).toBeEmptyDOMElement();
81
+ });
82
+
83
+ it('handles messages through the provider', () => {
84
+ render(
85
+ <BroadcastProvider channelName="test-channel">
86
+ <TestComponent />
87
+ </BroadcastProvider>
88
+ );
89
+
90
+ const message = {
91
+ id: 'test-1',
92
+ type: 'test',
93
+ message: { text: 'Hello' },
94
+ source: 'other-tab',
95
+ timestamp: Date.now()
96
+ };
97
+
98
+ act(() => {
99
+ mockChannels[0].simulateMessage(message);
100
+ });
101
+
102
+ expect(screen.getByTestId('message-test-1')).toHaveTextContent('Hello');
103
+ });
104
+
105
+ it('handles errors when BroadcastChannel is not supported', () => {
106
+ const originalBroadcastChannel = global.BroadcastChannel;
107
+ delete (global as any).BroadcastChannel;
108
+
109
+ render(
110
+ <BroadcastProvider channelName="test-channel">
111
+ <TestComponent />
112
+ </BroadcastProvider>
113
+ );
114
+
115
+ expect(screen.getByTestId('error')).toHaveTextContent(
116
+ 'BroadcastChannel is not supported in this browser. Please check browser compatibility.'
117
+ );
118
+
119
+ // Restore BroadcastChannel for other tests
120
+ global.BroadcastChannel = originalBroadcastChannel;
121
+ });
122
+
123
+ it('cleans up channel on unmount', () => {
124
+ const { unmount } = render(
125
+ <BroadcastProvider channelName="test-channel">
126
+ <TestComponent />
127
+ </BroadcastProvider>
128
+ );
129
+
130
+ unmount();
131
+
132
+ expect(mockChannels[0].close).toHaveBeenCalled();
133
+ });
134
+
135
+ it('handles error boundary when child component throws', () => {
136
+ const ThrowComponent = () => {
137
+ throw new Error('Test error');
138
+ };
139
+
140
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
141
+
142
+ render(
143
+ <BroadcastProvider channelName="test-channel">
144
+ <ThrowComponent />
145
+ </BroadcastProvider>
146
+ );
147
+
148
+ expect(screen.getByText('Error occurred in BroadcastProvider')).toBeInTheDocument();
149
+ expect(consoleError).toHaveBeenCalledWith('BroadcastChannel error:', expect.any(Error));
150
+
151
+ consoleError.mockRestore();
152
+ });
153
+
154
+ it('throws error when useBroadcastProvider is used outside provider', () => {
155
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
156
+
157
+ expect(() => {
158
+ render(<TestComponent />);
159
+ }).toThrow('useBroadcastProvider must be used within a BroadcastProvider');
160
+
161
+ consoleError.mockRestore();
162
+ });
163
+ });
@@ -0,0 +1,199 @@
1
+ // Set debug mode before requiring the debug module
2
+ process.env.REACT_APP_DEBUG_BROADCAST = 'true';
3
+ import { debug } from '../utils/debug';
4
+
5
+ describe('debug', () => {
6
+ const originalEnv = process.env;
7
+ const originalConsole = {
8
+ log: console.log,
9
+ warn: console.warn,
10
+ error: console.error,
11
+ };
12
+
13
+ beforeEach(() => {
14
+ process.env = { ...originalEnv };
15
+ console.log = jest.fn();
16
+ console.warn = jest.fn();
17
+ console.error = jest.fn();
18
+ });
19
+
20
+ afterEach(() => {
21
+ console.log = originalConsole.log;
22
+ console.warn = originalConsole.warn;
23
+ console.error = originalConsole.error;
24
+ process.env = originalEnv;
25
+ });
26
+
27
+ describe('when debug mode is disabled', () => {
28
+ beforeEach(() => {
29
+ process.env.REACT_APP_DEBUG_BROADCAST = 'false';
30
+ });
31
+
32
+ it('does not log anything', () => {
33
+ debug.channel.created('test-channel');
34
+ debug.message.sent({ id: '123' });
35
+ debug.error('test error');
36
+
37
+ expect(console.log).not.toHaveBeenCalled();
38
+ expect(console.warn).not.toHaveBeenCalled();
39
+ expect(console.error).not.toHaveBeenCalled();
40
+ });
41
+ });
42
+
43
+ describe('when debug mode is enabled', () => {
44
+ beforeEach(() => {
45
+ process.env.REACT_APP_DEBUG_BROADCAST = 'true';
46
+ });
47
+
48
+ it('logs channel events', () => {
49
+ debug.channel.created('test-channel');
50
+ expect(console.log).toHaveBeenCalledWith(
51
+ expect.stringMatching(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\] \[react-broadcast-sync\]/),
52
+ 'Channel created:',
53
+ 'test-channel'
54
+ );
55
+
56
+ debug.channel.closed('test-channel');
57
+ expect(console.log).toHaveBeenCalledWith(
58
+ expect.stringMatching(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\] \[react-broadcast-sync\]/),
59
+ 'Channel closed:',
60
+ 'test-channel'
61
+ );
62
+ });
63
+
64
+ it('logs message events', () => {
65
+ const testMessage = { id: '123', type: 'test' };
66
+
67
+ debug.message.sent(testMessage);
68
+ expect(console.log).toHaveBeenCalledWith(
69
+ expect.stringMatching(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\] \[react-broadcast-sync\]/),
70
+ 'Message sent:',
71
+ testMessage
72
+ );
73
+
74
+ debug.message.received(testMessage);
75
+ expect(console.log).toHaveBeenCalledWith(
76
+ expect.stringMatching(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\] \[react-broadcast-sync\]/),
77
+ 'Message received:',
78
+ testMessage
79
+ );
80
+
81
+ debug.message.cleared('123');
82
+ expect(console.log).toHaveBeenCalledWith(
83
+ expect.stringMatching(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\] \[react-broadcast-sync\]/),
84
+ 'Message cleared:',
85
+ '123'
86
+ );
87
+
88
+ debug.message.expired('123');
89
+ expect(console.log).toHaveBeenCalledWith(
90
+ expect.stringMatching(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\] \[react-broadcast-sync\]/),
91
+ 'Message expired:',
92
+ '123'
93
+ );
94
+
95
+ debug.message.duplicate('123');
96
+ expect(console.warn).toHaveBeenCalledWith(
97
+ expect.stringMatching(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\] \[react-broadcast-sync\]/),
98
+ 'Duplicate message ignored:',
99
+ '123'
100
+ );
101
+ });
102
+
103
+ it('logs cleanup events', () => {
104
+ debug.cleanup.started();
105
+ expect(console.log).toHaveBeenCalledWith(
106
+ expect.stringMatching(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\] \[react-broadcast-sync\]/),
107
+ 'Cleanup started',
108
+ ''
109
+ );
110
+
111
+ debug.cleanup.completed(5);
112
+ expect(console.log).toHaveBeenCalledWith(
113
+ expect.stringMatching(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\] \[react-broadcast-sync\]/),
114
+ 'Cleanup completed:',
115
+ { removedCount: 5 }
116
+ );
117
+ });
118
+
119
+ it('logs errors', () => {
120
+ debug.error('test error');
121
+ expect(console.error).toHaveBeenCalledWith(
122
+ expect.stringMatching(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\] \[react-broadcast-sync\]/),
123
+ 'Error:',
124
+ 'test error'
125
+ );
126
+ });
127
+
128
+ it('logs info with no data', () => {
129
+ debug.channel.created('test-channel');
130
+ expect(console.log).toHaveBeenCalledWith(
131
+ expect.stringMatching(/\[.*react-broadcast-sync\]/),
132
+ 'Channel created:',
133
+ 'test-channel'
134
+ );
135
+ });
136
+
137
+ it('logs warn with no data', () => {
138
+ debug.message.duplicate('123');
139
+ expect(console.warn).toHaveBeenCalledWith(
140
+ expect.stringMatching(/\[.*react-broadcast-sync\]/),
141
+ 'Duplicate message ignored:',
142
+ '123'
143
+ );
144
+ });
145
+
146
+ it('logs error with no data', () => {
147
+ debug.error('error!');
148
+ expect(console.error).toHaveBeenCalledWith(
149
+ expect.stringMatching(/\[.*react-broadcast-sync\]/),
150
+ 'Error:',
151
+ 'error!'
152
+ );
153
+ });
154
+
155
+ it('logs message with no data', () => {
156
+ debug.cleanup.started();
157
+ expect(console.log).toHaveBeenCalledWith(
158
+ expect.stringContaining('[react-broadcast-sync]'),
159
+ 'Cleanup started',
160
+ ''
161
+ );
162
+ });
163
+ });
164
+
165
+ describe('when process.env is undefined', () => {
166
+ beforeEach(() => {
167
+ // @ts-ignore - Simulating environment where process.env is undefined
168
+ process.env = undefined;
169
+ });
170
+
171
+ it('does not log anything', () => {
172
+ debug.channel.created('test-channel');
173
+ expect(console.log).not.toHaveBeenCalled();
174
+ });
175
+ });
176
+
177
+ it('logs info messages', () => {
178
+ debug.channel.created('test-channel');
179
+ expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[react-broadcast-sync]'), 'Channel created:', 'test-channel');
180
+ });
181
+
182
+ it('logs warning messages', () => {
183
+ debug.message.duplicate('test-id');
184
+ expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('[react-broadcast-sync]'), 'Duplicate message ignored:', 'test-id');
185
+ });
186
+
187
+ it('logs error messages', () => {
188
+ debug.error('Test error');
189
+ expect(console.error).toHaveBeenCalledWith(expect.stringContaining('[react-broadcast-sync]'), 'Error:', 'Test error');
190
+ });
191
+
192
+ it('logs cleanup information', () => {
193
+ debug.cleanup.started();
194
+ expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[react-broadcast-sync]'), 'Cleanup started', '');
195
+
196
+ debug.cleanup.completed(2);
197
+ expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[react-broadcast-sync]'), 'Cleanup completed:', { removedCount: 2 });
198
+ });
199
+ });