/** * 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?: (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?: (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 { 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()( * broadcastSync( * (set, get) => ({ * // store implementation * }), * { channelName: 'my-store' } * ) * ); * ``` */ export function broadcastSync( config: StateCreator, options: BroadcastSyncOptions = {}, ): StateCreator { 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(); // 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) => { 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()( * persist( * broadcastSync( * (set, get) => ({ * // store implementation * }), * { channelName: 'my-store' } * ), * { name: 'my-store-storage' } * ) * ); * ``` */ export function createSynchronizedStore( config: StateCreator, options: { persist?: { name: string; partialize?: (state: T) => Partial }; broadcast?: BroadcastSyncOptions; } = {}, ) { let store = config; // Apply broadcast sync if enabled if (options.broadcast?.enabled !== false) { store = broadcastSync(store, options.broadcast) as StateCreator; } // 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; } return store; }