use-broadcast-ts
Version:
Use the Broadcast Channel API in React easily with hooks or Zustand, and Typescript!
419 lines (372 loc) • 9.81 kB
JavaScript
import { useState, useRef, useEffect } from 'react';
/**
* Our hook will return an object with three properties:
* - send: a function that will send a message to all other tabs
* - state: the current state of the broadcast
* - subscribe: a function that will subscribe to the broadcast (Only if options.subscribe is true)
*/
/**
* The options for the useBroadcast hook
*/
/**
*
* @param name The name of the broadcast channel
* @param val The initial value of the broadcast
* @returns Returns an object with three properties: send, state and subscribe
*/
const useBroadcast = (name, val, options) => {
/**
* Store the state of the broadcast
*/
const [state, setState] = useState(val);
/**
* Store the BroadcastChannel instance
*/
const channel = useRef(null);
/**
* Store the listeners
*/
const listeners = useRef([]);
/**
* This function send the value to all the other tabs
* @param val The value to send
*/
const send = val => {
if (!channel.current) {
return;
}
/**
* Send the value to all the other tabs
*/
channel.current.postMessage(val);
if (!(options != null && options.subscribe)) {
setState(val);
}
/**
* Dispatch the event to the listeners
*/
listeners.current.forEach(listener => listener(val));
};
/**
* This function subscribe to the broadcast
* @param callback The callback function
* @returns Returns a function that unsubscribe the callback
*/
const subscribe = callback => {
/**
* Add the callback to the listeners
*/
listeners.current.push(callback);
/**
* Return a function that unsubscribe the callback
*/
return () => listeners.current.splice(listeners.current.indexOf(callback), 1);
};
useEffect(() => {
/**
* If BroadcastChannel is not supported, we log an error and return
*/
if (typeof window === "undefined") {
console.error("Window is undefined!");
return;
}
if (!window.BroadcastChannel) {
console.error("BroadcastChannel is not supported!");
return;
}
/**
* If the channel is null, we create a new one
*/
if (!channel.current) {
channel.current = new BroadcastChannel(name);
}
/**
* Subscribe to the message event
* @param e The message event
*/
channel.current.onmessage = e => {
/**
* Update the state
*/
if (!(options != null && options.subscribe)) {
setState(e.data);
}
/**
* Dispatch an event to the listeners
*/
listeners.current.forEach(listener => listener(e.data));
};
/**
* Cleanup
*/
return () => {
if (!channel.current) {
return;
}
channel.current.close();
channel.current = null;
};
}, [name, options]);
return {
send,
state,
subscribe
};
};
/**
* The Shared type
*/
/**
* Type implementation of the Shared function
*/
/**
* Shared implementation
* @param f Zustand state creator
* @param options The options
*/
const sharedImpl = (f, options) => (set, get, store) => {
var _options$name;
/**
* The broadcast channel is not supported in SSR
*/
if (typeof window === 'undefined' &&
// @ts-expect-error WorkerGlobalScope is not defined in the types
!(typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope)) {
console.warn('BroadcastChannel is not supported in this environment. The store will not be shared.');
return f(set, get, store);
}
/**
* If BroadcastChannel is not supported, return the basic store
*/
if (typeof BroadcastChannel === 'undefined') {
console.warn('BroadcastChannel is not supported in this browser. The store will not be shared.');
return f(set, get, store);
}
/**
* Types
*/
/**
* Is the store synced with the other tabs
*/
let isSynced = get() !== undefined;
/**
* Is this tab / window the main tab / window
* When a new tab / window is opened, it will be synced with the main
*/
let isMain = false;
/**
* The broadcast channel name
*/
const name = (_options$name = options == null ? void 0 : options.name) != null ? _options$name : f.toString();
/**
* The id of the tab / window
*/
let id = 0;
/**
* Store a list of all the tabs / windows
* Only for the main tab / window
*/
const tabs = [0];
/**
* Create the broadcast channel
*/
const channel = new BroadcastChannel(name);
const sendChangeToOtherTabs = () => {
let state = get();
/**
* If the partialize function is provided, use it to parse the state
*/
if (options != null && options.partialize) {
// Partialize the state
state = options.partialize(state);
}
/**
* If the user did not specify that serialization should be skipped, remove unsupported types
*/
if (!(options != null && options.skipSerialization)) {
// Remove unserializable types (functions, Symbols, etc.) from the state.
state = JSON.parse(JSON.stringify(state));
}
/**
* Send the states to all the other tabs
*/
channel.postMessage({
action: 'change',
state
});
};
/**
* Handle the Zustand set function
* Trigger a postMessage to all the other tabs
*/
const onSet = (...args) => {
/**
* Update the states
*/
set(...args);
/**
* If the stores should not be synced, return.
*/
if (options != null && options.unsync) {
return;
}
sendChangeToOtherTabs();
};
/**
* Subscribe to the broadcast channel
*/
channel.onmessage = e => {
if (e.data.action === 'sync') {
/**
* If this tab / window is not the main, return
*/
if (!isMain) {
return;
}
sendChangeToOtherTabs();
/**
* Set the new tab / window id
*/
const new_id = tabs[tabs.length - 1] + 1;
tabs.push(new_id);
options == null ? void 0 : options.onTabsChange == null ? void 0 : options.onTabsChange(tabs);
channel.postMessage({
action: 'add_new_tab',
id: new_id
});
return;
}
/**
* Set an id for the tab / window if it doesn't have one
*/
if (e.data.action === 'add_new_tab' && !isMain && id === 0) {
id = e.data.id;
return;
}
/**
* On receiving a new state, update the state
*/
if (e.data.action === 'change') {
/**
* Update the state
*/
set(state => options != null && options.merge ? options.merge(state, e.data.state) : e.data.state);
/**
* Set the synced attribute
*/
isSynced = true;
}
/**
* On receiving a close message, remove the tab / window id from the list
*/
if (e.data.action === 'close') {
if (!isMain) {
return;
}
const index = tabs.indexOf(e.data.id);
if (index !== -1) {
tabs.splice(index, 1);
options == null ? void 0 : options.onTabsChange == null ? void 0 : options.onTabsChange(tabs);
}
}
/**
* On receiving a change_main message, change the main tab / window
*/
if (e.data.action === 'change_main') {
if (e.data.id === id) {
isMain = true;
tabs.splice(0, tabs.length, ...e.data.tabs);
options == null ? void 0 : options.onBecomeMain == null ? void 0 : options.onBecomeMain(id);
}
}
};
/**
* Synchronize with the main tab
*/
const synchronize = () => {
var _options$mainTimeout;
channel.postMessage({
action: 'sync'
});
/**
* If isSynced is false after 100ms, this tab is the main tab
*/
setTimeout(() => {
if (!isSynced) {
isMain = true;
isSynced = true;
options == null ? void 0 : options.onBecomeMain == null ? void 0 : options.onBecomeMain(id);
}
}, (_options$mainTimeout = options == null ? void 0 : options.mainTimeout) != null ? _options$mainTimeout : 100);
};
/**
* Handle case when the tab / window is closed
*/
const onClose = () => {
/**
* For some reason, the channel can be closed abruptly, when redirecting for example
* So we need to wrap this in a try catch block
*/
try {
channel.postMessage({
action: 'close',
id
});
/**
* If we're closing the main, make the second the new main
*/
if (isMain) {
/**
* If there is only one tab left, close the channel and return
*/
if (tabs.length === 1) {
/**
* Clean up
*/
channel.close();
return;
}
const remaining_tabs = tabs.filter(tab => tab !== id);
channel.postMessage({
action: 'change_main',
id: remaining_tabs[0],
tabs: remaining_tabs
});
return;
}
} catch (e) {}
};
/**
* Add close event listener
*/
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', onClose);
}
/**
* Synchronize with the main tab
*/
if (!isSynced) {
synchronize();
}
/**
* Modify and return the Zustand store
*/
store.setState = onSet;
return f(onSet, get, store);
};
/**
* Shared middleware
*
* @example
* import { create } from 'zustand';
* import { shared } from 'use-broadcast-ts';
*
* const useStore = create(
* shared(
* (set) => ({ count: 0 }),
* { name: 'my-store' }
* )
* );
*/
const shared = sharedImpl;
export { shared, useBroadcast };