/** * Utility functions for performing "smart" updates on arrays */ /** * Creates a stable content hash for comparison */ function hashContent(obj: any): string { if (obj === null || obj === undefined) return 'null'; if (typeof obj !== 'object') return String(obj); // Handle arrays if (Array.isArray(obj)) { return `[${obj.map(hashContent).join(',')}]`; } // Sort keys for stable hashing (CRITICAL for nested objects!) const sortedKeys = Object.keys(obj).sort(); const parts = sortedKeys.map(key => `${key}:${hashContent(obj[key])}`); return parts.join('|'); } /** * Checks if content has meaningfully changed (not just object reference) */ function hasContentChanged(current: any, next: any): boolean { return hashContent(current) !== hashContent(next); } export interface SmartMergeOptions { compareFields?: (keyof T)[]; preserveOrder?: boolean; addToTop?: boolean; } export interface MergeChanges { added: T[]; removed: T[]; updated: T[]; } export interface SmartMergeResult { items: T[]; changes: MergeChanges; hasChanges: boolean; } /** * Performs intelligent array diffing and merging * * @param currentItems - The current items in state * @param newItems - The new items fetched from the server * @param options - Configuration options * @returns Merged items with change information */ export function smartMergeArray( currentItems: T[], newItems: T[], options?: SmartMergeOptions ): SmartMergeResult { const { compareFields, preserveOrder = false, addToTop = true, } = options || {}; // Create ID maps for quick lookup const currentMap = new Map(currentItems.map(item => [item.id, item])); const newMap = new Map(newItems.map(item => [item.id, item])); // Detect changes const added: T[] = []; const removed: T[] = []; const updated: T[] = []; // Find added and updated items for (const newItem of newItems) { const currentItem = currentMap.get(newItem.id); if (!currentItem) { // New item added.push(newItem); } else if (hasItemChanged(currentItem, newItem, compareFields)) { // Item has changed updated.push(newItem); } } // Find removed items for (const currentItem of currentItems) { if (!newMap.has(currentItem.id)) { removed.push(currentItem); } } const hasChanges = added.length > 0 || removed.length > 0 || updated.length > 0; // If no changes, return current items (preserves object references) if (!hasChanges) { return { items: currentItems, changes: { added: [], removed: [], updated: [] }, hasChanges: false, }; } // Build merged array let mergedItems: T[]; if (preserveOrder) { // Preserve the order of current items mergedItems = currentItems .filter(item => !removed.some(r => r.id === item.id)) .map(item => { const updatedItem = updated.find(u => u.id === item.id); return updatedItem || item; // Use updated version if available, otherwise keep current }); // Add new items at top or bottom if (addToTop) { mergedItems = [...added, ...mergedItems]; } else { mergedItems = [...mergedItems, ...added]; } } else { // Use the order from newItems mergedItems = newItems.map(newItem => { const currentItem = currentMap.get(newItem.id); // If item exists in current state and hasn't changed, preserve reference if (currentItem && !updated.some(u => u.id === newItem.id)) { return currentItem; } return newItem; }); } return { items: mergedItems, changes: { added, removed, updated }, hasChanges: true, }; } /** * Checks if an item has changed by comparing specific fields */ function hasItemChanged( currentItem: T, newItem: T, compareFields?: (keyof T)[] ): boolean { if (!compareFields || compareFields.length === 0) { // If no fields specified, use content hash comparison return hasContentChanged(currentItem, newItem); } // Compare only specified fields for (const field of compareFields) { const currentValue = currentItem[field]; const newValue = newItem[field]; // Handle nested objects/arrays with content hash if (typeof currentValue === 'object' && typeof newValue === 'object') { if (hasContentChanged(currentValue, newValue)) { return true; } } else if (currentValue !== newValue) { return true; } } return false; } /** * Creates a stable ID set for tracking interactions */ export function createStableIdSet(ids: string[]): Set { return new Set(ids); } /** * Checks if an ID is in an interaction set */ export function isInteractingWith(id: string, interactionSet: Set): boolean { return interactionSet.has(id); }