Last active
December 12, 2024 04:46
-
-
Save mary-ext/f3f3b99a6e385c19635ff29014768237 to your computer and use it in GitHub Desktop.
Broadcast and share Pinia states to other tabs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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