import { diff } from 'deep-diff'; import isEqual from 'fast-deep-equal'; import { cloneDeep, get, unset, isEmpty, set } from 'lodash'; type StateObject = { id: string } & Record export class VirtualState { constructor( private previousState: StateObject[] = [], ) { // Ensure previousState is always an array this.previousState = Array.isArray(previousState) ? previousState : []; } compare(nextState: StateObject[], ignoreProps?: string[]) { // Ensure nextState is always an array const validNextState = Array.isArray(nextState) ? nextState : []; return diffArraysById(this.previousState, validNextState, ignoreProps); } updateState(nextState: StateObject[]) { // Ensure nextState is always an array this.previousState = Array.isArray(nextState) ? nextState : []; } getState() { return this.previousState; } } interface MyObject { id: string | number; name: string; value?: number; } export type Changes = { added: T[], removed: T[], modified: { old: T, new: T }[], unchanged: T[] }; const getValidStateObjects = item => item !== null && item !== undefined && typeof item === 'object' && 'id' in item /** * Compares two objects while ignoring specified properties * Requires lodash's cloneDeep and unset functions */ function areObjectsEqualExcludingProps(obj1: T, obj2: T, ignoreProps?: string[]): boolean { if (!ignoreProps || ignoreProps.length === 0) { return isEqual(obj1, obj2); } // Clone objects to avoid modifying originals const clone1 = cloneDeep(obj1); const clone2 = cloneDeep(obj2); // Unset the ignored properties and clean up empty parent objects for (const prop of ignoreProps) { const pathParts = prop.split('.'); // Unset the specific property first unset(clone1, prop); unset(clone2, prop); // If this is a nested property, check parent paths and remove them if empty if (pathParts.length > 1) { for (let i = pathParts.length - 2; i >= 0; i--) { const parentPath = pathParts.slice(0, i + 1).join('.'); const parent1 = get(clone1, parentPath); const parent2 = get(clone2, parentPath); // Remove empty parent objects if (parent1 && isEmpty(parent1)) { unset(clone1, parentPath); } if (parent2 && isEmpty(parent2)) { unset(clone2, parentPath); } } } } // Compare with standard equality return isEqual(clone1, clone2); } function diffArraysById( array1: T[], array2: T[], ignoreProps?: string[] ): Changes { // Filter out null, undefined, non-objects, and objects without id const filteredArray1 = array1.filter(getValidStateObjects) as T[]; const filteredArray2 = array2.filter(getValidStateObjects) as T[]; const map1 = new Map(filteredArray1.map(item => [item.id, item])); const map2 = new Map(filteredArray2.map(item => [item.id, item])); const added: T[] = []; const removed: T[] = []; const modified: { old: T, new: T }[] = []; const unchanged: T[] = []; // Check for removed and potentially modified/unchanged for (const [id, item1] of map1.entries()) { const item2 = map2.get(id); if (item2) { // Exists in both, check for modification using our helper function if (!areObjectsEqualExcludingProps(item1, item2, ignoreProps)) { // Create a new version of item2, initially a clone const modifiedItem2 = cloneDeep(item2); // Remove ignored properties from the 'new' representation if (ignoreProps && ignoreProps.length > 0) { for (const prop of ignoreProps) { // Check if this is a property that was added in the new object // and not present in the old one - in that case we need to remove it if (!get(item1, prop) && get(modifiedItem2, prop) !== undefined) { unset(modifiedItem2, prop); } else if (get(item1, prop) !== undefined) { // If property exists in old object, copy its value to new object // to make it appear as if it didn't change set(modifiedItem2, prop, get(item1, prop)); } // Clean up potentially empty parent objects created by unsetting nested props const pathParts = prop.split('.'); if (pathParts.length > 1) { for (let i = pathParts.length - 2; i >= 0; i--) { const parentPath = pathParts.slice(0, i + 1).join('.'); const parent = get(modifiedItem2, parentPath); if (parent && isEmpty(parent)) { unset(modifiedItem2, parentPath); } } } } } modified.push({ old: item1, new: modifiedItem2 }); } else { unchanged.push(item1); // Use original object } // Mark as processed to avoid re-checking when iterating map2 map2.delete(id); } else { // Only in map1 -> removed removed.push(item1); } } // Any remaining items in map2 are new additions for (const item2 of map2.values()) { added.push(item2); } return { added, removed, modified, unchanged }; } /* // --- Example Usage --- const array1: MyObject[] = [ { id: 1, name: 'Apple', value: 10 }, { id: 2, name: 'Banana', value: 20 }, { id: 3, name: 'Cherry' }, ]; const array2: MyObject[] = [ { id: 1, name: 'Apple', value: 15 }, // Modified { id: 3, name: 'Cherry' }, // Unchanged { id: 4, name: 'Date' }, // Added ]; const changes = diffArraysById(array1, array2); console.log("Changes:", changes);*/ /* Changes: { added: [ { id: 4, name: 'Date' } ], removed: [ { id: 2, name: 'Banana', value: 20 } ], modified: [ { old: { id: 1, name: 'Apple', value: 10 }, new: { id: 1, name: 'Apple', value: 15 } } ], unchanged: [ { id: 3, name: 'Cherry' } ] } */ // Installs: // npm install fast-deep-equal // yarn add fast-deep-equal