@@ -825,6 +825,101 @@ describe('OnyxUtils', () => {
825825 } ) ;
826826 } ) ;
827827
828+ describe ( 'mergeCollection cache-first ordering' , ( ) => {
829+ // Save originals so we can restore them after each test. The tests below replace
830+ // StorageMock.multiMerge / StorageMock.multiSet with rejecting mocks; without
831+ // restoring, the mock leaks into later describe blocks (e.g. eviction tests) whose
832+ // setup relies on these storage methods working normally.
833+ const originalMultiMerge = StorageMock . multiMerge ;
834+ const originalMultiSet = StorageMock . multiSet ;
835+
836+ afterEach ( ( ) => {
837+ StorageMock . multiMerge = originalMultiMerge ;
838+ StorageMock . multiSet = originalMultiSet ;
839+ } ) ;
840+
841+ it ( 'updates cache and notifies subscribers even when Storage.multiMerge rejects' , async ( ) => {
842+ const collectionKey = ONYXKEYS . COLLECTION . TEST_KEY ;
843+ const existingMemberKey = `${ collectionKey } 1` ;
844+ const newMemberKey = `${ collectionKey } 2` ;
845+
846+ // Seed an existing member so the merge path exercises multiMerge (existing) + multiSet (new)
847+ await Onyx . set ( existingMemberKey , { value : 'initial' } ) ;
848+
849+ const collectionCallback = jest . fn ( ) ;
850+ Onyx . connect ( {
851+ key : collectionKey ,
852+ waitForCollectionCallback : true ,
853+ callback : collectionCallback ,
854+ } ) ;
855+ await waitForPromisesToResolve ( ) ;
856+ collectionCallback . mockClear ( ) ;
857+
858+ // Force Storage.multiMerge to reject with a non-retriable IDB error so the failure
859+ // path is taken without burning the full retry budget and without rejecting the
860+ // outer Onyx.mergeCollection promise.
861+ const nonRetriableIdbError = Object . assign ( new Error ( 'Internal error opening backing store for indexedDB.open.' ) , { name : 'UnknownError' } ) ;
862+ StorageMock . multiMerge = jest . fn ( ) . mockRejectedValue ( nonRetriableIdbError ) ;
863+
864+ await Onyx . mergeCollection ( collectionKey , {
865+ [ existingMemberKey ] : { value : 'merged' } ,
866+ [ newMemberKey ] : { value : 'new' } ,
867+ } as GenericCollection ) ;
868+
869+ // Cache must reflect the merge regardless of the multiMerge rejection. This is the
870+ // cache-first / storage-second invariant that mergeCollectionWithPatches must honor.
871+ const cachedCollection = OnyxCache . getCollectionData ( collectionKey ) ;
872+ expect ( cachedCollection ?. [ existingMemberKey ] ) . toEqual ( { value : 'merged' } ) ;
873+ expect ( cachedCollection ?. [ newMemberKey ] ) . toEqual ( { value : 'new' } ) ;
874+
875+ // Subscribers must have been notified with the merged values.
876+ expect ( collectionCallback ) . toHaveBeenCalled ( ) ;
877+ const lastBroadcast = collectionCallback . mock . calls . at ( - 1 ) ?. [ 0 ] as Record < string , unknown > | undefined ;
878+ expect ( lastBroadcast ?. [ existingMemberKey ] ) . toEqual ( { value : 'merged' } ) ;
879+ expect ( lastBroadcast ?. [ newMemberKey ] ) . toEqual ( { value : 'new' } ) ;
880+ } ) ;
881+
882+ it ( 'updates cache and notifies subscribers even when Storage.multiSet rejects' , async ( ) => {
883+ const collectionKey = ONYXKEYS . COLLECTION . TEST_KEY ;
884+ const newMemberKey1 = `${ collectionKey } 1` ;
885+ const newMemberKey2 = `${ collectionKey } 2` ;
886+
887+ // No keys are seeded, so every merged key is a "new" key. This forces the merge path
888+ // to use Storage.multiSet (existing keys would go through Storage.multiMerge).
889+ const collectionCallback = jest . fn ( ) ;
890+ Onyx . connect ( {
891+ key : collectionKey ,
892+ waitForCollectionCallback : true ,
893+ callback : collectionCallback ,
894+ } ) ;
895+ await waitForPromisesToResolve ( ) ;
896+ collectionCallback . mockClear ( ) ;
897+
898+ // Force Storage.multiSet to reject with a non-retriable IDB error so the failure
899+ // path is taken without burning the full retry budget and without rejecting the
900+ // outer Onyx.mergeCollection promise.
901+ const nonRetriableIdbError = Object . assign ( new Error ( 'Internal error opening backing store for indexedDB.open.' ) , { name : 'UnknownError' } ) ;
902+ StorageMock . multiSet = jest . fn ( ) . mockRejectedValue ( nonRetriableIdbError ) ;
903+
904+ await Onyx . mergeCollection ( collectionKey , {
905+ [ newMemberKey1 ] : { value : 'first' } ,
906+ [ newMemberKey2 ] : { value : 'second' } ,
907+ } as GenericCollection ) ;
908+
909+ // Cache must reflect the merge regardless of the multiSet rejection. This is the
910+ // cache-first / storage-second invariant that mergeCollectionWithPatches must honor.
911+ const cachedCollection = OnyxCache . getCollectionData ( collectionKey ) ;
912+ expect ( cachedCollection ?. [ newMemberKey1 ] ) . toEqual ( { value : 'first' } ) ;
913+ expect ( cachedCollection ?. [ newMemberKey2 ] ) . toEqual ( { value : 'second' } ) ;
914+
915+ // Subscribers must have been notified with the merged values.
916+ expect ( collectionCallback ) . toHaveBeenCalled ( ) ;
917+ const lastBroadcast = collectionCallback . mock . calls . at ( - 1 ) ?. [ 0 ] as Record < string , unknown > | undefined ;
918+ expect ( lastBroadcast ?. [ newMemberKey1 ] ) . toEqual ( { value : 'first' } ) ;
919+ expect ( lastBroadcast ?. [ newMemberKey2 ] ) . toEqual ( { value : 'second' } ) ;
920+ } ) ;
921+ } ) ;
922+
828923 describe ( 'storage eviction' , ( ) => {
829924 const diskFullError = new Error ( 'database or disk is full' ) ;
830925
0 commit comments