@@ -809,7 +809,13 @@ function reportStorageQuota(error?: Error): Promise<void> {
809809 * - Non-retriable errors: logs an alert and resolves without retrying
810810 * - Other errors: retries the operation
811811 */
812- function retryOperation < TMethod extends RetriableOnyxOperation > ( error : Error , onyxMethod : TMethod , defaultParams : Parameters < TMethod > [ 0 ] , retryAttempt : number | undefined ) : Promise < void > {
812+ function retryOperation < TMethod extends RetriableOnyxOperation > (
813+ error : Error ,
814+ onyxMethod : TMethod ,
815+ defaultParams : Parameters < TMethod > [ 0 ] ,
816+ retryAttempt : number | undefined ,
817+ inFlightKeys ?: Set < OnyxKey > ,
818+ ) : Promise < void > {
813819 const currentRetryAttempt = retryAttempt ?? 0 ;
814820 const nextRetryAttempt = currentRetryAttempt + 1 ;
815821
@@ -840,8 +846,10 @@ function retryOperation<TMethod extends RetriableOnyxOperation>(error: Error, on
840846 return onyxMethod ( defaultParams , nextRetryAttempt ) ;
841847 }
842848
843- // Find the least recently accessed evictable key that we can remove
844- const keyForRemoval = cache . getKeyForEviction ( ) ;
849+ // Find the least recently accessed evictable key that we can remove. Never evict an in-flight
850+ // key — its cache value is the merge base this retry depends on, so dropping it would truncate
851+ // the write to just the delta and diverge cache from storage.
852+ const keyForRemoval = cache . getKeyForEviction ( inFlightKeys ) ;
845853 if ( ! keyForRemoval ) {
846854 // If we have no acceptable keys to remove then we are possibly trying to save mission critical data. If this is the case,
847855 // then we should stop retrying as there is not much the user can do to fix this. Instead of getting them stuck in an infinite loop we
@@ -1407,14 +1415,21 @@ function multiSetWithRetry(data: OnyxMultiSetInput, retryAttempt?: number): Prom
14071415 // so re-entrant callbacks (e.g. Onyx.set inside a callback) see consistent cache
14081416 // and subscriber state, matching the original per-key notification semantics.
14091417 cache . set ( key , value ) ;
1410- keyChanged ( key , value ) ;
1418+ // Skip subscriber notification on retry — already notified on attempt 0.
1419+ // waitForCollectionCallback subscribers re-fire on every keyChanged by contract.
1420+ if ( ! retryAttempt ) {
1421+ keyChanged ( key , value ) ;
1422+ }
14111423 }
14121424 }
14131425
14141426 // One keysChanged() per collection — fires each collection-level subscriber once and lets
14151427 // keysChanged() internally decide which individual member subscribers need notification.
1416- for ( const [ collectionKey , batch ] of collectionBatches ) {
1417- keysChanged ( collectionKey as CollectionKeyBase , batch . partial , batch . previous ) ;
1428+ // Skip on retry — already notified on attempt 0 (see same-reason comment above).
1429+ if ( ! retryAttempt ) {
1430+ for ( const [ collectionKey , batch ] of collectionBatches ) {
1431+ keysChanged ( collectionKey as CollectionKeyBase , batch . partial , batch . previous ) ;
1432+ }
14181433 }
14191434
14201435 const keyValuePairsToStore = keyValuePairsToSet . filter ( ( keyValuePair ) => {
@@ -1423,8 +1438,10 @@ function multiSetWithRetry(data: OnyxMultiSetInput, retryAttempt?: number): Prom
14231438 return ! OnyxKeys . isRamOnlyKey ( key ) ;
14241439 } ) ;
14251440
1441+ const inFlightKeys = new Set < OnyxKey > ( keyValuePairsToSet . map ( ( [ key ] ) => key ) ) ;
1442+
14261443 return Storage . multiSet ( keyValuePairsToStore )
1427- . catch ( ( error ) => OnyxUtils . retryOperation ( error , multiSetWithRetry , newData , retryAttempt ) )
1444+ . catch ( ( error ) => OnyxUtils . retryOperation ( error , multiSetWithRetry , newData , retryAttempt , inFlightKeys ) )
14281445 . then ( ( ) => {
14291446 OnyxUtils . sendActionToDevTools ( OnyxUtils . METHOD . MULTI_SET , undefined , newData ) ;
14301447 } ) ;
@@ -1488,16 +1505,22 @@ function setCollectionWithRetry<TKey extends CollectionKeyBase>({collectionKey,
14881505
14891506 for ( const [ key , value ] of keyValuePairs ) cache . set ( key , value ) ;
14901507
1491- keysChanged ( collectionKey , mutableCollection , previousCollection ) ;
1508+ // Skip subscriber notification on retry — already notified on attempt 0.
1509+ // waitForCollectionCallback subscribers re-fire on every keysChanged by contract.
1510+ if ( ! retryAttempt ) {
1511+ keysChanged ( collectionKey , mutableCollection , previousCollection ) ;
1512+ }
14921513
14931514 // RAM-only keys are not supposed to be saved to storage
14941515 if ( OnyxKeys . isRamOnlyKey ( collectionKey ) ) {
14951516 OnyxUtils . sendActionToDevTools ( OnyxUtils . METHOD . SET_COLLECTION , undefined , mutableCollection ) ;
14961517 return ;
14971518 }
14981519
1520+ const inFlightKeys = new Set < OnyxKey > ( keyValuePairs . map ( ( [ key ] ) => key ) ) ;
1521+
14991522 return Storage . multiSet ( keyValuePairs )
1500- . catch ( ( error ) => OnyxUtils . retryOperation ( error , setCollectionWithRetry , { collectionKey, collection} , retryAttempt ) )
1523+ . catch ( ( error ) => OnyxUtils . retryOperation ( error , setCollectionWithRetry , { collectionKey, collection} , retryAttempt , inFlightKeys ) )
15011524 . then ( ( ) => {
15021525 OnyxUtils . sendActionToDevTools ( OnyxUtils . METHOD . SET_COLLECTION , undefined , mutableCollection ) ;
15031526 } ) ;
@@ -1628,12 +1651,17 @@ function mergeCollectionWithPatches<TKey extends CollectionKeyBase>(
16281651 // write fails.
16291652 const previousCollection = getCachedCollection ( collectionKey , existingKeys ) ;
16301653 cache . merge ( finalMergedCollection ) ;
1631- keysChanged ( collectionKey , finalMergedCollection , previousCollection ) ;
1654+ // Skip subscriber notification on retry — already notified on attempt 0.
1655+ // waitForCollectionCallback subscribers re-fire on every keysChanged by contract.
1656+ if ( ! retryAttempt ) {
1657+ keysChanged ( collectionKey , finalMergedCollection , previousCollection ) ;
1658+ }
16321659
16331660 const promises = [ ] ;
16341661
1635- // New keys will be added via multiSet while existing keys will be updated using multiMerge
1636- // This is because setting a key that doesn't exist yet with multiMerge will throw errors
1662+ // New keys go through multiSet and existing keys through multiMerge. multiMerge on a
1663+ // missing key stores the value just like multiSet across all backends; splitting them lets
1664+ // multiSet strip nested nulls (the merge layer keeps them to delete nested storage keys).
16371665 // We can skip this step for RAM-only keys as they should never be saved to storage
16381666 if ( ! OnyxKeys . isRamOnlyKey ( collectionKey ) && keyValuePairsForExistingCollection . length > 0 ) {
16391667 promises . push ( Storage . multiMerge ( keyValuePairsForExistingCollection ) ) ;
@@ -1644,13 +1672,16 @@ function mergeCollectionWithPatches<TKey extends CollectionKeyBase>(
16441672 promises . push ( Storage . multiSet ( keyValuePairsForNewCollection ) ) ;
16451673 }
16461674
1675+ const inFlightKeys = new Set < OnyxKey > ( Object . keys ( finalMergedCollection ) ) ;
1676+
16471677 return Promise . all ( promises )
16481678 . catch ( ( error ) =>
16491679 retryOperation (
16501680 error ,
16511681 mergeCollectionWithPatches ,
16521682 { collectionKey, collection : resultCollection as OnyxMergeCollectionInput < TKey > , mergeReplaceNullPatches, isProcessingCollectionUpdate} ,
16531683 retryAttempt ,
1684+ inFlightKeys ,
16541685 ) ,
16551686 )
16561687 . then ( ( ) => {
@@ -1707,15 +1738,21 @@ function partialSetCollection<TKey extends CollectionKeyBase>({collectionKey, co
17071738
17081739 for ( const [ key , value ] of keyValuePairs ) cache . set ( key , value ) ;
17091740
1710- keysChanged ( collectionKey , mutableCollection , previousCollection ) ;
1741+ // Skip subscriber notification on retry — already notified on attempt 0.
1742+ // waitForCollectionCallback subscribers re-fire on every keysChanged by contract.
1743+ if ( ! retryAttempt ) {
1744+ keysChanged ( collectionKey , mutableCollection , previousCollection ) ;
1745+ }
17111746
17121747 if ( OnyxKeys . isRamOnlyKey ( collectionKey ) ) {
17131748 sendActionToDevTools ( METHOD . SET_COLLECTION , undefined , mutableCollection ) ;
17141749 return ;
17151750 }
17161751
1752+ const inFlightKeys = new Set < OnyxKey > ( keyValuePairs . map ( ( [ key ] ) => key ) ) ;
1753+
17171754 return Storage . multiSet ( keyValuePairs )
1718- . catch ( ( error ) => retryOperation ( error , partialSetCollection , { collectionKey, collection} , retryAttempt ) )
1755+ . catch ( ( error ) => retryOperation ( error , partialSetCollection , { collectionKey, collection} , retryAttempt , inFlightKeys ) )
17191756 . then ( ( ) => {
17201757 sendActionToDevTools ( METHOD . SET_COLLECTION , undefined , mutableCollection ) ;
17211758 } ) ;
0 commit comments