Skip to content

Commit 6317b21

Browse files
authored
Merge pull request #490 from paultsimura/fix/update-order
Try to synchronize operations within one update
2 parents f3ebf86 + 4fabc64 commit 6317b21

File tree

4 files changed

+404
-31
lines changed

4 files changed

+404
-31
lines changed

lib/Onyx.ts

Lines changed: 96 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,16 @@ import Storage from './storage';
99
import utils from './utils';
1010
import DevTools from './DevTools';
1111
import type {
12+
Collection,
13+
CollectionKey,
1214
CollectionKeyBase,
1315
ConnectOptions,
1416
InitOptions,
1517
KeyValueMapping,
1618
Mapping,
1719
OnyxInputKeyValueMapping,
1820
OnyxCollection,
21+
MixedOperationsQueue,
1922
OnyxKey,
2023
OnyxMergeCollectionInput,
2124
OnyxMergeInput,
@@ -437,30 +440,16 @@ function merge<TKey extends OnyxKey>(key: TKey, changes: OnyxMergeInput<TKey>):
437440
* @param collection Object collection keyed by individual collection member keys and values
438441
*/
439442
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)) {
441444
Logger.logInfo('mergeCollection() called with invalid or empty value. Skipping this update.');
442445
return Promise.resolve();
443446
}
447+
444448
const mergedCollection: OnyxInputKeyValueMapping = collection;
445449

446450
// Confirm all the collection keys belong to the same parent
447-
let hasCollectionKeyCheckFailed = false;
448451
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)) {
464453
return Promise.resolve();
465454
}
466455

@@ -712,23 +701,55 @@ function update(data: OnyxUpdate[]): Promise<void> {
712701
}
713702
});
714703

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+
715724
const promises: Array<() => Promise<void>> = [];
716725
let clearPromise: Promise<void> = Promise.resolve();
717726

718727
data.forEach(({onyxMethod, key, value}) => {
719728
switch (onyxMethod) {
720729
case OnyxUtils.METHOD.SET:
721-
promises.push(() => set(key, value));
730+
enqueueSetOperation(key, value);
722731
break;
723732
case OnyxUtils.METHOD.MERGE:
724-
promises.push(() => merge(key, value));
733+
enqueueMergeOperation(key, value);
725734
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+
729749
break;
750+
}
730751
case OnyxUtils.METHOD.MULTI_SET:
731-
promises.push(() => multiSet(value));
752+
Object.entries(value).forEach(([entryKey, entryValue]) => enqueueSetOperation(entryKey, entryValue));
732753
break;
733754
case OnyxUtils.METHOD.CLEAR:
734755
clearPromise = clear();
@@ -738,6 +759,58 @@ function update(data: OnyxUpdate[]): Promise<void> {
738759
}
739760
});
740761

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+
741814
return clearPromise
742815
.then(() => Promise.all(promises.map((p) => p())))
743816
.then(() => updateSnapshots(data))

