UNPKG

use-broadcast-ts

Version:

Use the Broadcast Channel API in React easily with hooks or Zustand, and Typescript!

375 lines (321 loc) 8.56 kB
import { StateCreator, StoreMutatorIdentifier } from 'zustand'; export type SharedOptions<T = unknown> = { /** * The name of the broadcast channel * It must be unique */ name?: string; /** * Main timeout * If the main tab / window doesn't respond in this time, this tab / window will become the main * @default 100 ms */ mainTimeout?: number; /** * If true, the store will only synchronize once with the main tab. After that, the store will be unsynchronized. * @default false */ unsync?: boolean; /** * If true, will not serialize with JSON.parse(JSON.stringify(state)) the state before sending it. * This results in a performance boost, but it is on the user to ensure there are no unsupported types in their state. * @default false */ skipSerialization?: boolean; /** * Custom function to parse the state before sending it to the other tabs * @param state The state * @returns The parsed state */ partialize?: (state: T) => Partial<T>; /** * Custom function to merge the state after receiving it from the other tabs * @param state The current state * @param receivedState The state received from the other tab * @returns The restored state */ merge?: (state: T, receivedState: Partial<T>) => T; /** * Callback when this tab / window becomes the main tab / window * Triggered only in the main tab / window */ onBecomeMain?: (id: number) => void; /** * Callback when a new tab is opened / closed * Triggered only in the main tab / window */ onTabsChange?: (ids: number[]) => void; }; /** * The Shared type */ export type Shared = < T extends object, Mps extends [StoreMutatorIdentifier, unknown][] = [], Mcs extends [StoreMutatorIdentifier, unknown][] = [] >( f: StateCreator<T, Mps, Mcs>, options?: SharedOptions<T> ) => StateCreator<T, Mps, Mcs>; /** * Type implementation of the Shared function */ type SharedImpl = <T>(f: StateCreator<T, [], []>, options?: SharedOptions<T>) => StateCreator<T, [], []>; /** * Shared implementation * @param f Zustand state creator * @param options The options */ const sharedImpl: SharedImpl = (f, options) => (set, get, store) => { /** * 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 */ type T = ReturnType<typeof get>; type Item = { [key: string]: unknown }; type Message = | { action: 'sync'; } | { action: 'change'; state: Item; } | { action: 'add_new_tab'; id: number; } | { action: 'close'; id: number; } | { action: 'change_main'; id: number; tabs: number[]; }; /** * 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 ?? 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: number[] = [0]; /** * Create the broadcast channel */ const channel = new BroadcastChannel(name); const sendChangeToOtherTabs = () => { let state: Item = get() as Item; /** * If the partialize function is provided, use it to parse the state */ if (options?.partialize) { // Partialize the state state = options.partialize(state as T); } /** * If the user did not specify that serialization should be skipped, remove unsupported types */ if (!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 } as Message); }; /** * Handle the Zustand set function * Trigger a postMessage to all the other tabs */ const onSet: typeof set = (...args) => { /** * Update the states */ set(...(args as Parameters<typeof set>)); /** * If the stores should not be synced, return. */ if (options?.unsync) { return; } sendChangeToOtherTabs(); }; /** * Subscribe to the broadcast channel */ channel.onmessage = (e) => { if ((e.data as Message).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?.onTabsChange?.(tabs); channel.postMessage({ action: 'add_new_tab', id: new_id } as Message); return; } /** * Set an id for the tab / window if it doesn't have one */ if ((e.data as Message).action === 'add_new_tab' && !isMain && id === 0) { id = e.data.id; return; } /** * On receiving a new state, update the state */ if ((e.data as Message).action === 'change') { /** * Update the state */ set((state) => (options?.merge ? options.merge(state, e.data.state as Partial<T>) : 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 as Message).action === 'close') { if (!isMain) { return; } const index = tabs.indexOf(e.data.id); if (index !== -1) { tabs.splice(index, 1); options?.onTabsChange?.(tabs); } } /** * On receiving a change_main message, change the main tab / window */ if ((e.data as Message).action === 'change_main') { if (e.data.id === id) { isMain = true; tabs.splice(0, tabs.length, ...e.data.tabs); options?.onBecomeMain?.(id); } } }; /** * Synchronize with the main tab */ const synchronize = (): void => { channel.postMessage({ action: 'sync' } as Message); /** * If isSynced is false after 100ms, this tab is the main tab */ setTimeout(() => { if (!isSynced) { isMain = true; isSynced = true; options?.onBecomeMain?.(id); } }, options?.mainTimeout ?? 100); }; /** * Handle case when the tab / window is closed */ const onClose = (): void => { /** * 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 } as Message); /** * 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 } as Message); 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' } * ) * ); */ export const shared = sharedImpl as Shared;