- Added optional onStateSync callback to BroadcastSyncOptions - Callback is called when state is updated locally or synced from another tab - Callback receives new state and previous state as parameters - Error handling prevents callback errors from breaking sync - Stores can opt-in by providing callback that invalidates React Query queries - No breaking changes - callback is optional - Action 4.2.1.1 complete
408 lines
14 KiB
TypeScript
408 lines
14 KiB
TypeScript
/**
|
|
* BroadcastChannel State Synchronization
|
|
* FE-STATE-002: Synchronize Zustand state across browser tabs using BroadcastChannel
|
|
*
|
|
* This middleware allows Zustand stores to automatically sync their state
|
|
* across all open tabs/windows of the same origin.
|
|
*
|
|
* NOTE: This sync mechanism coexists with React Query cache synchronization
|
|
* (see reactQuerySync.ts). Both use BroadcastChannel but with different channel
|
|
* names and message formats, so they do not conflict:
|
|
* - Zustand sync: `veza-store-${storeName}` channels with BroadcastMessage format
|
|
* - React Query sync: `veza-react-query-sync` channel with ReactQuerySyncMessage format
|
|
*/
|
|
|
|
import { StateCreator } from 'zustand';
|
|
import { logger } from './logger';
|
|
|
|
/**
|
|
* Options for broadcast synchronization
|
|
*/
|
|
export interface BroadcastSyncOptions {
|
|
/** Channel name for BroadcastChannel (default: store name) */
|
|
channelName?: string;
|
|
/** Whether to enable synchronization (default: true) */
|
|
enabled?: boolean;
|
|
/** Function to filter which state changes should be synced */
|
|
shouldSync?: <T>(state: T, prevState: T | null) => boolean;
|
|
/**
|
|
* Action 4.2.1.1: Callback to invalidate React Query cache when state is synced
|
|
* Called when state updates are broadcast or received from other tabs
|
|
*/
|
|
onStateSync?: <T>(state: T, prevState: T | null) => void;
|
|
}
|
|
|
|
/**
|
|
* BroadcastChannel message format
|
|
* CRITIQUE FIX #14: Ajout d'un ID unique pour chaque message pour éviter les doublons
|
|
*/
|
|
interface BroadcastMessage<T = unknown> {
|
|
type: 'state-update' | 'state-request' | 'state-response';
|
|
storeName: string;
|
|
state?: T;
|
|
timestamp: number;
|
|
messageId?: string; // CRITIQUE FIX #14: ID unique pour éviter les doublons
|
|
}
|
|
|
|
/**
|
|
* Create a BroadcastChannel instance for a store
|
|
*/
|
|
function createBroadcastChannel(storeName: string): BroadcastChannel | null {
|
|
if (typeof window === 'undefined' || !window.BroadcastChannel) {
|
|
logger.warn(
|
|
'[BroadcastSync] BroadcastChannel not supported in this environment',
|
|
);
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return new BroadcastChannel(`veza-store-${storeName}`);
|
|
} catch (error) {
|
|
logger.warn(
|
|
`[BroadcastSync] Failed to create BroadcastChannel for ${storeName}`,
|
|
{
|
|
error: error instanceof Error ? error.message : String(error),
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
storeName,
|
|
},
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Zustand middleware for BroadcastChannel synchronization
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* export const useMyStore = create<MyState>()(
|
|
* broadcastSync(
|
|
* (set, get) => ({
|
|
* // store implementation
|
|
* }),
|
|
* { channelName: 'my-store' }
|
|
* )
|
|
* );
|
|
* ```
|
|
*/
|
|
export function broadcastSync<T extends object>(
|
|
config: StateCreator<T>,
|
|
options: BroadcastSyncOptions = {},
|
|
): StateCreator<T> {
|
|
return (set, get, api) => {
|
|
const storeName = options.channelName || 'default-store';
|
|
const enabled = options.enabled !== false;
|
|
const shouldSync = options.shouldSync || (() => true);
|
|
const onStateSync = options.onStateSync;
|
|
|
|
let channel: BroadcastChannel | null = null;
|
|
let isReceivingUpdate = false;
|
|
let lastState: T | null = null;
|
|
// CRITIQUE FIX #14: Système de timestamps pour déterminer quelle mise à jour est la plus récente
|
|
let lastUpdateTimestamp = 0;
|
|
// CRITIQUE FIX #14: Set pour tracker les messages déjà traités (éviter les doublons)
|
|
const processedMessages = new Set<string>();
|
|
// CRITIQUE FIX #14: Queue pour les mises à jour en attente
|
|
const pendingUpdates: Array<{
|
|
state: T;
|
|
timestamp: number;
|
|
messageId: string;
|
|
}> = [];
|
|
|
|
// Initialize BroadcastChannel
|
|
if (enabled) {
|
|
channel = createBroadcastChannel(storeName);
|
|
|
|
if (channel) {
|
|
// CRITIQUE FIX #14: Fonction pour traiter les mises à jour en queue
|
|
const processPendingUpdates = () => {
|
|
if (pendingUpdates.length === 0 || isReceivingUpdate) {
|
|
return;
|
|
}
|
|
|
|
// Trier par timestamp (plus récent en premier)
|
|
pendingUpdates.sort((a, b) => b.timestamp - a.timestamp);
|
|
|
|
// Traiter la mise à jour la plus récente
|
|
const update = pendingUpdates.shift();
|
|
if (update && update.timestamp > lastUpdateTimestamp) {
|
|
isReceivingUpdate = true;
|
|
set(update.state);
|
|
lastState = update.state;
|
|
lastUpdateTimestamp = update.timestamp;
|
|
|
|
// Nettoyer les anciens messages traités (garder seulement les 100 derniers)
|
|
if (processedMessages.size > 100) {
|
|
const toDelete = Array.from(processedMessages).slice(0, 50);
|
|
toDelete.forEach((id) => processedMessages.delete(id));
|
|
}
|
|
|
|
// Reset flag après traitement
|
|
setTimeout(() => {
|
|
isReceivingUpdate = false;
|
|
// Traiter la prochaine mise à jour en queue
|
|
processPendingUpdates();
|
|
}, 50); // Réduire le délai pour une meilleure réactivité
|
|
}
|
|
};
|
|
|
|
// Listen for state updates from other tabs
|
|
channel.onmessage = (event: MessageEvent<BroadcastMessage>) => {
|
|
const message = event.data;
|
|
|
|
// Action 2.3.1.3: Type guard to ensure this is a valid BroadcastMessage
|
|
// This prevents processing messages from other sync mechanisms (e.g., React Query sync)
|
|
if (
|
|
!message ||
|
|
typeof message !== 'object' ||
|
|
!message.type ||
|
|
!message.storeName ||
|
|
typeof message.timestamp !== 'number'
|
|
) {
|
|
// Not a valid BroadcastMessage - ignore (likely from a different sync mechanism)
|
|
return;
|
|
}
|
|
|
|
// Verify message type is one of the expected Zustand sync types
|
|
if (
|
|
message.type !== 'state-update' &&
|
|
message.type !== 'state-request' &&
|
|
message.type !== 'state-response'
|
|
) {
|
|
// Not a Zustand sync message - ignore
|
|
return;
|
|
}
|
|
|
|
// Verify storeName matches (extra safety check)
|
|
if (message.storeName !== storeName) {
|
|
// Message is for a different store - ignore
|
|
return;
|
|
}
|
|
|
|
// CRITIQUE FIX #14: Générer un ID unique pour ce message s'il n'en a pas
|
|
const messageId =
|
|
message.messageId ||
|
|
`${message.type}-${message.timestamp}-${Math.random()}`;
|
|
|
|
// Ignorer les messages déjà traités (éviter les doublons)
|
|
if (processedMessages.has(messageId)) {
|
|
return;
|
|
}
|
|
|
|
if (message.type === 'state-update' && message.state) {
|
|
// Ignore updates from the same tab si on est déjà en train de recevoir
|
|
if (isReceivingUpdate) {
|
|
// CRITIQUE FIX #14: Ajouter à la queue au lieu de rejeter
|
|
pendingUpdates.push({
|
|
state: message.state as T,
|
|
timestamp: message.timestamp,
|
|
messageId,
|
|
});
|
|
// Trier la queue et traiter si possible
|
|
processPendingUpdates();
|
|
return;
|
|
}
|
|
|
|
// CRITIQUE FIX #14: Vérifier si cette mise à jour est plus récente que la dernière
|
|
if (message.timestamp <= lastUpdateTimestamp) {
|
|
// Mise à jour obsolète, l'ignorer
|
|
processedMessages.add(messageId);
|
|
return;
|
|
}
|
|
|
|
// Check if we should sync this update
|
|
if (shouldSync(message.state, lastState)) {
|
|
processedMessages.add(messageId);
|
|
isReceivingUpdate = true;
|
|
const prevState = lastState; // Save previous state before updating
|
|
set(message.state as T);
|
|
const newState = message.state as T;
|
|
lastState = newState;
|
|
lastUpdateTimestamp = message.timestamp;
|
|
|
|
// Action 4.2.1.1: Invalidate React Query cache when state is synced from another tab
|
|
if (onStateSync) {
|
|
try {
|
|
onStateSync(newState, prevState);
|
|
} catch (error) {
|
|
logger.warn(
|
|
'[BroadcastSync] Error in onStateSync callback',
|
|
{
|
|
error: error instanceof Error ? error.message : String(error),
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
storeName,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
// Reset flag after a short delay
|
|
setTimeout(() => {
|
|
isReceivingUpdate = false;
|
|
// Traiter les mises à jour en queue après le délai
|
|
processPendingUpdates();
|
|
}, 50); // Réduire le délai pour une meilleure réactivité
|
|
} else {
|
|
processedMessages.add(messageId);
|
|
}
|
|
} else if (message.type === 'state-request') {
|
|
// Another tab is requesting the current state
|
|
const currentState = get();
|
|
// Only serialize data, not functions (functions can't be cloned)
|
|
const serializableState = JSON.parse(JSON.stringify(currentState));
|
|
if (channel) {
|
|
channel.postMessage({
|
|
type: 'state-response',
|
|
storeName,
|
|
state: serializableState,
|
|
timestamp: Date.now(),
|
|
} as BroadcastMessage);
|
|
}
|
|
} else if (message.type === 'state-response' && message.state) {
|
|
// CRITIQUE FIX #14: Received state from another tab (initial sync)
|
|
// Vérifier le timestamp pour éviter d'écraser un état plus récent
|
|
if (!lastState || message.timestamp > lastUpdateTimestamp) {
|
|
processedMessages.add(messageId);
|
|
isReceivingUpdate = true;
|
|
const prevState = lastState; // Save previous state before updating
|
|
set(message.state as T);
|
|
const newState = message.state as T;
|
|
lastState = newState;
|
|
lastUpdateTimestamp = message.timestamp;
|
|
|
|
// Action 4.2.1.1: Invalidate React Query cache when state is synced from another tab
|
|
if (onStateSync) {
|
|
try {
|
|
onStateSync(newState, prevState);
|
|
} catch (error) {
|
|
logger.warn(
|
|
'[BroadcastSync] Error in onStateSync callback',
|
|
{
|
|
error: error instanceof Error ? error.message : String(error),
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
storeName,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
setTimeout(() => {
|
|
isReceivingUpdate = false;
|
|
processPendingUpdates();
|
|
}, 50);
|
|
} else {
|
|
processedMessages.add(messageId);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Request initial state from other tabs on startup
|
|
channel.postMessage({
|
|
type: 'state-request',
|
|
storeName,
|
|
timestamp: Date.now(),
|
|
} as BroadcastMessage);
|
|
}
|
|
}
|
|
|
|
// Create the store with wrapped set function
|
|
const store = config(
|
|
(...args) => {
|
|
if (!isReceivingUpdate) {
|
|
set(...args);
|
|
|
|
// Broadcast state update to other tabs
|
|
if (channel && enabled) {
|
|
const newState = get();
|
|
|
|
if (shouldSync(newState, lastState)) {
|
|
// CRITIQUE FIX #14: Utiliser un timestamp précis et un ID unique pour chaque message
|
|
const timestamp = Date.now();
|
|
const messageId = `update-${timestamp}-${Math.random()}`;
|
|
|
|
// Only serialize data, not functions (functions can't be cloned)
|
|
const serializableState = JSON.parse(JSON.stringify(newState));
|
|
channel.postMessage({
|
|
type: 'state-update',
|
|
storeName,
|
|
state: serializableState,
|
|
timestamp,
|
|
messageId, // CRITIQUE FIX #14: Ajouter l'ID unique
|
|
} as BroadcastMessage);
|
|
|
|
// Action 4.2.1.1: Invalidate React Query cache when state is updated locally
|
|
if (onStateSync) {
|
|
try {
|
|
onStateSync(newState, lastState);
|
|
} catch (error) {
|
|
logger.warn(
|
|
'[BroadcastSync] Error in onStateSync callback',
|
|
{
|
|
error: error instanceof Error ? error.message : String(error),
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
storeName,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
lastState = newState;
|
|
lastUpdateTimestamp = timestamp;
|
|
}
|
|
}
|
|
} else {
|
|
// If we're receiving an update, just set without broadcasting
|
|
set(...args);
|
|
}
|
|
},
|
|
get,
|
|
api,
|
|
);
|
|
|
|
return store;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Helper to create a synchronized store with both persistence and broadcast sync
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* export const useMyStore = create<MyState>()(
|
|
* persist(
|
|
* broadcastSync(
|
|
* (set, get) => ({
|
|
* // store implementation
|
|
* }),
|
|
* { channelName: 'my-store' }
|
|
* ),
|
|
* { name: 'my-store-storage' }
|
|
* )
|
|
* );
|
|
* ```
|
|
*/
|
|
export function createSynchronizedStore<T extends object>(
|
|
config: StateCreator<T>,
|
|
options: {
|
|
persist?: { name: string; partialize?: (state: T) => Partial<T> };
|
|
broadcast?: BroadcastSyncOptions;
|
|
} = {},
|
|
) {
|
|
let store = config;
|
|
|
|
// Apply broadcast sync if enabled
|
|
if (options.broadcast?.enabled !== false) {
|
|
store = broadcastSync(store, options.broadcast) as StateCreator<T>;
|
|
}
|
|
|
|
// Apply persistence if configured
|
|
if (options.persist) {
|
|
const { persist: persistMiddleware } = require('zustand/middleware');
|
|
store = persistMiddleware(store, {
|
|
name: options.persist.name,
|
|
partialize: options.persist.partialize,
|
|
}) as StateCreator<T>;
|
|
}
|
|
|
|
return store;
|
|
}
|