@@ -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' ,
@@ -28,9 +38,6 @@ class OnyxCache {
2838 /** A map of cached values */
2939 private storageMap : Record < OnyxKey , OnyxValue < OnyxKey > > ;
3040
31- /** Cache of complete collection data objects for O(1) retrieval */
32- private collectionData : Record < OnyxKey , Record < OnyxKey , OnyxValue < OnyxKey > > > ;
33-
3441 /**
3542 * Captured pending tasks for already running storage methods
3643 * Using a map yields better performance on operations such a delete
@@ -43,12 +50,19 @@ class OnyxCache {
4350 /** List of keys that have been directly subscribed to or recently modified from least to most recent */
4451 private recentlyAccessedKeys = new Set < OnyxKey > ( ) ;
4552
53+ /** Frozen collection snapshots for structural sharing */
54+ private collectionSnapshots : Map < OnyxKey , CollectionSnapshot > ;
55+
56+ /** Collections whose snapshots need rebuilding (lazy — rebuilt on next read) */
57+ private dirtyCollections : Set < CollectionKeyBase > ;
58+
4659 constructor ( ) {
4760 this . storageKeys = new Set ( ) ;
4861 this . nullishStorageKeys = new Set ( ) ;
4962 this . storageMap = { } ;
50- this . collectionData = { } ;
5163 this . pendingPromises = new Map ( ) ;
64+ this . collectionSnapshots = new Map ( ) ;
65+ this . dirtyCollections = new Set ( ) ;
5266
5367 // bind all public methods to prevent problems with `this`
5468 bindAll (
@@ -74,8 +88,8 @@ class OnyxCache {
7488 'addEvictableKeysToRecentlyAccessedList' ,
7589 'getKeyForEviction' ,
7690 'setCollectionKeys' ,
77- 'getCollectionData' ,
7891 'hasValueChanged' ,
92+ 'getCollectionData' ,
7993 ) ;
8094 }
8195
@@ -147,24 +161,21 @@ class OnyxCache {
147161 this . nullishStorageKeys . delete ( key ) ;
148162
149163 const collectionKey = OnyxKeys . getCollectionKey ( key ) ;
164+ const oldValue = this . storageMap [ key ] ;
165+
150166 if ( value === null || value === undefined ) {
151167 delete this . storageMap [ key ] ;
152168
153- // Remove from collection data cache if it's a collection member
154- if ( collectionKey && this . collectionData [ collectionKey ] ) {
155- delete this . collectionData [ collectionKey ] [ key ] ;
169+ if ( collectionKey && oldValue !== undefined ) {
170+ this . dirtyCollections . add ( collectionKey ) ;
156171 }
157172 return undefined ;
158173 }
159174
160175 this . storageMap [ key ] = value ;
161176
162- // Update collection data cache if this is a collection member
163- if ( collectionKey ) {
164- if ( ! this . collectionData [ collectionKey ] ) {
165- this . collectionData [ collectionKey ] = { } ;
166- }
167- this . collectionData [ collectionKey ] [ key ] = value ;
177+ if ( collectionKey && oldValue !== value ) {
178+ this . dirtyCollections . add ( collectionKey ) ;
168179 }
169180
170181 return value ;
@@ -174,15 +185,14 @@ class OnyxCache {
174185 drop ( key : OnyxKey ) : void {
175186 delete this . storageMap [ key ] ;
176187
177- // Remove from collection data cache if this is a collection member
178188 const collectionKey = OnyxKeys . getCollectionKey ( key ) ;
179- if ( collectionKey && this . collectionData [ collectionKey ] ) {
180- delete this . collectionData [ collectionKey ] [ key ] ;
189+ if ( collectionKey ) {
190+ this . dirtyCollections . add ( collectionKey ) ;
181191 }
182192
183- // If this is a collection key, clear its data
193+ // If this is a collection key, clear its snapshot
184194 if ( OnyxKeys . isCollectionKey ( key ) ) {
185- delete this . collectionData [ key ] ;
195+ this . collectionSnapshots . delete ( key ) ;
186196 }
187197
188198 this . storageKeys . delete ( key ) ;
@@ -198,37 +208,54 @@ class OnyxCache {
198208 throw new Error ( 'data passed to cache.merge() must be an Object of onyx key/value pairs' ) ;
199209 }
200210
201- this . storageMap = {
202- ...utils . fastMerge ( this . storageMap , data , {
203- shouldRemoveNestedNulls : true ,
204- objectRemovalMode : 'replace' ,
205- } ) . result ,
206- } ;
211+ const affectedCollections = new Set < OnyxKey > ( ) ;
207212
208213 for ( const [ key , value ] of Object . entries ( data ) ) {
209214 this . addKey ( key ) ;
210215
211216 const collectionKey = OnyxKeys . getCollectionKey ( key ) ;
212217
213- if ( value === null || value === undefined ) {
218+ if ( value === undefined ) {
219+ this . addNullishStorageKey ( key ) ;
220+ // undefined means "no change" — skip storageMap modification
221+ continue ;
222+ }
223+
224+ if ( value === null ) {
214225 this . addNullishStorageKey ( key ) ;
226+ delete this . storageMap [ key ] ;
215227
216- // Remove from collection data cache if it's a collection member
217- if ( collectionKey && this . collectionData [ collectionKey ] ) {
218- delete this . collectionData [ collectionKey ] [ key ] ;
228+ if ( collectionKey ) {
229+ affectedCollections . add ( collectionKey ) ;
219230 }
220231 } else {
221232 this . nullishStorageKeys . delete ( key ) ;
222233
223- // Update collection data cache if this is a collection member
234+ // Per-key merge instead of spreading the entire storageMap
235+ const existing = this . storageMap [ key ] ;
236+ const merged = utils . fastMerge ( existing , value , {
237+ shouldRemoveNestedNulls : true ,
238+ objectRemovalMode : 'replace' ,
239+ } ) . result ;
240+
241+ // fastMerge is reference-stable: returns the original target when
242+ // nothing changed, so a simple === check detects no-ops.
243+ if ( merged === existing ) {
244+ continue ;
245+ }
246+
247+ this . storageMap [ key ] = merged ;
248+
224249 if ( collectionKey ) {
225- if ( ! this . collectionData [ collectionKey ] ) {
226- this . collectionData [ collectionKey ] = { } ;
227- }
228- this . collectionData [ collectionKey ] [ key ] = this . storageMap [ key ] ;
250+ affectedCollections . add ( collectionKey ) ;
229251 }
230252 }
231253 }
254+
255+ // Mark affected collections as dirty — snapshots will be lazily rebuilt on next read
256+ for ( const collectionKey of affectedCollections ) {
257+ this . dirtyCollections . add ( collectionKey ) ;
258+ }
232259 }
233260
234261 /**
@@ -264,9 +291,12 @@ class OnyxCache {
264291 return returnPromise ;
265292 }
266293
267- /** Check if the value has changed */
294+ /** Check if the value has changed. Uses reference equality as a fast path, falls back to deep equality. */
268295 hasValueChanged ( key : OnyxKey , value : OnyxValue < OnyxKey > ) : boolean {
269- const currentValue = this . get ( key ) ;
296+ const currentValue = this . storageMap [ key ] ;
297+ if ( currentValue === value ) {
298+ return false ;
299+ }
270300 return ! deepEqual ( currentValue , value ) ;
271301 }
272302
@@ -345,26 +375,103 @@ class OnyxCache {
345375 setCollectionKeys ( collectionKeys : Set < OnyxKey > ) : void {
346376 OnyxKeys . setCollectionKeys ( collectionKeys ) ;
347377
348- // Initialize collection data for existing collection keys
378+ // Initialize frozen snapshots for collection keys
349379 for ( const collectionKey of collectionKeys ) {
350- if ( this . collectionData [ collectionKey ] ) {
380+ if ( ! this . collectionSnapshots . has ( collectionKey ) ) {
381+ this . collectionSnapshots . set ( collectionKey , Object . freeze ( { } ) ) ;
382+ }
383+ }
384+ }
385+
386+ /**
387+ * Rebuilds the frozen collection snapshot from current storageMap references.
388+ * Uses the indexed collection->members map for O(collectionMembers) instead of O(totalKeys).
389+ * Returns the previous snapshot reference when all member references are identical,
390+ * preventing unnecessary re-renders in useSyncExternalStore.
391+ *
392+ * @param collectionKey - The collection key to rebuild
393+ */
394+ private rebuildCollectionSnapshot ( collectionKey : OnyxKey ) : void {
395+ const previousSnapshot = this . collectionSnapshots . get ( collectionKey ) ;
396+
397+ const members : NonUndefined < OnyxCollection < KeyValueMapping [ OnyxKey ] > > = { } ;
398+ let hasMemberChanges = false ;
399+
400+ // Use the indexed forward lookup for O(collectionMembers) iteration.
401+ // Falls back to scanning all storageKeys if the index isn't populated yet.
402+ const memberKeys = OnyxKeys . getMembersOfCollection ( collectionKey ) ;
403+ const keysToScan = memberKeys ?? this . storageKeys ;
404+ const needsPrefixCheck = ! memberKeys ;
405+
406+ for ( const key of keysToScan ) {
407+ // When using the fallback path (scanning all storageKeys instead of the indexed
408+ // forward lookup), skip keys that don't belong to this collection.
409+ if ( needsPrefixCheck && OnyxKeys . getCollectionKey ( key ) !== collectionKey ) {
351410 continue ;
352411 }
353- this . collectionData [ collectionKey ] = { } ;
412+ const val = this . storageMap [ key ] ;
413+ // Skip null/undefined values — they represent deleted or unset keys
414+ // and should not be included in the frozen collection snapshot.
415+ if ( val !== undefined && val !== null ) {
416+ members [ key ] = val ;
417+
418+ // Check if this member's reference changed from the old snapshot
419+ if ( ! hasMemberChanges && ( ! previousSnapshot || previousSnapshot [ key ] !== val ) ) {
420+ hasMemberChanges = true ;
421+ }
422+ }
354423 }
424+
425+ // Check if any members were removed from the previous snapshot.
426+ // We can't rely on count comparison alone — if one key is removed and another added,
427+ // the counts match but the snapshot content is different.
428+ if ( ! hasMemberChanges && previousSnapshot ) {
429+ // eslint-disable-next-line no-restricted-syntax
430+ for ( const key in previousSnapshot ) {
431+ if ( ! ( key in members ) ) {
432+ hasMemberChanges = true ;
433+ break ;
434+ }
435+ }
436+ }
437+
438+ // If nothing actually changed, reuse the old snapshot reference.
439+ // This is critical: useSyncExternalStore uses === to detect changes,
440+ // so returning the same reference prevents unnecessary re-renders.
441+ if ( ! hasMemberChanges && previousSnapshot ) {
442+ return ;
443+ }
444+
445+ Object . freeze ( members ) ;
446+
447+ this . collectionSnapshots . set ( collectionKey , members ) ;
355448 }
356449
357450 /**
358- * Get all data for a collection key
451+ * Get all data for a collection key.
452+ * Returns a frozen snapshot with structural sharing — safe to return by reference.
453+ * Lazily rebuilds the snapshot if the collection was modified since the last read.
359454 */
360455 getCollectionData ( collectionKey : OnyxKey ) : Record < OnyxKey , OnyxValue < OnyxKey > > | undefined {
361- const cachedCollection = this . collectionData [ collectionKey ] ;
362- if ( ! cachedCollection || Object . keys ( cachedCollection ) . length === 0 ) {
456+ if ( this . dirtyCollections . has ( collectionKey ) ) {
457+ this . rebuildCollectionSnapshot ( collectionKey ) ;
458+ this . dirtyCollections . delete ( collectionKey ) ;
459+ }
460+
461+ const snapshot = this . collectionSnapshots . get ( collectionKey ) ;
462+ if ( utils . isEmptyObject ( snapshot ) ) {
463+ // We check storageKeys.size (not collection-specific keys) to distinguish
464+ // "init complete, this collection is genuinely empty" from "init not done yet."
465+ // During init, setAllKeys loads ALL keys at once — so if any key exists,
466+ // the full storage picture is loaded and an empty collection is truly empty.
467+ // Returning undefined before init prevents subscribers from seeing a false empty state.
468+ if ( this . storageKeys . size > 0 ) {
469+ return FROZEN_EMPTY_COLLECTION ;
470+ }
363471 return undefined ;
364472 }
365473
366- // Return a shallow copy to ensure React detects changes when items are added/removed
367- return { ...cachedCollection } ;
474+ return snapshot ;
368475 }
369476}
370477
0 commit comments