346 lines
10 KiB
TypeScript
346 lines
10 KiB
TypeScript
|
|
/**
|
||
|
|
* 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');
|
||
|
|
};
|
||
|
|
}
|