lib/OnyxUtils.ts

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type {
2323
OnyxEntry,
2424
OnyxInput,
2525
OnyxKey,
26+
OnyxMergeCollectionInput,
2627
OnyxValue,
2728
Selector,
2829
WithOnyxConnectOptions,
@@ -45,11 +46,11 @@ type OnyxMethod = ValueOf<typeof METHOD>;
4546
const mergeQueue: Record<OnyxKey, Array<OnyxValue<OnyxKey>>> = {};
4647
const mergeQueuePromise: Record<OnyxKey, Promise<void>> = {};
4748

48-
// Holds a mapping of all the react components that want their state subscribed to a store key
49+
// Holds a mapping of all the React components that want their state subscribed to a store key
4950
const callbackToStateMapping: Record<string, Mapping<OnyxKey>> = {};
5051

5152
// Keeps a copy of the values of the onyx collection keys as a map for faster lookups
52-
let onyxCollectionKeyMap = new Map<OnyxKey, OnyxValue<OnyxKey>>();
53+
let onyxCollectionKeySet = new Set<OnyxKey>();
5354

5455
// Holds a mapping of the connected key to the connectionID for faster lookups
5556
const onyxKeyToConnectionIDs = new Map();
@@ -115,11 +116,11 @@ function getDefaultKeyStates(): Record<OnyxKey, OnyxValue<OnyxKey>> {
115116
function initStoreValues(keys: DeepRecord<string, OnyxKey>, initialKeyStates: Partial<KeyValueMapping>, safeEvictionKeys: OnyxKey[]): void {
116117
// We need the value of the collection keys later for checking if a
117118
// key is a collection. We store it in a map for faster lookup.
118-
const collectionValues = Object.values(keys.COLLECTION ?? {});
119-
onyxCollectionKeyMap = collectionValues.reduce((acc, val) => {
120-
acc.set(val, true);
119+
const collectionValues = Object.values(keys.COLLECTION ?? {}) as string[];
120+
onyxCollectionKeySet = collectionValues.reduce((acc, val) => {
121+
acc.add(val);
121122
return acc;
122-
}, new Map());
123+
}, new Set<OnyxKey>());
123124

124125
// Set our default key states to use when initializing and clearing Onyx data
125126
defaultKeyStates = initialKeyStates;
@@ -376,11 +377,18 @@ function getAllKeys(): Promise<Set<OnyxKey>> {
376377
}
377378

378379
/**
379-
* Checks to see if the a subscriber's supplied key
380+
* Returns set of all registered collection keys
381+
*/
382+
function getCollectionKeys(): Set<OnyxKey> {
383+
return onyxCollectionKeySet;
384+
}
385+
386+
/**
387+
* Checks to see if the subscriber's supplied key
380388
* is associated with a collection of keys.
381389
*/
382390
function isCollectionKey(key: OnyxKey): key is CollectionKeyBase {
383-
return onyxCollectionKeyMap.has(key);
391+
return onyxCollectionKeySet.has(key);
384392
}
385393

386394
function isCollectionMemberKey<TCollectionKey extends CollectionKeyBase>(collectionKey: TCollectionKey, key: string): key is `${TCollectionKey}${string}` {
@@ -1182,6 +1190,34 @@ function initializeWithDefaultKeyStates(): Promise<void> {
11821190
});
11831191
}
11841192

1193+
/**
1194+
* Validate the collection is not empty and has a correct type before applying mergeCollection()
1195+
*/
1196+
function isValidNonEmptyCollectionForMerge<TKey extends CollectionKeyBase, TMap>(collection: OnyxMergeCollectionInput<TKey, TMap>): boolean {
1197+
return typeof collection === 'object' && !Array.isArray(collection) && !utils.isEmptyObject(collection);
1198+
}
1199+
1200+
/**
1201+
* Verify if all the collection keys belong to the same parent
1202+
*/
1203+
function doAllCollectionItemsBelongToSameParent<TKey extends CollectionKeyBase>(collectionKey: TKey, collectionKeys: string[]): boolean {
1204+
let hasCollectionKeyCheckFailed = false;
1205+
collectionKeys.forEach((dataKey) => {
1206+
if (OnyxUtils.isKeyMatch(collectionKey, dataKey)) {
1207+
return;
1208+
}
1209+
1210+
if (process.env.NODE_ENV === 'development') {
1211+
throw new Error(`Provided collection doesn't have all its data belonging to the same parent. CollectionKey: ${collectionKey}, DataKey: ${dataKey}`);
1212+
}
1213+
1214+
hasCollectionKeyCheckFailed = true;
1215+
Logger.logAlert(`Provided collection doesn't have all its data belonging to the same parent. CollectionKey: ${collectionKey}, DataKey: ${dataKey}`);
1216+
});
1217+
1218+
return !hasCollectionKeyCheckFailed;
1219+
}
1220+
11851221
const OnyxUtils = {
11861222
METHOD,
11871223
getMergeQueue,
@@ -1194,6 +1230,7 @@ const OnyxUtils = {
11941230
batchUpdates,
11951231
get,
11961232
getAllKeys,
1233+
getCollectionKeys,
11971234
isCollectionKey,
11981235
isCollectionMemberKey,
11991236
splitCollectionMemberKey,
@@ -1227,6 +1264,8 @@ const OnyxUtils = {
12271264
deleteKeyByConnections,
12281265
getSnapshotKey,
12291266
multiGet,
1267+
isValidNonEmptyCollectionForMerge,
1268+
doAllCollectionItemsBelongToSameParent,
12301269
};
12311270

12321271
export default OnyxUtils;

lib/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,14 @@ type InitOptions = {
432432
// eslint-disable-next-line @typescript-eslint/no-explicit-any
433433
type GenericFunction = (...args: any[]) => any;
434434

435+
/**
436+
* Represents a combination of Merge and Set operations that should be executed in Onyx
437+
*/
438+
type MixedOperationsQueue = {
439+
merge: OnyxInputKeyValueMapping;
440+
set: OnyxInputKeyValueMapping;
441+
};
442+
435443
export type {
436444
BaseConnectOptions,
437445
Collection,
@@ -468,4 +476,5 @@ export type {
468476
OnyxValue,
469477
Selector,
470478
WithOnyxConnectOptions,
479+
MixedOperationsQueue,
471480
};

0 commit comments

Comments
 (0)