Skip to content

Commit fa71e47

Browse files
authored
Merge pull request #810 from callstack-internal/perf/memoize-useonyx-shallowequal
Memoize shallowEqual verdicts by object identity in useOnyx
2 parents 13ad6fa + 51bdc1b commit fa71e47

3 files changed

Lines changed: 122 additions & 2 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: 5 additions & 2 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;
@@ -201,9 +202,11 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(
201202

202203
// shallowEqual checks === first (O(1) for frozen snapshots and stable selector references),
203204
// then falls back to comparing top-level properties for individual keys that may have
204-
// new references with equivalent content.
205+
// new references with equivalent content. The comparison is memoized by object identity
206+
// (see `memoizedShallowEqual`) so N hooks comparing the same two cache objects pay for
207+
// one walk in total instead of one walk each.
205208
// Normalize null to undefined to ensure consistent comparison (both represent "no value").
206-
const areValuesEqual = shallowEqual(previousValueRef.current ?? undefined, newValueRef.current ?? undefined);
209+
const areValuesEqual = memoizedShallowEqual(previousValueRef.current ?? undefined, newValueRef.current ?? undefined);
207210

208211
// We update the cached value and the result in the following conditions:
209212
// We will update the cached value and the result in any of the following situations:
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('should return true for the same reference', () => {
6+
const obj = {a: 1};
7+
expect(memoizedShallowEqual(obj, obj)).toBe(true);
8+
});
9+
10+
it('should return 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('should return false when a top-level value differs', () => {
16+
expect(memoizedShallowEqual({a: 1}, {a: 2})).toBe(false);
17+
});
18+
19+
it('should return false when key counts differ', () => {
20+
expect(memoizedShallowEqual({a: 1}, {a: 1, b: 2})).toBe(false);
21+
});
22+
23+
it('should return 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('should handle 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('should handle 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('should return 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('should cache 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('should 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)