/** * React Query Cache Synchronization * Action 2.3.1.1: Create React Query sync utility using BroadcastChannel * * This utility synchronizes React Query cache updates across browser tabs/windows * using BroadcastChannel API. When a query is invalidated or data is updated in one tab, * other tabs automatically receive the update and sync their cache. * * NOTE: This sync mechanism coexists with Zustand state synchronization * (see broadcastSync.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 { QueryClient } from '@tanstack/react-query'; import { logger } from './logger'; /** * Message format for React Query sync */ interface ReactQuerySyncMessage { type: 'query-invalidate' | 'query-set-data' | 'mutation-success'; queryKey: (string | number)[]; data?: unknown; timestamp: number; messageId: string; tabId?: string; // Identifier for the tab that sent the message } /** * Options for React Query sync */ export interface ReactQuerySyncOptions { /** Channel name for BroadcastChannel (default: 'veza-react-query-sync') */ channelName?: string; /** Whether to enable synchronization (default: true) */ enabled?: boolean; /** Function to filter which query updates should be synced */ shouldSync?: (queryKey: (string | number)[], type: ReactQuerySyncMessage['type']) => boolean; } /** * Create a BroadcastChannel instance for React Query sync */ function createBroadcastChannel(channelName: string): BroadcastChannel | null { if (typeof window === 'undefined' || !window.BroadcastChannel) { logger.warn( '[ReactQuerySync] BroadcastChannel not supported in this environment', ); return null; } try { return new BroadcastChannel(channelName); } catch (error) { logger.warn('[ReactQuerySync] Failed to create BroadcastChannel', { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, channelName, }); return null; } } /** * Generate a unique tab ID for this browser tab */ function getTabId(): string { if (typeof window === 'undefined') { return 'server'; } // Try to get or create a tab ID from sessionStorage let tabId = sessionStorage.getItem('veza-tab-id'); if (!tabId) { tabId = `tab-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; sessionStorage.setItem('veza-tab-id', tabId); } return tabId; } /** * Generate a unique message ID */ function generateMessageId(): string { return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; } /** * Initialize React Query cache synchronization * Action 2.3.1.1: Create React Query sync utility * * @param queryClient The React Query QueryClient instance * @param options Configuration options * @returns Cleanup function to stop synchronization * * @example * ```typescript * const queryClient = new QueryClient(); * const cleanup = setupReactQuerySync(queryClient); * * // Later, to stop syncing: * cleanup(); * ``` */ export function setupReactQuerySync( queryClient: QueryClient, options: ReactQuerySyncOptions = {}, ): () => void { const channelName = options.channelName || 'veza-react-query-sync'; const enabled = options.enabled !== false; const shouldSync = options.shouldSync || (() => true); if (!enabled) { return () => {}; // No-op cleanup } const channel = createBroadcastChannel(channelName); if (!channel) { logger.warn('[ReactQuerySync] BroadcastChannel not available, sync disabled'); return () => {}; // No-op cleanup } const tabId = getTabId(); const processedMessages = new Set(); // Track if we're currently processing a message to avoid loops let isProcessingMessage = false; /** * Broadcast a query invalidation to other tabs */ function broadcastInvalidation(queryKey: (string | number)[]): void { if (isProcessingMessage || !shouldSync(queryKey, 'query-invalidate')) { return; } const message: ReactQuerySyncMessage = { type: 'query-invalidate', queryKey, timestamp: Date.now(), messageId: generateMessageId(), tabId, }; try { if (channel) { channel.postMessage(message); logger.debug('[ReactQuerySync] Broadcasted query invalidation', { queryKey, messageId: message.messageId, }); } } catch (error) { logger.error('[ReactQuerySync] Failed to broadcast invalidation', { error: error instanceof Error ? error.message : String(error), queryKey, }); } } /** * Broadcast query data update to other tabs * Note: This is kept for potential future use but not actively used * to avoid performance issues from broadcasting every query update * Currently only invalidations and mutations are synced */ // @ts-expect-error - Kept for future use, not currently called function broadcastSetData( queryKey: (string | number)[], data: unknown, ): void { if (isProcessingMessage || !shouldSync(queryKey, 'query-set-data') || !channel) { return; } const message: ReactQuerySyncMessage = { type: 'query-set-data', queryKey, data, timestamp: Date.now(), messageId: generateMessageId(), tabId, }; try { channel.postMessage(message); logger.debug('[ReactQuerySync] Broadcasted query data update', { queryKey, messageId: message.messageId, }); } catch (error) { logger.error('[ReactQuerySync] Failed to broadcast data update', { error: error instanceof Error ? error.message : String(error), queryKey, }); } } /** * Handle incoming messages from other tabs */ function handleMessage(event: MessageEvent): void { const message = event.data; // Action 2.3.1.3: Type guard to ensure this is a valid ReactQuerySyncMessage // This prevents processing messages from other sync mechanisms (e.g., Zustand sync) if ( !message || typeof message !== 'object' || !message.type || !Array.isArray(message.queryKey) || typeof message.timestamp !== 'number' || typeof message.messageId !== 'string' ) { // Not a valid ReactQuerySyncMessage - ignore (likely from a different sync mechanism) return; } // Verify message type is one of the expected React Query sync types if ( message.type !== 'query-invalidate' && message.type !== 'query-set-data' && message.type !== 'mutation-success' ) { // Not a React Query sync message - ignore return; } // Ignore messages from this tab if (message.tabId === tabId) { return; } // Ignore duplicate messages if (processedMessages.has(message.messageId)) { return; } // Mark as processed processedMessages.add(message.messageId); // Clean up old processed message IDs (keep last 1000) if (processedMessages.size > 1000) { const toDelete = Array.from(processedMessages).slice(0, 500); toDelete.forEach((id) => processedMessages.delete(id)); } // Check if we should sync this update if (!shouldSync(message.queryKey, message.type)) { return; } isProcessingMessage = true; try { switch (message.type) { case 'query-invalidate': queryClient.invalidateQueries({ queryKey: message.queryKey }); logger.debug('[ReactQuerySync] Invalidated query from other tab', { queryKey: message.queryKey, messageId: message.messageId, }); break; case 'query-set-data': if (message.data !== undefined) { queryClient.setQueryData(message.queryKey, message.data); logger.debug('[ReactQuerySync] Updated query data from other tab', { queryKey: message.queryKey, messageId: message.messageId, }); } break; case 'mutation-success': // On mutation success, invalidate related queries queryClient.invalidateQueries({ queryKey: message.queryKey }); logger.debug( '[ReactQuerySync] Invalidated queries after mutation from other tab', { queryKey: message.queryKey, messageId: message.messageId, }, ); break; default: logger.warn('[ReactQuerySync] Unknown message type', { type: (message as any).type, messageId: message.messageId, }); } } catch (error) { logger.error('[ReactQuerySync] Error processing sync message', { error: error instanceof Error ? error.message : String(error), messageId: message.messageId, queryKey: message.queryKey, }); } finally { // Reset flag after a short delay to allow React Query to process setTimeout(() => { isProcessingMessage = false; }, 50); } } // Listen for messages from other tabs channel.addEventListener('message', handleMessage); // Subscribe to QueryClient mutations to broadcast invalidations const unsubscribeMutations = queryClient.getMutationCache().subscribe( (event) => { if (!event || !channel) { return; } // Only broadcast successful mutations on 'updated' events if (event.type === 'updated' && event.mutation.state.status === 'success') { // Get query keys that should be invalidated const queryKey = event.mutation.options.mutationKey; if (queryKey) { const message: ReactQuerySyncMessage = { type: 'mutation-success', queryKey: queryKey as (string | number)[], timestamp: Date.now(), messageId: generateMessageId(), tabId, }; try { channel.postMessage(message); logger.debug( '[ReactQuerySync] Broadcasted mutation success', { queryKey, messageId: message.messageId, }, ); } catch (error) { logger.error('[ReactQuerySync] Failed to broadcast mutation', { error: error instanceof Error ? error.message : String(error), queryKey, }); } } } }, ); // Subscribe to query cache invalidations to broadcast them // We focus on invalidations rather than every data update to avoid performance issues const unsubscribeQueries = queryClient.getQueryCache().subscribe((event) => { if (event?.type === 'removed' || (event?.type === 'updated' && event.query?.state.isInvalidated)) { const queryKey = event.query.queryKey; // Broadcast invalidation when queries are removed or invalidated broadcastInvalidation(queryKey); } }); logger.info('[ReactQuerySync] React Query cache synchronization enabled', { channelName, tabId, }); // Return cleanup function return () => { channel.removeEventListener('message', handleMessage); unsubscribeMutations(); unsubscribeQueries(); channel.close(); logger.info('[ReactQuerySync] React Query cache synchronization disabled'); }; }