Skip to content

Commit b2a8465

Browse files
committed
Extract memoizedShallowEqual to separate file and add unit tests
1 parent 9073e97 commit b2a8465

3 files changed

Lines changed: 118 additions & 34 deletions

File tree

lib/memoizedShallowEqual.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import {shallowEqual} from 'fast-equals';
2+
3+
/**
4+
* Memoizes shallowEqual verdicts by the identity of the compared objects. Onyx values are
5+
* treated as immutable (merge/set replace objects, never mutate), so a (prev, next) reference
6+
* pair always yields the same verdict. In the hot case — N no-selector hooks on the same big
7+
* key — every hook compares the exact same two cache-owned objects, so the first hook pays for
8+
* the O(keys) walk and the rest resolve in O(1). WeakMap keys make stale entries impossible to
9+
* read (lookup requires holding both exact objects) and let GC reclaim them.
10+
*/
11+
const shallowEqualVerdicts = new WeakMap<object, WeakMap<object, boolean>>();
12+
13+
/**
14+
* Identity-pair-memoized shallowEqual: same (a, b) references → cached verdict, no walk.
15+
*/
16+
function memoizedShallowEqual(a: unknown, b: unknown): boolean {
17+
// Only object pairs are memoizable (WeakMap keys) — anything else is O(1) to compare anyway.
18+
if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) {
19+
return shallowEqual(a, b);
20+
}
21+
22+
let verdictsForA = shallowEqualVerdicts.get(a);
23+
24+
if (!verdictsForA) {
25+
verdictsForA = new WeakMap();
26+
shallowEqualVerdicts.set(a, verdictsForA);
27+
}
28+
29+
const cachedVerdict = verdictsForA.get(b);
30+
31+
if (cachedVerdict !== undefined) {
32+
return cachedVerdict;
33+
}
34+
35+
const verdict = shallowEqual(a, b);
36+
verdictsForA.set(b, verdict);
37+
38+
return verdict;
39+
}
40+
41+
export default memoizedShallowEqual;

lib/useOnyx.ts

Lines changed: 1 addition & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import connectionManager from './OnyxConnectionManager';
77
import OnyxUtils from './OnyxUtils';
88
import type {CollectionKeyBase, OnyxKey, OnyxValue} from './types';
99
import onyxSnapshotCache from './OnyxSnapshotCache';
10+
import memoizedShallowEqual from './memoizedShallowEqual';
1011
import useLiveRef from './useLiveRef';
1112

1213
type UseOnyxSelector<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>> = (data: OnyxValue<TKey> | undefined) => TReturnValue;
@@ -28,40 +29,6 @@ type UseOnyxOptions<TKey extends OnyxKey, TReturnValue> = {
2829
selector?: UseOnyxSelector<TKey, TReturnValue>;
2930
};
3031

31-
// Memoizes shallowEqual verdicts by the identity of the compared objects. Onyx values are
32-
// treated as immutable (merge/set replace objects, never mutate), so a (prev, next) reference
33-
// pair always yields the same verdict. In the hot case — N no-selector hooks on the same big
34-
// key — every hook compares the exact same two cache-owned objects, so the first hook pays for
35-
// the O(keys) walk and the rest resolve in O(1). WeakMap keys make stale entries impossible to
36-
// read (lookup requires holding both exact objects) and let GC reclaim them.
37-
const shallowEqualVerdicts = new WeakMap<object, WeakMap<object, boolean>>();
38-
39-
// Identity-pair-memoized shallowEqual: same (prev, next) references → cached verdict, no walk.
40-
function memoizedShallowEqual(a: unknown, b: unknown): boolean {
41-
// Only object pairs are memoizable (WeakMap keys) — anything else is O(1) to compare anyway.
42-
if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) {
43-
return shallowEqual(a, b);
44-
}
45-
46-
let verdictsForA = shallowEqualVerdicts.get(a);
47-
48-
if (!verdictsForA) {
49-
verdictsForA = new WeakMap();
50-
shallowEqualVerdicts.set(a, verdictsForA);
51-
}
52-
53-
const cachedVerdict = verdictsForA.get(b);
54-
55-
if (cachedVerdict !== undefined) {
56-
return cachedVerdict;
57-
}
58-
59-
const verdict = shallowEqual(a, b);
60-
verdictsForA.set(b, verdict);
61-
62-
return verdict;
63-
}
64-
6532
type FetchStatus = 'loading' | 'loaded';
6633

