Skip to content

Commit aace5a9

Browse files
fabioh8010claude
andcommitted
perf: frozen collection snapshots with lazy rebuild and per-key merge
Replace mutable collectionData with frozen collectionSnapshots and dirty tracking. Collection snapshots are lazily rebuilt on read, returning stable frozen references for structural sharing. The merge() method now operates per-key instead of spreading the entire storageMap, and hasValueChanged() uses reference equality as a fast path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3217c90 commit aace5a9

1 file changed

Lines changed: 153 additions & 51 deletions

File tree

lib/OnyxCache.ts

Lines changed: 153 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,19 @@ import {deepEqual} from 'fast-equals';
22
import bindAll from 'lodash/bindAll';
33
import type {ValueOf} from 'type-fest';
44
import utils from './utils';
5-
import type {OnyxKey, OnyxValue} from './types';
5+
import type {CollectionKeyBase, KeyValueMapping, NonUndefined, OnyxCollection, OnyxKey, OnyxValue} from './types';
66
import OnyxKeys from './OnyxKeys';
77

8+
/** Frozen object containing all collection members — safe to return by reference */
9+
type CollectionSnapshot = Readonly<NonUndefined<OnyxCollection<KeyValueMapping[OnyxKey]>>>;
10+
11+
/**
12+
* Stable frozen empty object used as the canonical value for empty collections.
13+
* Returning the same reference avoids unnecessary re-renders in useSyncExternalStore,
14+
* which relies on === equality to detect changes.
15+
*/
16+
const FROZEN_EMPTY_COLLECTION: Readonly<NonUndefined<OnyxCollection<KeyValueMapping[OnyxKey]>>> = Object.freeze({});
17+
818
// Task constants
919
const TASK = {
1020
GET: 'get',
@@ -31,9 +41,6 @@ class OnyxCache {
3141
/** A map of cached values */
3242
private storageMap: Record<OnyxKey, OnyxValue<OnyxKey>>;
3343

34-
/** Cache of complete collection data objects for O(1) retrieval */
35-
private collectionData: Record<OnyxKey, Record<OnyxKey, OnyxValue<OnyxKey>>>;
36-
3744
/**
3845
* Captured pending tasks for already running storage methods
3946
* Using a map yields better performance on operations such a delete
@@ -52,13 +59,20 @@ class OnyxCache {
5259
/** List of keys that have been directly subscribed to or recently modified from least to most recent */
5360
private recentlyAccessedKeys = new Set<OnyxKey>();
5461

62+
/** Frozen collection snapshots for structural sharing */
63+
private collectionSnapshots: Map<OnyxKey, CollectionSnapshot>;
64+
65+
/** Collections whose snapshots need rebuilding (lazy — rebuilt on next read) */
66+
private dirtyCollections: Set<CollectionKeyBase>;
67+
5568
constructor() {
5669
this.storageKeys = new Set();
5770
this.nullishStorageKeys = new Set();
5871
this.recentKeys = new Set();
5972
this.storageMap = {};
60-
this.collectionData = {};
6173
this.pendingPromises = new Map();
74+
this.collectionSnapshots = new Map();
75+
this.dirtyCollections = new Set();
6276

6377
// bind all public methods to prevent problems with `this`
6478
bindAll(
@@ -88,8 +102,8 @@ class OnyxCache {
88102
'addEvictableKeysToRecentlyAccessedList',
89103
'getKeyForEviction',
90104
'setCollectionKeys',
91-
'getCollectionData',
92105
'hasValueChanged',
106+
'getCollectionData',
93107
);
94108
}
95109

@@ -168,24 +182,21 @@ class OnyxCache {
168182
this.nullishStorageKeys.delete(key);
169183

170184
const collectionKey = OnyxKeys.getCollectionKey(key);
185+
const oldValue = this.storageMap[key];
186+
171187
if (value === null || value === undefined) {
172188
delete this.storageMap[key];
173189

174-
// Remove from collection data cache if it's a collection member
175-
if (collectionKey && this.collectionData[collectionKey]) {
176-
delete this.collectionData[collectionKey][key];
190+
if (collectionKey && oldValue !== undefined) {
191+
this.dirtyCollections.add(collectionKey);
177192
}
178193
return undefined;
179194
}
180195

181196
this.storageMap[key] = value;
182197

183-
// Update collection data cache if this is a collection member
184-
if (collectionKey) {
185-
if (!this.collectionData[collectionKey]) {
186-
this.collectionData[collectionKey] = {};
187-
}
188-
this.collectionData[collectionKey][key] = value;
198+
if (collectionKey && oldValue !== value) {
199+
this.dirtyCollections.add(collectionKey);
189200
}
190201

191202
return value;
@@ -195,15 +206,14 @@ class OnyxCache {
195206
drop(key: OnyxKey): void {
196207
delete this.storageMap[key];
197208

198-
// Remove from collection data cache if this is a collection member
199209
const collectionKey = OnyxKeys.getCollectionKey(key);
200-
if (collectionKey && this.collectionData[collectionKey]) {
201-
delete this.collectionData[collectionKey][key];
210+
if (collectionKey) {
211+
this.dirtyCollections.add(collectionKey);
202212
}
203213

204-
// If this is a collection key, clear its data
214+
// If this is a collection key, clear its snapshot
205215
if (OnyxKeys.isCollectionKey(key)) {
206-
delete this.collectionData[key];
216+
this.collectionSnapshots.delete(key);
207217
}
208218

209219
this.storageKeys.delete(key);
@@ -220,38 +230,55 @@ class OnyxCache {
220230
throw new Error('data passed to cache.merge() must be an Object of onyx key/value pairs');
221231
}
222232

223-
this.storageMap = {
224-
...utils.fastMerge(this.storageMap, data, {
225-
shouldRemoveNestedNulls: true,
226-
objectRemovalMode: 'replace',
227-
}).result,
228-
};
233+
const affectedCollections = new Set<OnyxKey>();
229234

230235
for (const [key, value] of Object.entries(data)) {
231236
this.addKey(key);
232237
this.addToAccessedKeys(key);
233238

234239
const collectionKey = OnyxKeys.getCollectionKey(key);
235240

236-
if (value === null || value === undefined) {
241+
if (value === undefined) {
237242
this.addNullishStorageKey(key);
243+
// undefined means "no change" — skip storageMap modification
244+
continue;
245+
}
246+
247+
if (value === null) {
248+
this.addNullishStorageKey(key);
249+
delete this.storageMap[key];
238250

239-
// Remove from collection data cache if it's a collection member
240-
if (collectionKey && this.collectionData[collectionKey]) {
241-
delete this.collectionData[collectionKey][key];
251+
if (collectionKey) {
252+
affectedCollections.add(collectionKey);
242253
}
243254
} else {
244255
this.nullishStorageKeys.delete(key);
245256

246-
// Update collection data cache if this is a collection member
257+
// Per-key merge instead of spreading the entire storageMap
258+
const existing = this.storageMap[key];
259+
const merged = utils.fastMerge(existing, value, {
260+
shouldRemoveNestedNulls: true,
261+
objectRemovalMode: 'replace',
262+
}).result;
263+
264+
// fastMerge is reference-stable: returns the original target when
265+
// nothing changed, so a simple === check detects no-ops.
266+
if (merged === existing) {
267+
continue;
268+
}
269+
270+
this.storageMap[key] = merged;
271+
247272
if (collectionKey) {
248-
if (!this.collectionData[collectionKey]) {
249-
this.collectionData[collectionKey] = {};
250-
}
251-
this.collectionData[collectionKey][key] = this.storageMap[key];
273+
affectedCollections.add(collectionKey);
252274
}
253275
}
254276
}
277+
278+
// Mark affected collections as dirty — snapshots will be lazily rebuilt on next read
279+
for (const collectionKey of affectedCollections) {
280+
this.dirtyCollections.add(collectionKey);
281+
}
255282
}
256283

257284
/**
@@ -317,26 +344,35 @@ class OnyxCache {
317344
iterResult = iterator.next();
318345
}
319346

347+
const affectedCollections = new Set<OnyxKey>();
348+
320349
for (const key of keysToRemove) {
321350
delete this.storageMap[key];
322351

323-
// Remove from collection data cache if this is a collection member
352+
// Track affected collections for snapshot rebuild
324353
const collectionKey = OnyxKeys.getCollectionKey(key);
325-
if (collectionKey && this.collectionData[collectionKey]) {
326-
delete this.collectionData[collectionKey][key];
354+
if (collectionKey) {
355+
affectedCollections.add(collectionKey);
327356
}
328357
this.recentKeys.delete(key);
329358
}
359+
360+
for (const collectionKey of affectedCollections) {
361+
this.dirtyCollections.add(collectionKey);
362+
}
330363
}
331364

332365
/** Set the recent keys list size */
333366
setRecentKeysLimit(limit: number): void {
334367
this.maxRecentKeysSize = limit;
335368
}
336369

337-
/** Check if the value has changed */
370+
/** Check if the value has changed. Uses reference equality as a fast path, falls back to deep equality. */
338371
hasValueChanged(key: OnyxKey, value: OnyxValue<OnyxKey>): boolean {
339-
const currentValue = this.get(key, false);
372+
const currentValue = this.storageMap[key];
373+
if (currentValue === value) {
374+
return false;
375+
}
340376
return !deepEqual(currentValue, value);
341377
}
342378

@@ -425,33 +461,99 @@ class OnyxCache {
425461
setCollectionKeys(collectionKeys: Set<OnyxKey>): void {
426462
OnyxKeys.setCollectionKeys(collectionKeys);
427463

428-
// Initialize collection data for existing collection keys
464+
// Initialize frozen snapshots for collection keys
429465
for (const collectionKey of collectionKeys) {
430-
if (this.collectionData[collectionKey]) {
431-
continue;
466+
if (!this.collectionSnapshots.has(collectionKey)) {
467+
this.collectionSnapshots.set(collectionKey, Object.freeze({}));
432468
}
433-
this.collectionData[collectionKey] = {};
434469
}
435470

436-
// Register existing storageKeys with OnyxKeys
471+
// Pre-populate the reverse lookup map for any existing keys
437472
for (const key of this.storageKeys) {
438473
OnyxKeys.registerMemberKey(key);
439474
}
440475
}
441476

442477
/**
443-
* Get all data for a collection key
478+
* Rebuilds the frozen collection snapshot from current storageMap references.
479+
* Uses the indexed collection->members map for O(collectionMembers) instead of O(totalKeys).
480+
* Returns the previous snapshot reference when all member references are identical,
481+
* preventing unnecessary re-renders in useSyncExternalStore.
482+
*
483+
* @param collectionKey - The collection key to rebuild
484+
*/
485+
private rebuildCollectionSnapshot(collectionKey: OnyxKey): void {
486+
const oldSnapshot = this.collectionSnapshots.get(collectionKey);
487+
488+
const members: NonUndefined<OnyxCollection<KeyValueMapping[OnyxKey]>> = {};
489+
let hasChanges = false;
490+
let newMemberCount = 0;
491+
492+
// Use the indexed forward lookup for O(collectionMembers) iteration.
493+
// Falls back to scanning all storageKeys if the index isn't populated yet.
494+
const memberKeys = OnyxKeys.getMembersOfCollection(collectionKey);
495+
const keysToScan = memberKeys ?? this.storageKeys;
496+
const needsPrefixCheck = !memberKeys;
497+
498+
for (const key of keysToScan) {
499+
if (needsPrefixCheck && !OnyxKeys.isCollectionMemberKey(collectionKey, key)) {
500+
continue;
501+
}
502+
const val = this.storageMap[key];
503+
if (val !== undefined && val !== null) {
504+
members[key] = val;
505+
newMemberCount++;
506+
507+
// Check if this member's reference changed from the old snapshot
508+
if (!hasChanges && (!oldSnapshot || oldSnapshot[key] !== val)) {
509+
hasChanges = true;
510+
}
511+
}
512+
}
513+
514+
// Check if any members were removed (old snapshot had more keys)
515+
if (!hasChanges && oldSnapshot) {
516+
const oldMemberCount = Object.keys(oldSnapshot).length;
517+
if (oldMemberCount !== newMemberCount) {
518+
hasChanges = true;
519+
}
520+
}
521+
522+
// If nothing actually changed, reuse the old snapshot reference.
523+
// This is critical: useSyncExternalStore uses === to detect changes,
524+
// so returning the same reference prevents unnecessary re-renders.
525+
if (!hasChanges && oldSnapshot) {
526+
return;
527+
}
528+
529+
Object.freeze(members);
530+
531+
this.collectionSnapshots.set(collectionKey, members);
532+
}
533+
534+
/**
535+
* Get all data for a collection key.
536+
* Returns a frozen snapshot with structural sharing — safe to return by reference.
537+
* Lazily rebuilds the snapshot if the collection was modified since the last read.
444538
*/
445539
getCollectionData(collectionKey: OnyxKey): Record<OnyxKey, OnyxValue<OnyxKey>> | undefined {
446-
const cachedCollection = this.collectionData[collectionKey];
447-
if (!cachedCollection || Object.keys(cachedCollection).length === 0) {
540+
if (this.dirtyCollections.has(collectionKey)) {
541+
this.rebuildCollectionSnapshot(collectionKey);
542+
this.dirtyCollections.delete(collectionKey);
543+
}
544+
545+
const snapshot = this.collectionSnapshots.get(collectionKey);
546+
if (!snapshot || Object.keys(snapshot).length === 0) {
547+
// If we know we have storage keys loaded, return a stable empty reference
548+
// to avoid new {} allocations that break useSyncExternalStore === equality.
549+
if (this.storageKeys.size > 0) {
550+
return FROZEN_EMPTY_COLLECTION;
551+
}
448552
return undefined;
449553
}
450554

451-
// Return a shallow copy to ensure React detects changes when items are added/removed
452-
return {...cachedCollection};
555+
return snapshot;
453556
}
454-
455557
}
456558

457559
const instance = new OnyxCache();

0 commit comments

Comments
 (0)