@@ -920,6 +920,128 @@ describe('OnyxUtils', () => {
920920 } ) ;
921921 } ) ;
922922
923+ describe ( 'retry side-effect idempotency' , ( ) => {
924+ // Save originals so each test can replace StorageMock.multiMerge / StorageMock.multiSet
925+ // with a one-shot rejecting mock that triggers retryOperation's transient-error path.
926+ // Restoring keeps mocks from leaking into the storage-eviction describe block below.
927+ const originalMultiMerge = StorageMock . multiMerge ;
928+ const originalMultiSet = StorageMock . multiSet ;
929+
930+ afterEach ( ( ) => {
931+ StorageMock . multiMerge = originalMultiMerge ;
932+ StorageMock . multiSet = originalMultiSet ;
933+ } ) ;
934+
935+ // A retriable error: not in NON_RETRIABLE_ERRORS, not in STORAGE_ERRORS, so retryOperation
936+ // re-enters the failing method on the next attempt.
937+ const transientError = new Error ( 'Transient storage error' ) ;
938+
939+ it ( 'mergeCollection — waitForCollectionCallback subscriber fires once across retries' , async ( ) => {
940+ const collectionKey = ONYXKEYS . COLLECTION . TEST_KEY ;
941+ const existingMemberKey = `${ collectionKey } 1` ;
942+ const newMemberKey = `${ collectionKey } 2` ;
943+
944+ await Onyx . set ( existingMemberKey , { value : 'initial' } ) ;
945+
946+ const collectionCallback = jest . fn ( ) ;
947+ Onyx . connect ( {
948+ key : collectionKey ,
949+ waitForCollectionCallback : true ,
950+ callback : collectionCallback ,
951+ } ) ;
952+ await waitForPromisesToResolve ( ) ;
953+ collectionCallback . mockClear ( ) ;
954+
955+ StorageMock . multiMerge = jest . fn ( originalMultiMerge ) . mockRejectedValueOnce ( transientError ) ;
956+
957+ await Onyx . mergeCollection ( collectionKey , {
958+ [ existingMemberKey ] : { value : 'merged' } ,
959+ [ newMemberKey ] : { value : 'new' } ,
960+ } as GenericCollection ) ;
961+
962+ // Before this fix, every retry attempt re-fired keysChanged() — and
963+ // waitForCollectionCallback subscribers fire on every keysChanged() call by contract.
964+ // After the fix, retries skip the keysChanged re-fire, so subscribers are notified
965+ // exactly once per logical operation.
966+ expect ( collectionCallback ) . toHaveBeenCalledTimes ( 1 ) ;
967+ } ) ;
968+
969+ it ( 'Onyx.multiSet — collection subscriber fires once across retries' , async ( ) => {
970+ const collectionKey = ONYXKEYS . COLLECTION . TEST_KEY ;
971+ const memberKey1 = `${ collectionKey } 1` ;
972+ const memberKey2 = `${ collectionKey } 2` ;
973+
974+ const collectionCallback = jest . fn ( ) ;
975+ Onyx . connect ( {
976+ key : collectionKey ,
977+ waitForCollectionCallback : true ,
978+ callback : collectionCallback ,
979+ } ) ;
980+ await waitForPromisesToResolve ( ) ;
981+ collectionCallback . mockClear ( ) ;
982+
983+ StorageMock . multiSet = jest . fn ( originalMultiSet ) . mockRejectedValueOnce ( transientError ) ;
984+
985+ await Onyx . multiSet ( {
986+ [ memberKey1 ] : { value : 'first' } ,
987+ [ memberKey2 ] : { value : 'second' } ,
988+ } ) ;
989+
990+ expect ( collectionCallback ) . toHaveBeenCalledTimes ( 1 ) ;
991+ } ) ;
992+
993+ it ( 'Onyx.setCollection — collection subscriber fires once across retries' , async ( ) => {
994+ const collectionKey = ONYXKEYS . COLLECTION . TEST_KEY ;
995+ const memberKey1 = `${ collectionKey } 1` ;
996+ const memberKey2 = `${ collectionKey } 2` ;
997+
998+ const collectionCallback = jest . fn ( ) ;
999+ Onyx . connect ( {
1000+ key : collectionKey ,
1001+ waitForCollectionCallback : true ,
1002+ callback : collectionCallback ,
1003+ } ) ;
1004+ await waitForPromisesToResolve ( ) ;
1005+ collectionCallback . mockClear ( ) ;
1006+
1007+ StorageMock . multiSet = jest . fn ( originalMultiSet ) . mockRejectedValueOnce ( transientError ) ;
1008+
1009+ await Onyx . setCollection ( collectionKey , {
1010+ [ memberKey1 ] : { value : 'first' } ,
1011+ [ memberKey2 ] : { value : 'second' } ,
1012+ } as GenericCollection ) ;
1013+
1014+ expect ( collectionCallback ) . toHaveBeenCalledTimes ( 1 ) ;
1015+ } ) ;
1016+
1017+ it ( 'OnyxUtils.partialSetCollection — collection subscriber fires once across retries' , async ( ) => {
1018+ const collectionKey = ONYXKEYS . COLLECTION . TEST_KEY ;
1019+ const memberKey1 = `${ collectionKey } 1` ;
1020+ const memberKey2 = `${ collectionKey } 2` ;
1021+
1022+ const collectionCallback = jest . fn ( ) ;
1023+ Onyx . connect ( {
1024+ key : collectionKey ,
1025+ waitForCollectionCallback : true ,
1026+ callback : collectionCallback ,
1027+ } ) ;
1028+ await waitForPromisesToResolve ( ) ;
1029+ collectionCallback . mockClear ( ) ;
1030+
1031+ StorageMock . multiSet = jest . fn ( originalMultiSet ) . mockRejectedValueOnce ( transientError ) ;
1032+
1033+ await OnyxUtils . partialSetCollection ( {
1034+ collectionKey,
1035+ collection : {
1036+ [ memberKey1 ] : { value : 'first' } ,
1037+ [ memberKey2 ] : { value : 'second' } ,
1038+ } as GenericCollection ,
1039+ } ) ;
1040+
1041+ expect ( collectionCallback ) . toHaveBeenCalledTimes ( 1 ) ;
1042+ } ) ;
1043+ } ) ;
1044+
9231045 describe ( 'storage eviction' , ( ) => {
9241046 const diskFullError = new Error ( 'database or disk is full' ) ;
9251047
0 commit comments