@@ -2,9 +2,19 @@ import {deepEqual} from 'fast-equals';
22import bindAll from 'lodash/bindAll' ;
33import type { ValueOf } from 'type-fest' ;
44import utils from './utils' ;
5- import type { OnyxKey , OnyxValue } from './types' ;
5+ import type { CollectionKeyBase , KeyValueMapping , NonUndefined , OnyxCollection , OnyxKey , OnyxValue } from './types' ;
66import 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
919const 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
457559const instance = new OnyxCache ( ) ;
0 commit comments