diff --git a/frontends/web/src/utils/equal.js b/frontends/web/src/utils/equal.js deleted file mode 100644 index 1e4cd8dce6..0000000000 --- a/frontends/web/src/utils/equal.js +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Copyright 2018 Shift Devices AG - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -const isArray = Array.isArray; -const keyList = Object.keys; -const hasProp = Object.prototype.hasOwnProperty; - -export function equal(a, b) { - if (Object.is(a, b)) { - return true; - } - - if (a && b && typeof a === 'object' && typeof b === 'object') { - let arrA = isArray(a), arrB = isArray(b), i, length, key; - - if (arrA && arrB) { - length = a.length; - if (length !== b.length) { - return false; - } - for (i = 0; i < length; i++) { - if (!equal(a[i], b[i])) { - return false; - } - } - return true; - } - - if (arrA !== arrB) { - return false; - } - - let keys = keyList(a); - length = keys.length; - - if (length !== keyList(b).length) { - return false; - } - - for (i = 0; i < length; i++) { - if (!hasProp.call(b, keys[i])) { - return false; - } - } - - for (i = 0; i < length; i++) { - key = keys[i]; - if (!equal(a[key], b[key])) { - return false; - } - } - - return true; - } - - return false; -} diff --git a/frontends/web/src/utils/equal.test.tsx b/frontends/web/src/utils/equal.test.tsx index 8a5be45213..de0027c37b 100644 --- a/frontends/web/src/utils/equal.test.tsx +++ b/frontends/web/src/utils/equal.test.tsx @@ -1,5 +1,6 @@ /** * Copyright 2018 Shift Devices AG + * Copyright 2025 Shift Crypto AG * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,11 +28,20 @@ describe('equal', () => { expect(equal(null, null)).toBeTruthy(); }); + it('compares undefined and null', () => { + expect(equal(undefined, undefined)).toBeTruthy(); + expect(equal(undefined, null)).toBeFalsy(); + }); + it('compares ints', () => { expect(equal(13, 13)).toBeTruthy(); expect(equal(1, 13)).toBeFalsy(); }); + it('compares NaN', () => { + expect(equal(NaN, NaN)).toBeTruthy(); + }); + it('compares strings', () => { expect(equal('foo', 'foo')).toBeTruthy(); expect(equal('foo', 'bar')).toBeFalsy(); @@ -75,6 +85,13 @@ describe('equal', () => { expect(equal(a, b)).toBeFalsy(); expect(equal(b, a)).toBeFalsy(); }); + + it('compares sparse array vs defined array', () => { + /*eslint no-sparse-arrays: "off"*/ + expect(equal([1, , 3], [1, undefined, 3])).toBeFalsy(); + /*eslint no-sparse-arrays: "off"*/ + expect(equal([1, , 3], [1, , 3])).toBeTruthy(); + }); }); describe('objects', () => { @@ -83,6 +100,10 @@ describe('equal', () => { expect(equal(null, {})).toBeFalsy(); }); + it('is false for [] and {}', () => { + expect(equal([], {})).toBeFalsy(); + }); + it('is true for same key/value pairs', () => { const a = { one: 'two', three: 'four' }; const b = { one: 'two', three: 'four' }; @@ -114,5 +135,85 @@ describe('equal', () => { expect(equal(a, null)).toBeFalsy(); expect(equal(null, a)).toBeFalsy(); }); + + it('doesn’t affect key order equality', () => { + const a = { a: 1, b: 2 }; + const b = { b: 2, a: 1 }; + expect(equal(a, b)).toBeTruthy(); + }); + + it('deep compares nested structures', () => { + const a = { foo: [1, { bar: 'baz' }] }; + const b = { foo: [1, { bar: 'baz' }] }; + expect(equal(a, b)).toBeTruthy(); + const c = { foo: [1, { bar: 'qux' }] }; + expect(equal(a, c)).toBeFalsy(); + }); + + it('fails on deep nested mismatch', () => { + const a = { foo: { bar: { baz: 1 } } }; + const b = { foo: { bar: { baz: 2 } } }; + expect(equal(a, b)).toBeFalsy(); + }); + + it('compares object with mixed value types', () => { + const a = { num: 1, str: 'x', bool: true }; + const b = { num: 1, str: 'x', bool: true }; + expect(equal(a, b)).toBeTruthy(); + }); + + it('returns false for two different Symbols with same description', () => { + expect(equal(Symbol('x'), Symbol('x'))).toBeFalsy(); + }); + + it('compares Symbols', () => { + const s = Symbol('x'); + expect(equal(s, s)).toBeTruthy(); + }); + }); + + describe('RegExp, functions and dates', () => { + it('compares RegExp objects correctly', () => { + expect(equal(/foo/g, /foo/g)).toBeTruthy(); + expect(equal(/foo/g, /bar/g)).toBeFalsy(); + }); + + it('compares Date objects correctly', () => { + expect(equal(new Date('2020-01-01'), new Date('2020-01-01'))).toBeTruthy(); + expect(equal(new Date('2020-01-01'), new Date('2021-01-01'))).toBeFalsy(); + }); + + it('returns true only for same reference', () => { + const a = () => {}; + expect(equal(a, a)).toBeTruthy(); + }); + + it('returns false for different functions', () => { + const fn1 = () => {}; + const fn2 = () => {}; + expect(equal(fn1, fn2)).toBeFalsy(); + }); + }); +}); + +describe('edge cases: array vs object structure', () => { + it('[] vs {} is not equal', () => { + expect(equal([], {})).toBeFalsy(); + }); + + it('empty array vs object with numeric key is not equal', () => { + const arr: any = []; + const obj = { 0: undefined }; + expect(equal(arr, obj)).toBeFalsy(); + }); + + it('array with undefined value vs object with matching key is not equal', () => { + const arr = [undefined]; + const obj = { 0: undefined }; + expect(equal(arr, obj)).toBeFalsy(); + }); + + it('nested empty object vs array is not equal', () => { + expect(equal({ foo: [] }, { foo: {} })).toBeFalsy(); }); }); diff --git a/frontends/web/src/utils/equal.ts b/frontends/web/src/utils/equal.ts new file mode 100644 index 0000000000..16aa54fc31 --- /dev/null +++ b/frontends/web/src/utils/equal.ts @@ -0,0 +1,101 @@ +/** + * Copyright 2018 Shift Devices AG + * Copyright 2025 Shift Crypto AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const isArray = Array.isArray; +const hasProp = Object.prototype.hasOwnProperty; +const typedKeys = (obj: Readonly): readonly (keyof T)[] => { + return Object.keys(obj) as (keyof T)[]; +}; + +/** + * Performs a deep equality check between two values. + * + * This function compares primitive types, arrays, plain objects, Date instances, + * and RegExp objects. It returns true if the values are deeply equal, false otherwise. + * + * - Uses `Object.is` for primitive comparison (handles `NaN`, `-0`, etc.) + * - Recursively checks array contents and object properties + * - Properly compares Date and RegExp objects + * - Returns false for functions, symbols, maps, sets, or class instances (not handled) + * + * @param a - The first value to compare. + * @param b - The second value to compare. + * @returns `true` if values are deeply equal, `false` otherwise. + */ +export const equal = (a: unknown, b: unknown): boolean => { + if (Object.is(a, b)) { + return true; + } + + if (a instanceof Date && b instanceof Date) { + return a.getTime() === b.getTime(); + } + + if (a instanceof RegExp && b instanceof RegExp) { + return a.toString() === b.toString(); + } + + if ( + (a instanceof Date) !== (b instanceof Date) + || (a instanceof RegExp) !== (b instanceof RegExp) + ) { + return false; + } + + if (a && b && typeof a === 'object' && typeof b === 'object') { + if (isArray(a) && isArray(b)) { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + // handle sparse arrays + const hasA = i in a; + if (hasA !== i in b) { + return false; + } + if (hasA && !equal(a[i], b[i])) { + return false; + } + } + return true; + } + + if (isArray(a) !== isArray(b)) { + return false; + } + + const aKeys = typedKeys(a); + const bKeys = typedKeys(b); + + if (aKeys.length !== bKeys.length) { + return false; + } + + for (const key of aKeys) { + if (!hasProp.call(b, key)) { + return false; + } + if (!equal(a[key], b[key])) { + return false; + } + } + + return true; + } + + return false; +};