Skip to content

Commit ac8c3a0

Browse files
committed
Merge branch 'frontend-utilsequal-in-typescript'
2 parents 833ee9d + 6bed139 commit ac8c3a0

File tree

3 files changed

+210
-70
lines changed

3 files changed

+210
-70
lines changed

frontends/web/src/utils/equal.js

Lines changed: 0 additions & 70 deletions
This file was deleted.

frontends/web/src/utils/equal.test.tsx

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/**
22
* Copyright 2018 Shift Devices AG
3+
* Copyright 2025 Shift Crypto AG
34
*
45
* Licensed under the Apache License, Version 2.0 (the "License");
56
* you may not use this file except in compliance with the License.
@@ -27,11 +28,20 @@ describe('equal', () => {
2728
expect(equal(null, null)).toBeTruthy();
2829
});
2930

31+
it('compares undefined and null', () => {
32+
expect(equal(undefined, undefined)).toBeTruthy();
33+
expect(equal(undefined, null)).toBeFalsy();
34+
});
35+
3036
it('compares ints', () => {
3137
expect(equal(13, 13)).toBeTruthy();
3238
expect(equal(1, 13)).toBeFalsy();
3339
});
3440

41+
it('compares NaN', () => {
42+
expect(equal(NaN, NaN)).toBeTruthy();
43+
});
44+
3545
it('compares strings', () => {
3646
expect(equal('foo', 'foo')).toBeTruthy();
3747
expect(equal('foo', 'bar')).toBeFalsy();
@@ -75,6 +85,13 @@ describe('equal', () => {
7585
expect(equal(a, b)).toBeFalsy();
7686
expect(equal(b, a)).toBeFalsy();
7787
});
88+
89+
it('compares sparse array vs defined array', () => {
90+
// eslint-disable-next-line no-sparse-arrays
91+
expect(equal([1, , 3], [1, undefined, 3])).toBeFalsy();
92+
// eslint-disable-next-line no-sparse-arrays
93+
expect(equal([1, , 3], [1, , 3])).toBeTruthy();
94+
});
7895
});
7996

8097
describe('objects', () => {
@@ -83,6 +100,10 @@ describe('equal', () => {
83100
expect(equal(null, {})).toBeFalsy();
84101
});
85102

103+
it('is false for [] and {}', () => {
104+
expect(equal([], {})).toBeFalsy();
105+
});
106+
86107
it('is true for same key/value pairs', () => {
87108
const a = { one: 'two', three: 'four' };
88109
const b = { one: 'two', three: 'four' };
@@ -114,5 +135,85 @@ describe('equal', () => {
114135
expect(equal(a, null)).toBeFalsy();
115136
expect(equal(null, a)).toBeFalsy();
116137
});
138+
139+
it('doesn’t affect key order equality', () => {
140+
const a = { a: 1, b: 2 };
141+
const b = { b: 2, a: 1 };
142+
expect(equal(a, b)).toBeTruthy();
143+
});
144+
145+
it('deep compares nested structures', () => {
146+
const a = { foo: [1, { bar: 'baz' }] };
147+
const b = { foo: [1, { bar: 'baz' }] };
148+
expect(equal(a, b)).toBeTruthy();
149+
const c = { foo: [1, { bar: 'qux' }] };
150+
expect(equal(a, c)).toBeFalsy();
151+
});
152+
153+
it('fails on deep nested mismatch', () => {
154+
const a = { foo: { bar: { baz: 1 } } };
155+
const b = { foo: { bar: { baz: 2 } } };
156+
expect(equal(a, b)).toBeFalsy();
157+
});
158+
159+
it('compares object with mixed value types', () => {
160+
const a = { num: 1, str: 'x', bool: true };
161+
const b = { num: 1, str: 'x', bool: true };
162+
expect(equal(a, b)).toBeTruthy();
163+
});
164+
165+
it('returns false for two different Symbols with same description', () => {
166+
expect(equal(Symbol('x'), Symbol('x'))).toBeFalsy();
167+
});
168+
169+
it('compares Symbols', () => {
170+
const s = Symbol('x');
171+
expect(equal(s, s)).toBeTruthy();
172+
});
173+
});
174+
175+
describe('RegExp, functions and dates', () => {
176+
it('compares RegExp objects correctly', () => {
177+
expect(equal(/foo/g, /foo/g)).toBeTruthy();
178+
expect(equal(/foo/g, /bar/g)).toBeFalsy();
179+
});
180+
181+
it('compares Date objects correctly', () => {
182+
expect(equal(new Date('2020-01-01'), new Date('2020-01-01'))).toBeTruthy();
183+
expect(equal(new Date('2020-01-01'), new Date('2021-01-01'))).toBeFalsy();
184+
});
185+
186+
it('returns true only for same reference', () => {
187+
const a = () => {};
188+
expect(equal(a, a)).toBeTruthy();
189+
});
190+
191+
it('returns false for different functions', () => {
192+
const fn1 = () => {};
193+
const fn2 = () => {};
194+
expect(equal(fn1, fn2)).toBeFalsy();
195+
});
196+
});
197+
});
198+
199+
describe('edge cases: array vs object structure', () => {
200+
it('[] vs {} is not equal', () => {
201+
expect(equal([], {})).toBeFalsy();
202+
});
203+
204+
it('empty array vs object with numeric key is not equal', () => {
205+
const arr: any = [];
206+
const obj = { 0: undefined };
207+
expect(equal(arr, obj)).toBeFalsy();
208+
});
209+
210+
it('array with undefined value vs object with matching key is not equal', () => {
211+
const arr = [undefined];
212+
const obj = { 0: undefined };
213+
expect(equal(arr, obj)).toBeFalsy();
214+
});
215+
216+
it('nested empty object vs array is not equal', () => {
217+
expect(equal({ foo: [] }, { foo: {} })).toBeFalsy();
117218
});
118219
});

frontends/web/src/utils/equal.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* Copyright 2018 Shift Devices AG
3+
* Copyright 2025 Shift Crypto AG
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
const isArray = Array.isArray;
19+
const hasProp = Object.prototype.hasOwnProperty;
20+
const typedKeys = <T extends object>(obj: Readonly<T>): readonly (keyof T)[] => {
21+
return Object.keys(obj) as (keyof T)[];
22+
};
23+
24+
/**
25+
* Performs a deep comparison between two values to determine if they are equivalent.
26+
*
27+
* Handles comparison for:
28+
* - Primitives (`number`, `string`, `boolean`, `null`, `undefined`, `symbol`, `bigint`)
29+
* - `NaN` (considers `NaN` equal to `NaN`)
30+
* - Arrays (including sparse arrays)
31+
* - Plain objects (including nested structures)
32+
* - `Date` objects (compared by timestamp)
33+
* - `RegExp` objects (compared by pattern and flags)
34+
* - Symbols (only same references are equal)
35+
* - Functions (only same references are equal)
36+
*
37+
* Returns false for:
38+
* - Differing types
39+
* - Different array orders or lengths
40+
* - Objects with different keys or values
41+
* - Mismatched sparse vs dense arrays
42+
*
43+
* @param a - The first value to compare.
44+
* @param b - The second value to compare.
45+
* @returns `true` if the values are deeply equal, otherwise `false`.
46+
*/
47+
export const equal = (a: unknown, b: unknown): boolean => {
48+
if (Object.is(a, b)) {
49+
return true;
50+
}
51+
52+
if (a instanceof Date && b instanceof Date) {
53+
return a.getTime() === b.getTime();
54+
}
55+
56+
if (a instanceof RegExp && b instanceof RegExp) {
57+
return a.toString() === b.toString();
58+
}
59+
60+
if (
61+
(a instanceof Date) !== (b instanceof Date)
62+
|| (a instanceof RegExp) !== (b instanceof RegExp)
63+
) {
64+
return false;
65+
}
66+
67+
if (a && b && typeof a === 'object' && typeof b === 'object') {
68+
if (isArray(a) && isArray(b)) {
69+
if (a.length !== b.length) {
70+
return false;
71+
}
72+
for (let i = 0; i < a.length; i++) {
73+
// handle sparse arrays
74+
const hasA = i in a;
75+
if (hasA !== i in b) {
76+
return false;
77+
}
78+
if (hasA && !equal(a[i], b[i])) {
79+
return false;
80+
}
81+
}
82+
return true;
83+
}
84+
85+
if (isArray(a) !== isArray(b)) {
86+
return false;
87+
}
88+
89+
const aKeys = typedKeys(a);
90+
const bKeys = typedKeys(b);
91+
92+
if (aKeys.length !== bKeys.length) {
93+
return false;
94+
}
95+
96+
for (const key of aKeys) {
97+
if (!hasProp.call(b, key)) {
98+
return false;
99+
}
100+
if (!equal(a[key], b[key])) {
101+
return false;
102+
}
103+
}
104+
105+
return true;
106+
}
107+
108+
return false;
109+
};

0 commit comments

Comments
 (0)