veza/apps/web/src/utils/reactQuerySync.ts

346 lines
10 KiB
TypeScript
Raw Normal View History

/**
* 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.
*/
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<string>();
// 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<ReactQuerySyncMessage>): void {
const message = event.data;
// 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');
};
}