diff --git a/src/lib/smartStateUpdate.ts b/src/lib/smartStateUpdate.ts new file mode 100644 index 00000000..4a3cd144 --- /dev/null +++ b/src/lib/smartStateUpdate.ts @@ -0,0 +1,159 @@ +/** + * Smart State Update Utility + * + * Provides intelligent array diffing and merging to prevent + * unnecessary re-renders and preserve user interaction state. + */ + +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) { + // Deep comparison if no specific fields provided + return JSON.stringify(currentItem) !== JSON.stringify(newItem); + } + + // Compare only specified fields + for (const field of compareFields) { + if (currentItem[field] !== newItem[field]) { + 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); +}