6734
type ResultMetadata = {
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import memoizedShallowEqual from '../../lib/memoizedShallowEqual';
2+
3+
describe('memoizedShallowEqual', () => {
4+
describe('shallowEqual semantics', () => {
5+
it('returns true for the same reference', () => {
6+
const obj = {a: 1};
7+
expect(memoizedShallowEqual(obj, obj)).toBe(true);
8+
});
9+
10+
it('returns true for different references with shallowly-equal content', () => {
11+
const member = {name: 'John'};
12+
expect(memoizedShallowEqual({a: 1, member}, {a: 1, member})).toBe(true);
13+
});
14+
15+
it('returns false when a top-level value differs', () => {
16+
expect(memoizedShallowEqual({a: 1}, {a: 2})).toBe(false);
17+
});
18+
19+
it('returns false when key counts differ', () => {
20+
expect(memoizedShallowEqual({a: 1}, {a: 1, b: 2})).toBe(false);
21+
});
22+
23+
it('returns false for equal deep content with different nested references', () => {
24+
// Shallow, not deep: nested objects are compared by reference.
25+
expect(memoizedShallowEqual({member: {name: 'John'}}, {member: {name: 'John'}})).toBe(false);
26+
});
27+
28+
it('handles non-object inputs', () => {
29+
expect(memoizedShallowEqual(undefined, undefined)).toBe(true);
30+
expect(memoizedShallowEqual(undefined, {})).toBe(false);
31+
expect(memoizedShallowEqual('a', 'a')).toBe(true);
32+
expect(memoizedShallowEqual('a', 'b')).toBe(false);
33+
expect(memoizedShallowEqual(1, 1)).toBe(true);
34+
expect(memoizedShallowEqual(NaN, NaN)).toBe(true);
35+
});
36+
37+
it('handles arrays', () => {
38+
expect(memoizedShallowEqual([1, 2], [1, 2])).toBe(true);
39+
expect(memoizedShallowEqual([1, 2], [1, 3])).toBe(false);
40+
});
41+
});
42+
43+
describe('memoization', () => {
44+
it('returns the cached verdict for the same object pair without re-comparing', () => {
45+
const a = {name: 'John'};
46+
const b = {name: 'Jane'};
47+
expect(memoizedShallowEqual(a, b)).toBe(false);
48+
49+
// Mutate `b` so the objects are now content-equal. Onyx values are immutable,
50+
// so the memo is expected to keep returning the verdict computed for this exact
51+
// (a, b) pair — proving the second call resolved from the cache, not a re-compare.
52+
b.name = 'John';
53+
expect(memoizedShallowEqual(a, b)).toBe(false);
54+
});
55+
56+
it('caches verdicts per pair, not per object', () => {
57+
const a = {x: 1};
58+
const equalToA = {x: 1};
59+
const differentFromA = {x: 2};
60+
61+
expect(memoizedShallowEqual(a, equalToA)).toBe(true);
62+
expect(memoizedShallowEqual(a, differentFromA)).toBe(false);
63+
64+
// Both verdicts are retained independently for the same `a`.
65+
expect(memoizedShallowEqual(a, equalToA)).toBe(true);
66+
expect(memoizedShallowEqual(a, differentFromA)).toBe(false);
67+
});
68+
69+
it('does not memoize non-object inputs', () => {
70+
// Primitives cannot be WeakMap keys; these calls must not throw and must compare directly.
71+
expect(memoizedShallowEqual(1, {})).toBe(false);
72+
expect(memoizedShallowEqual({}, 1)).toBe(false);
73+
expect(memoizedShallowEqual(null, null)).toBe(true);
74+
});
75+
});
76+
});

0 commit comments

Comments
 (0)