Skip to content

Instantly share code, notes, and snippets.

@LiniovasDovydas
Created September 19, 2024 06:40
Show Gist options
  • Save LiniovasDovydas/67f3fadd207d4375c6f8777711458d07 to your computer and use it in GitHub Desktop.
Save LiniovasDovydas/67f3fadd207d4375c6f8777711458d07 to your computer and use it in GitHub Desktop.
Broadcast channel state sync with react external store API
type BroadcastStateSyncMessage = { type: "STATE_SYNC" };
type BroadcastStateUpdate<S> = { type: "UPDATE"; data: S };
type BroadcastCheckOwnerShipMessage = { type: "CHECK_OWNERSHIP" };
type BroadcastTransferOwnershipMessage = { type: "TRANSFER_OWNERSHIP" };
type BroadcastOwnershipTakenMessage = { type: "OWNERSHIP_TAKEN"; id: string };
type BroadcastDefaultMessages =
| BroadcastStateSyncMessage
| BroadcastOwnershipTakenMessage
| BroadcastTransferOwnershipMessage
| BroadcastCheckOwnerShipMessage;
type BroadcastMessage<S> = BroadcastStateUpdate<S> | BroadcastDefaultMessages;
const OWNERSHIP_TAKEOVER_DELAY = 24; // ms
const channels = new Map<
string,
ReturnType<typeof createBroadcastChannelStore>
>();
function createBroadcastChannelStore<S>(name: string, initialState: S) {
const id = `${Date.now()}`;
const listeners = new Set<() => void>();
const channel = new BroadcastChannel(name);
let isOwner = false;
let data = initialState;
let ownershipTimeout: number | undefined;
// Internal functions
const notifyListeners = () => {
listeners.forEach((callback) => callback());
};
const postDefaultMessage = (message: BroadcastMessage<S>) => {
channel.postMessage(message);
};
const giveUpOwnership = () => {
window.removeEventListener("beforeunload", giveUpOwnership);
if (!isOwner) {
return;
}
postDefaultMessage({ type: "TRANSFER_OWNERSHIP" });
};
const takeOwnership = () => {
window.addEventListener("beforeunload", giveUpOwnership);
if (ownershipTimeout) {
clearTimeout(ownershipTimeout);
}
ownershipTimeout = window.setTimeout(() => {
isOwner = true;
postDefaultMessage({ type: "OWNERSHIP_TAKEN", id });
}, OWNERSHIP_TAKEOVER_DELAY);
};
const onMessageReceived = (event: MessageEvent<BroadcastMessage<S>>) => {
if (!isBroadCastMessage<S>(event.data)) {
return;
}
switch (event.data.type) {
case "OWNERSHIP_TAKEN":
isOwner = event.data.id === id;
if (!isOwner) {
clearTimeout(ownershipTimeout);
}
break;
case "STATE_SYNC":
if (isOwner) {
postDefaultMessage({ type: "UPDATE", data });
}
break;
case "TRANSFER_OWNERSHIP":
takeOwnership();
break;
case "CHECK_OWNERSHIP":
if (isOwner) {
postDefaultMessage({ type: "OWNERSHIP_TAKEN", id });
}
break;
case "UPDATE":
data = event.data.data;
notifyListeners();
break;
}
};
const setData = (newData: S) => {
data = newData;
postDefaultMessage({ type: "UPDATE", data });
notifyListeners();
};
const getSnapshot = () => data;
const syncInitialValue = () => postDefaultMessage({ type: "STATE_SYNC" });
channel.onmessage = onMessageReceived;
syncInitialValue();
postDefaultMessage({ type: "CHECK_OWNERSHIP" });
takeOwnership();
return {
subscribe: (onStateChange: () => void) => {
listeners.add(onStateChange);
return () => {
listeners.delete(onStateChange);
if (listeners.size === 0) {
requestAnimationFrame(() => {
if (listeners.size === 0) {
channels.delete(name);
giveUpOwnership();
channel.close();
}
});
}
};
},
setData,
getSnapshot,
};
}
function getBroadcastStore<S>(name: string, initialState: S) {
const channel = channels.get(name);
if (!channel) {
const instance = createBroadcastChannelStore<S>(name, initialState);
channels.set(
name,
instance as ReturnType<typeof createBroadcastChannelStore>
);
return instance;
}
return channel as ReturnType<typeof createBroadcastChannelStore<S>>;
}
function isBroadCastMessage<S>(
message: unknown
): message is BroadcastMessage<S> {
if (!message || typeof message !== "object") {
return false;
}
return "type" in message;
}
export { getBroadcastStore };
import { useState, useSyncExternalStore } from "react";
import { getBroadcastStore } from "./broadcastStore";
function useBroadcastStore<S>(name: string, initialState: S) {
const [channel] = useState(getBroadcastStore<S>(name, initialState));
const state = useSyncExternalStore(
channel.subscribe,
channel.getSnapshot,
undefined
);
return [state, channel.setData] as const;
}
export { useBroadcastStore };
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment