@@ -9,13 +9,16 @@ import Storage from './storage';
9
9
import utils from './utils' ;
10
10
import DevTools from './DevTools' ;
11
11
import type {
12
+ Collection ,
13
+ CollectionKey ,
12
14
CollectionKeyBase ,
13
15
ConnectOptions ,
14
16
InitOptions ,
15
17
KeyValueMapping ,
16
18
Mapping ,
17
19
OnyxInputKeyValueMapping ,
18
20
OnyxCollection ,
21
+ MixedOperationsQueue ,
19
22
OnyxKey ,
20
23
OnyxMergeCollectionInput ,
21
24
OnyxMergeInput ,
@@ -437,30 +440,16 @@ function merge<TKey extends OnyxKey>(key: TKey, changes: OnyxMergeInput<TKey>):
437
440
* @param collection Object collection keyed by individual collection member keys and values
438
441
*/
439
442
function mergeCollection < TKey extends CollectionKeyBase , TMap > ( collectionKey : TKey , collection : OnyxMergeCollectionInput < TKey , TMap > ) : Promise < void > {
440
- if ( typeof collection !== 'object' || Array . isArray ( collection ) || utils . isEmptyObject ( collection ) ) {
443
+ if ( ! OnyxUtils . isValidNonEmptyCollectionForMerge ( collection ) ) {
441
444
Logger . logInfo ( 'mergeCollection() called with invalid or empty value. Skipping this update.' ) ;
442
445
return Promise . resolve ( ) ;
443
446
}
447
+
444
448
const mergedCollection : OnyxInputKeyValueMapping = collection ;
445
449
446
450
// Confirm all the collection keys belong to the same parent
447
- let hasCollectionKeyCheckFailed = false ;
448
451
const mergedCollectionKeys = Object . keys ( mergedCollection ) ;
449
- mergedCollectionKeys . forEach ( ( dataKey ) => {
450
- if ( OnyxUtils . isKeyMatch ( collectionKey , dataKey ) ) {
451
- return ;
452
- }
453
-
454
- if ( process . env . NODE_ENV === 'development' ) {
455
- throw new Error ( `Provided collection doesn't have all its data belonging to the same parent. CollectionKey: ${ collectionKey } , DataKey: ${ dataKey } ` ) ;
456
- }
457
-
458
- hasCollectionKeyCheckFailed = true ;
459
- Logger . logAlert ( `Provided collection doesn't have all its data belonging to the same parent. CollectionKey: ${ collectionKey } , DataKey: ${ dataKey } ` ) ;
460
- } ) ;
461
-
462
- // Gracefully handle bad mergeCollection updates so it doesn't block the merge queue
463
- if ( hasCollectionKeyCheckFailed ) {
452
+ if ( ! OnyxUtils . doAllCollectionItemsBelongToSameParent ( collectionKey , mergedCollectionKeys ) ) {
464
453
return Promise . resolve ( ) ;
465
454
}
466
455
@@ -712,23 +701,55 @@ function update(data: OnyxUpdate[]): Promise<void> {
712
701
}
713
702
} ) ;
714
703
704
+ // The queue of operations within a single `update` call in the format of <item key - list of operations updating the item>.
705
+ // This allows us to batch the operations per item and merge them into one operation in the order they were requested.
706
+ const updateQueue : Record < OnyxKey , Array < OnyxValue < OnyxKey > > > = { } ;
707
+ const enqueueSetOperation = ( key : OnyxKey , value : OnyxValue < OnyxKey > ) => {
708
+ // If a `set` operation is enqueued, we should clear the whole queue.
709
+ // Since the `set` operation replaces the value entirely, there's no need to perform any previous operations.
710
+ // To do this, we first put `null` in the queue, which removes the existing value, and then merge the new value.
711
+ updateQueue [ key ] = [ null , value ] ;
712
+ } ;
713
+ const enqueueMergeOperation = ( key : OnyxKey , value : OnyxValue < OnyxKey > ) => {
714
+ if ( value === null ) {
715
+ // If we merge `null`, the value is removed and all the previous operations are discarded.
716
+ updateQueue [ key ] = [ null ] ;
717
+ } else if ( ! updateQueue [ key ] ) {
718
+ updateQueue [ key ] = [ value ] ;
719
+ } else {
720
+ updateQueue [ key ] . push ( value ) ;
721
+ }
722
+ } ;
723
+
715
724
const promises : Array < ( ) => Promise < void > > = [ ] ;
716
725
let clearPromise : Promise < void > = Promise . resolve ( ) ;
717
726
718
727
data . forEach ( ( { onyxMethod, key, value} ) => {
719
728
switch ( onyxMethod ) {
720
729
case OnyxUtils . METHOD . SET :
721
- promises . push ( ( ) => set ( key , value ) ) ;
730
+ enqueueSetOperation ( key , value ) ;
722
731
break ;
723
732
case OnyxUtils . METHOD . MERGE :
724
- promises . push ( ( ) => merge ( key , value ) ) ;
733
+ enqueueMergeOperation ( key , value ) ;
725
734
break ;
726
- case OnyxUtils . METHOD . MERGE_COLLECTION :
727
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- We validated that the value is a collection
728
- promises . push ( ( ) => mergeCollection ( key , value as any ) ) ;
735
+ case OnyxUtils . METHOD . MERGE_COLLECTION : {
736
+ const collection = value as Collection < CollectionKey , unknown , unknown > ;
737
+ if ( ! OnyxUtils . isValidNonEmptyCollectionForMerge ( collection ) ) {
738
+ Logger . logInfo ( 'mergeCollection enqueued within update() with invalid or empty value. Skipping this operation.' ) ;
739
+ break ;
740
+ }
741
+
742
+ // Confirm all the collection keys belong to the same parent
743
+ const collectionKeys = Object . keys ( collection ) ;
744
+ if ( OnyxUtils . doAllCollectionItemsBelongToSameParent ( key , collectionKeys ) ) {
745
+ const mergedCollection : OnyxInputKeyValueMapping = collection ;
746
+ collectionKeys . forEach ( ( collectionKey ) => enqueueMergeOperation ( collectionKey , mergedCollection [ collectionKey ] ) ) ;
747
+ }
748
+
729
749
break ;
750
+ }
730
751
case OnyxUtils . METHOD . MULTI_SET :
731
- promises . push ( ( ) => multiSet ( value ) ) ;
752
+ Object . entries ( value ) . forEach ( ( [ entryKey , entryValue ] ) => enqueueSetOperation ( entryKey , entryValue ) ) ;
732
753
break ;
733
754
case OnyxUtils . METHOD . CLEAR :
734
755
clearPromise = clear ( ) ;
@@ -738,6 +759,58 @@ function update(data: OnyxUpdate[]): Promise<void> {
738
759
}
739
760
} ) ;
740
761
762
+ // Group all the collection-related keys and update each collection in a single `mergeCollection` call.
763
+ // This is needed to prevent multiple `mergeCollection` calls for the same collection and `merge` calls for the individual items of the said collection.
764
+ // This way, we ensure there is no race condition in the queued updates of the same key.
765
+ OnyxUtils . getCollectionKeys ( ) . forEach ( ( collectionKey ) => {
766
+ const collectionItemKeys = Object . keys ( updateQueue ) . filter ( ( key ) => OnyxUtils . isKeyMatch ( collectionKey , key ) ) ;
767
+ if ( collectionItemKeys . length <= 1 ) {
768
+ // If there are no items of this collection in the updateQueue, we should skip it.
769
+ // If there is only one item, we should update it individually, therefore retain it in the updateQueue.
770
+ return ;
771
+ }
772
+
773
+ const batchedCollectionUpdates = collectionItemKeys . reduce (
774
+ ( queue : MixedOperationsQueue , key : string ) => {
775
+ const operations = updateQueue [ key ] ;
776
+
777
+ // Remove the collection-related key from the updateQueue so that it won't be processed individually.
778
+ delete updateQueue [ key ] ;
779
+
780
+ const updatedValue = OnyxUtils . applyMerge ( undefined , operations , false ) ;
781
+ if ( operations [ 0 ] === null ) {
782
+ // eslint-disable-next-line no-param-reassign
783
+ queue . set [ key ] = updatedValue ;
784
+ } else {
785
+ // eslint-disable-next-line no-param-reassign
786
+ queue . merge [ key ] = updatedValue ;
787
+ }
788
+ return queue ;
789
+ } ,
790
+ {
791
+ merge : { } ,
792
+ set : { } ,
793
+ } ,
794
+ ) ;
795
+
796
+ if ( ! utils . isEmptyObject ( batchedCollectionUpdates . merge ) ) {
797
+ promises . push ( ( ) => mergeCollection ( collectionKey , batchedCollectionUpdates . merge as Collection < CollectionKey , unknown , unknown > ) ) ;
798
+ }
799
+ if ( ! utils . isEmptyObject ( batchedCollectionUpdates . set ) ) {
800
+ promises . push ( ( ) => multiSet ( batchedCollectionUpdates . set ) ) ;
801
+ }
802
+ } ) ;
803
+
804
+ Object . entries ( updateQueue ) . forEach ( ( [ key , operations ] ) => {
805
+ const batchedChanges = OnyxUtils . applyMerge ( undefined , operations , false ) ;
806
+
807
+ if ( operations [ 0 ] === null ) {
808
+ promises . push ( ( ) => set ( key , batchedChanges ) ) ;
809
+ } else {
810
+ promises . push ( ( ) => merge ( key , batchedChanges ) ) ;
811
+ }
812
+ } ) ;
813
+
741
814
return clearPromise
742
815
. then ( ( ) => Promise . all ( promises . map ( ( p ) => p ( ) ) ) )
743
816
. then ( ( ) => updateSnapshots ( data ) )
0 commit comments