Skip to content

Instantly share code, notes, and snippets.

@mary-ext
Last active December 12, 2024 04:46
Show Gist options
  • Save mary-ext/f3f3b99a6e385c19635ff29014768237 to your computer and use it in GitHub Desktop.
Save mary-ext/f3f3b99a6e385c19635ff29014768237 to your computer and use it in GitHub Desktop.
Broadcast and share Pinia states to other tabs
import isEqual from "fast-deep-equal";
import { PiniaPlugin, StateTree } from "pinia";
import { onScopeDispose } from "vue";
// Web Locks API have very good support at this point, but it's disabled by
// Safari when lockdown mode is disabled. Let's gracefully handle this by not
// doing any of that initialization broadcast when it's not present.
const locks = navigator.locks as LockManager | undefined;
const PREFIX = "pinia-broadcast:";
interface StateSyncEvent {
type: "sync";
ts: number;
value: StateTree;
}
interface StateRequestEvent {
type: "request";
}
type StateEvent = StateSyncEvent | StateRequestEvent;
export interface BroadcastOptions<S extends StateTree> {
/** Request state synchronization from another tab during init */
requestSync?: boolean;
/** Omit specific keys from being broadcasted */
omit?: (keyof S)[];
}
declare module "pinia" {
// eslint-disable-next-line no-undef, @typescript-eslint/no-unused-vars
export interface DefineStoreOptionsBase<S extends StateTree, Store> {
broadcast?: BroadcastOptions<S>;
}
export interface PiniaCustomProperties {
$broadcast: () => void;
}
}
export const createPiniaBroadcast = (): PiniaPlugin => {
return ({ store, options: { broadcast } }) => {
if (!broadcast) {
return;
}
const id = store.$id;
const channel = new BroadcastChannel(PREFIX + id);
let timestamp = -1;
let leading = false;
let snapshot: StateTree | undefined;
// Register listeners
channel.onmessage = (ev) => {
const event = ev.data as StateEvent;
switch (event.type) {
case "sync": {
if (event.ts <= timestamp || isEqual(snapshot, event.value)) {
return;
}
timestamp = event.ts;
snapshot = event.value;
store.$patch(event.value);
return;
}
case "request": {
if (!leading || timestamp === -1) {
return;
}
const syncEvent: StateSyncEvent = {
type: "sync",
ts: timestamp,
value: getStateSnapshot(store.$state, broadcast.omit),
};
channel.postMessage(syncEvent);
return;
}
}
};
onScopeDispose(() => {
channel.close();
});
// Check if Web Locks API is present, set up leader election
if (locks && broadcast.requestSync) {
// Request for a lock
locks?.request(PREFIX + id, () => {
leading = true;
// Never resolve the promise, we're now the leader until tab closure
//
// Worth mentioning that this might not go well over the browser's
// power-saving measures with the tab inhibition and all, let's just
// pretend it doesn't matter, for now.
return new Promise(() => {});
});
// Ask other tabs if they have state to synchronize
{
const requestEvent: StateRequestEvent = {
type: "request",
};
channel.postMessage(requestEvent);
}
}
// Set broadcast functionality
store.$broadcast = () => {
const nextSnapshot = getStateSnapshot(store.$state, broadcast.omit);
if (!isEqual(snapshot, nextSnapshot)) {
const syncEvent: StateSyncEvent = {
type: "sync",
ts: (timestamp = getCurrentTime()),
value: (snapshot = nextSnapshot),
};
channel.postMessage(syncEvent);
}
};
// Now subscribe to the the store
store.$subscribe((mutation) => {
if (mutation.type === "direct") {
store.$broadcast();
}
});
};
};
// `Date.now()` can't be used as it doesn't guarantee monotonicity
const getCurrentTime = () => {
return performance.timeOrigin + performance.now();
};
const getStateSnapshot = (
state: StateTree,
omitted?: (string | number | symbol)[]
): StateTree => {
const cloned = JSON.parse(JSON.stringify(state));
if (omitted) {
for (let idx = 0, len = omitted.length; idx < len; idx++) {
const key = omitted[idx];
delete cloned[key];
}
}
return cloned;
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment