Skip to content

Commit 46e412c

Browse files
authored
Merge pull request #3 from margelo/@chrispader/fast-merge-modififer-rewrite
fix: Improve `fastMerge`, `Onyx.merge` and combine merge functions in `OnyxUtils`
2 parents d88c9dd + ed66dad commit 46e412c

File tree

19 files changed

+412
-349
lines changed

19 files changed

+412
-349
lines changed

API-INTERNAL.md

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -139,18 +139,13 @@ whatever it is we attempted to do.</p>
139139
<dt><a href="#broadcastUpdate">broadcastUpdate()</a></dt>
140140
<dd><p>Notifies subscribers and writes current value to cache</p>
141141
</dd>
142-
<dt><a href="#removeNullValues">removeNullValues()</a> ⇒</dt>
143-
<dd><p>Removes a key from storage if the value is null.
144-
Otherwise removes all nested null values in objects,
145-
if shouldRemoveNestedNulls is true and returns the object.</p>
146-
</dd>
147142
<dt><a href="#prepareKeyValuePairsForStorage">prepareKeyValuePairsForStorage()</a> ⇒</dt>
148143
<dd><p>Storage expects array like: [[&quot;@MyApp_user&quot;, value_1], [&quot;@MyApp_key&quot;, value_2]]
149144
This method transforms an object like {&#39;@MyApp_user&#39;: myUserValue, &#39;@MyApp_key&#39;: myKeyValue}
150145
to an array of key-value pairs in the above format and removes key-value pairs that are being set to null</p>
151146
</dd>
152-
<dt><a href="#applyMerge">applyMerge(changes)</a></dt>
153-
<dd><p>Merges an array of changes with an existing value</p>
147+
<dt><a href="#mergeChanges">mergeChanges(changes)</a></dt>
148+
<dd><p>Merges an array of changes with an existing value or creates a single change</p>
154149
</dd>
155150
<dt><a href="#initializeWithDefaultKeyStates">initializeWithDefaultKeyStates()</a></dt>
156151
<dd><p>Merge user provided default key value pairs.</p>
@@ -464,14 +459,6 @@ whatever it is we attempted to do.
464459
## broadcastUpdate()
465460
Notifies subscribers and writes current value to cache
466461

467-
**Kind**: global function
468-
<a name="removeNullValues"></a>
469-
470-
## removeNullValues() ⇒
471-
Removes a key from storage if the value is null.
472-
Otherwise removes all nested null values in objects,
473-
if shouldRemoveNestedNulls is true and returns the object.
474-
475462
**Kind**: global function
476463
**Returns**: The value without null values and a boolean "wasRemoved", which indicates if the key got removed completely
477464
<a name="prepareKeyValuePairsForStorage"></a>
@@ -483,10 +470,10 @@ to an array of key-value pairs in the above format and removes key-value pairs t
483470

484471
**Kind**: global function
485472
**Returns**: an array of key - value pairs <[key, value]>
486-
<a name="applyMerge"></a>
473+
<a name="mergeChanges"></a>
487474

488-
## applyMerge(changes)
489-
Merges an array of changes with an existing value
475+
## mergeChanges(changes)
476+
Merges an array of changes with an existing value or creates a single change
490477

491478
**Kind**: global function
492479

lib/Onyx.ts

Lines changed: 33 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
/* eslint-disable no-continue */
21
import _ from 'underscore';
32
import lodashPick from 'lodash/pick';
43
import * as Logger from './Logger';
@@ -27,21 +26,23 @@ import type {
2726
OnyxValue,
2827
OnyxInput,
2928
OnyxMethodMap,
29+
MultiMergeReplaceNullPatches,
3030
} from './types';
3131
import OnyxUtils from './OnyxUtils';
3232
import logMessages from './logMessages';
3333
import type {Connection} from './OnyxConnectionManager';
3434
import connectionManager from './OnyxConnectionManager';
3535
import * as GlobalSettings from './GlobalSettings';
3636
import decorateWithMetrics from './metrics';
37+
import OnyxMerge from './OnyxMerge/index.native';
3738

3839
/** Initialize the store with actions and listening for storage events */
3940
function init({
4041
keys = {},
4142
initialKeyStates = {},
4243
safeEvictionKeys = [],
4344
maxCachedKeysCount = 1000,
44-
shouldSyncMultipleInstances = Boolean(global.localStorage),
45+
shouldSyncMultipleInstances = !!global.localStorage,
4546
debugSetState = false,
4647
enablePerformanceMetrics = false,
4748
skippableCollectionMemberIDs = [],
@@ -169,38 +170,31 @@ function set<TKey extends OnyxKey>(key: TKey, value: OnyxSetInput<TKey>): Promis
169170
return Promise.resolve();
170171
}
171172

172-
// If the value is null, we remove the key from storage
173-
const {value: valueAfterRemoving, wasRemoved} = OnyxUtils.removeNullValues(key, value);
174-
175-
const logSetCall = (hasChanged = true) => {
176-
// Logging properties only since values could be sensitive things we don't want to log
177-
Logger.logInfo(`set called for key: ${key}${_.isObject(value) ? ` properties: ${_.keys(value).join(',')}` : ''} hasChanged: ${hasChanged}`);
178-
};
179-
180-
// Calling "OnyxUtils.removeNullValues" removes the key from storage and cache and updates the subscriber.
173+
// If the change is null, we can just delete the key.
181174
// Therefore, we don't need to further broadcast and update the value so we can return early.
182-
if (wasRemoved) {
183-
logSetCall();
175+
if (value === null) {
176+
OnyxUtils.remove(key);
177+
Logger.logInfo(`set called for key: ${key} => null passed, so key was removed`);
184178
return Promise.resolve();
185179
}
186180

187-
const valueWithoutNullValues = valueAfterRemoving as OnyxValue<TKey>;
188-
const hasChanged = cache.hasValueChanged(key, valueWithoutNullValues);
181+
const valueWithoutNestedNullValues = utils.removeNestedNullValues(value) as OnyxValue<TKey>;
182+
const hasChanged = cache.hasValueChanged(key, valueWithoutNestedNullValues);
189183

190-
logSetCall(hasChanged);
184+
Logger.logInfo(`set called for key: ${key}${_.isObject(value) ? ` properties: ${_.keys(value).join(',')}` : ''} hasChanged: ${hasChanged}`);
191185

192186
// This approach prioritizes fast UI changes without waiting for data to be stored in device storage.
193-
const updatePromise = OnyxUtils.broadcastUpdate(key, valueWithoutNullValues, hasChanged);
187+
const updatePromise = OnyxUtils.broadcastUpdate(key, valueWithoutNestedNullValues, hasChanged);
194188

195189
// If the value has not changed or the key got removed, calling Storage.setItem() would be redundant and a waste of performance, so return early instead.
196190
if (!hasChanged) {
197191
return updatePromise;
198192
}
199193

200-
return Storage.setItem(key, valueWithoutNullValues)
201-
.catch((error) => OnyxUtils.evictStorageAndRetry(error, set, key, valueWithoutNullValues))
194+
return Storage.setItem(key, valueWithoutNestedNullValues)
195+
.catch((error) => OnyxUtils.evictStorageAndRetry(error, set, key, valueWithoutNestedNullValues))
202196
.then(() => {
203-
OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET, key, valueWithoutNullValues);
197+
OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET, key, valueWithoutNestedNullValues);
204198
return updatePromise;
205199
});
206200
}
@@ -307,7 +301,6 @@ function merge<TKey extends OnyxKey>(key: TKey, changes: OnyxMergeInput<TKey>):
307301
}
308302

309303
try {
310-
// We first only merge the changes, so we use OnyxUtils.batchMergeChanges() to combine all the changes into just one.
311304
const validChanges = mergeQueue[key].filter((change) => {
312305
const {isCompatible, existingValueType, newValueType} = utils.checkCompatibilityWithExistingValue(change, existingValue);
313306
if (!isCompatible) {
@@ -319,54 +312,21 @@ function merge<TKey extends OnyxKey>(key: TKey, changes: OnyxMergeInput<TKey>):
319312
if (!validChanges.length) {
320313
return Promise.resolve();
321314
}
322-
const batchedDeltaChanges = OnyxUtils.batchMergeChanges(validChanges).result;
323-
324-
// Case (1): When there is no existing value in storage, we want to set the value instead of merge it.
325-
// Case (2): The presence of a top-level `null` in the merge queue instructs us to drop the whole existing value.
326-
// In this case, we can't simply merge the batched changes with the existing value, because then the null in the merge queue would have no effect.
327-
const shouldSetValue = !existingValue || mergeQueue[key].includes(null);
328315

329316
// Clean up the write queue, so we don't apply these changes again.
330317
delete mergeQueue[key];
331318
delete mergeQueuePromise[key];
332319

333-
const logMergeCall = (hasChanged = true) => {
334-
// Logging properties only since values could be sensitive things we don't want to log.
335-
Logger.logInfo(`merge called for key: ${key}${_.isObject(batchedDeltaChanges) ? ` properties: ${_.keys(batchedDeltaChanges).join(',')}` : ''} hasChanged: ${hasChanged}`);
336-
};
337-
338-
// If the batched changes equal null, we want to remove the key from storage, to reduce storage size.
339-
const {wasRemoved} = OnyxUtils.removeNullValues(key, batchedDeltaChanges);
340-
341-
// Calling "OnyxUtils.removeNullValues" removes the key from storage and cache and updates the subscriber.
320+
// If the last change is null, we can just delete the key.
342321
// Therefore, we don't need to further broadcast and update the value so we can return early.
343-
if (wasRemoved) {
344-
logMergeCall();
322+
if (validChanges.at(-1) === null) {
323+
Logger.logInfo(`merge called for key: ${key} => null passed, so key was removed`);
324+
OnyxUtils.remove(key);
345325
return Promise.resolve();
346326
}
347327

348-
// If "shouldSetValue" is true, it means that we want to completely replace the existing value with the batched changes,
349-
// so we pass `undefined` to OnyxUtils.applyMerge() first parameter to make it use "batchedDeltaChanges" to
350-
// create a new object for us.
351-
// If "shouldSetValue" is false, it means that we want to merge the batched changes into the existing value,
352-
// so we pass "existingValue" to the first parameter.
353-
const resultValue = OnyxUtils.applyMerge(shouldSetValue ? undefined : existingValue, [batchedDeltaChanges]);
354-
355-
// In cache, we don't want to remove the key if it's null to improve performance and speed up the next merge.
356-
const hasChanged = cache.hasValueChanged(key, resultValue);
357-
358-
logMergeCall(hasChanged);
359-
360-
// This approach prioritizes fast UI changes without waiting for data to be stored in device storage.
361-
const updatePromise = OnyxUtils.broadcastUpdate(key, resultValue as OnyxValue<TKey>, hasChanged);
362-
363-
// If the value has not changed, calling Storage.setItem() would be redundant and a waste of performance, so return early instead.
364-
if (!hasChanged) {
365-
return updatePromise;
366-
}
367-
368-
return Storage.mergeItem(key, resultValue as OnyxValue<TKey>).then(() => {
369-
OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.MERGE, key, changes, resultValue);
328+
return OnyxMerge.applyMerge(key, existingValue, validChanges).then(({mergedValue, updatePromise}) => {
329+
OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.MERGE, key, changes, mergedValue);
370330
return updatePromise;
371331
});
372332
} catch (error) {
@@ -394,7 +354,7 @@ function merge<TKey extends OnyxKey>(key: TKey, changes: OnyxMergeInput<TKey>):
394354
function mergeCollection<TKey extends CollectionKeyBase, TMap>(
395355
collectionKey: TKey,
396356
collection: OnyxMergeCollectionInput<TKey, TMap>,
397-
mergeReplaceNullPatches?: MixedOperationsQueue['mergeReplaceNullPatches'],
357+
mergeReplaceNullPatches?: MultiMergeReplaceNullPatches,
398358
): Promise<void> {
399359
if (!OnyxUtils.isValidNonEmptyCollectionForMerge(collection)) {
400360
Logger.logInfo('mergeCollection() called with invalid or empty value. Skipping this update.');
@@ -445,10 +405,12 @@ function mergeCollection<TKey extends CollectionKeyBase, TMap>(
445405

446406
const existingKeyCollection = existingKeys.reduce((obj: OnyxInputKeyValueMapping, key) => {
447407
const {isCompatible, existingValueType, newValueType} = utils.checkCompatibilityWithExistingValue(resultCollection[key], cachedCollectionForExistingKeys[key]);
408+
448409
if (!isCompatible) {
449410
Logger.logAlert(logMessages.incompatibleUpdateAlert(key, 'mergeCollection', existingValueType, newValueType));
450411
return obj;
451412
}
413+
452414
// eslint-disable-next-line no-param-reassign
453415
obj[key] = resultCollection[key];
454416
return obj;
@@ -465,7 +427,7 @@ function mergeCollection<TKey extends CollectionKeyBase, TMap>(
465427
// When (multi-)merging the values with the existing values in storage,
466428
// we don't want to remove nested null values from the data that we pass to the storage layer,
467429
// because the storage layer uses them to remove nested keys from storage natively.
468-
const keyValuePairsForExistingCollection = OnyxUtils.prepareKeyValuePairsForStorage(existingKeyCollection, false);
430+
const keyValuePairsForExistingCollection = OnyxUtils.prepareKeyValuePairsForStorage(existingKeyCollection, false, mergeReplaceNullPatches);
469431

470432
// We can safely remove nested null values when using (multi-)set,
471433
// because we will simply overwrite the existing values in storage.
@@ -480,7 +442,7 @@ function mergeCollection<TKey extends CollectionKeyBase, TMap>(
480442
// New keys will be added via multiSet while existing keys will be updated using multiMerge
481443
// This is because setting a key that doesn't exist yet with multiMerge will throw errors
482444
if (keyValuePairsForExistingCollection.length > 0) {
483-
promises.push(Storage.multiMerge(keyValuePairsForExistingCollection, mergeReplaceNullPatches));
445+
promises.push(Storage.multiMerge(keyValuePairsForExistingCollection));
484446
}
485447

486448
if (keyValuePairsForNewCollection.length > 0) {
@@ -774,7 +736,7 @@ function update(data: OnyxUpdate[]): Promise<void> {
774736
// Remove the collection-related key from the updateQueue so that it won't be processed individually.
775737
delete updateQueue[key];
776738

777-
const batchedChanges = OnyxUtils.batchMergeChanges(operations);
739+
const batchedChanges = OnyxUtils.mergeAndMarkChanges(operations);
778740
if (operations[0] === null) {
779741
// eslint-disable-next-line no-param-reassign
780742
queue.set[key] = batchedChanges.result;
@@ -806,13 +768,16 @@ function update(data: OnyxUpdate[]): Promise<void> {
806768
});
807769

808770
Object.entries(updateQueue).forEach(([key, operations]) => {
809-
const batchedChanges = OnyxUtils.batchMergeChanges(operations).result;
810-
811771
if (operations[0] === null) {
772+
const batchedChanges = OnyxUtils.mergeAndMarkChanges(operations).result;
812773
promises.push(() => set(key, batchedChanges));
813-
} else {
814-
promises.push(() => merge(key, batchedChanges));
774+
return;
815775
}
776+
777+
const mergePromises = operations.map((operation) => {
778+
return merge(key, operation);
779+
});
780+
promises.push(() => mergePromises.at(0) ?? Promise.resolve());
816781
});
817782

818783
const snapshotPromises = updateSnapshots(data);

lib/OnyxCache.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,12 @@ class OnyxCache {
164164
throw new Error('data passed to cache.merge() must be an Object of onyx key/value pairs');
165165
}
166166

167-
this.storageMap = {...utils.fastMerge(this.storageMap, data, true, false, true).result};
167+
this.storageMap = {
168+
...utils.fastMerge(this.storageMap, data, {
169+
shouldRemoveNestedNulls: true,
170+
objectRemovalMode: 'replace',
171+
}).result,
172+
};
168173

169174
Object.entries(data).forEach(([key, value]) => {
170175
this.addKey(key);
@@ -227,6 +232,10 @@ class OnyxCache {
227232
const temp = [];
228233
while (numKeysToRemove > 0) {
229234
const value = iterator.next().value;
235+
if (value === undefined) {
236+
break;
237+
}
238+
230239
temp.push(value);
231240
numKeysToRemove--;
232241
}

lib/OnyxMerge/index.native.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import _ from 'underscore';
2+
import * as Logger from '../Logger';
3+
import OnyxUtils from '../OnyxUtils';
4+
import type {OnyxKey, OnyxValue} from '../types';
5+
import cache from '../OnyxCache';
6+
import Storage from '../storage';
7+
import type {ApplyMerge, ApplyMergeResult} from './types';
8+
9+
const applyMerge: ApplyMerge = <TKey extends OnyxKey>(key: TKey, existingValue: OnyxValue<TKey>, validChanges: unknown[]): Promise<ApplyMergeResult> => {
10+
// If any of the changes is null, we need to discard the existing value.
11+
const baseValue = validChanges.includes(null) ? undefined : existingValue;
12+
13+
// We first batch the changes into a single change with object removal marks,
14+
// so that SQLite can merge the changes more efficiently.
15+
const {result: batchedChanges, replaceNullPatches} = OnyxUtils.mergeAndMarkChanges(validChanges);
16+
17+
// We then merge the batched changes with the existing value, because we need to final merged value to broadcast to subscribers.
18+
const {result: mergedValue} = OnyxUtils.mergeChanges([batchedChanges], baseValue);
19+
20+
// In cache, we don't want to remove the key if it's null to improve performance and speed up the next merge.
21+
const hasChanged = cache.hasValueChanged(key, mergedValue);
22+
23+
// Logging properties only since values could be sensitive things we don't want to log.
24+
Logger.logInfo(`merge called for key: ${key}${_.isObject(mergedValue) ? ` properties: ${_.keys(mergedValue).join(',')}` : ''} hasChanged: ${hasChanged}`);
25+
26+
// This approach prioritizes fast UI changes without waiting for data to be stored in device storage.
27+
const updatePromise = OnyxUtils.broadcastUpdate(key, mergedValue as OnyxValue<TKey>, hasChanged);
28+
29+
// If the value has not changed, calling Storage.setItem() would be redundant and a waste of performance, so return early instead.
30+
if (!hasChanged) {
31+
return Promise.resolve({mergedValue, updatePromise});
32+
}
33+
34+
return Storage.mergeItem(key, batchedChanges as OnyxValue<TKey>, replaceNullPatches).then(() => ({
35+
mergedValue,
36+
updatePromise,
37+
}));
38+
};
39+
40+
const OnyxMerge = {
41+
applyMerge,
42+
};
43+
44+
export default OnyxMerge;

lib/OnyxMerge/index.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import _ from 'underscore';
2+
import * as Logger from '../Logger';
3+
import OnyxUtils from '../OnyxUtils';
4+
import type {OnyxKey, OnyxValue} from '../types';
5+
import cache from '../OnyxCache';
6+
import Storage from '../storage';
7+
import type {ApplyMerge, ApplyMergeResult} from './types';
8+
9+
const applyMerge: ApplyMerge = <TKey extends OnyxKey>(key: TKey, existingValue: OnyxValue<TKey>, validChanges: unknown[]): Promise<ApplyMergeResult> => {
10+
const {result: mergedValue} = OnyxUtils.mergeChanges(validChanges, existingValue);
11+
12+
// In cache, we don't want to remove the key if it's null to improve performance and speed up the next merge.
13+
const hasChanged = cache.hasValueChanged(key, mergedValue);
14+
15+
// Logging properties only since values could be sensitive things we don't want to log.
16+
Logger.logInfo(`merge called for key: ${key}${_.isObject(mergedValue) ? ` properties: ${_.keys(mergedValue).join(',')}` : ''} hasChanged: ${hasChanged}`);
17+
18+
// This approach prioritizes fast UI changes without waiting for data to be stored in device storage.
19+
const updatePromise = OnyxUtils.broadcastUpdate(key, mergedValue as OnyxValue<TKey>, hasChanged);
20+
21+
// If the value has not changed, calling Storage.setItem() would be redundant and a waste of performance, so return early instead.
22+
if (!hasChanged) {
23+
return Promise.resolve({mergedValue, updatePromise});
24+
}
25+
26+
return Storage.setItem(key, mergedValue as OnyxValue<TKey>).then(() => ({
27+
mergedValue,
28+
updatePromise,
29+
}));
30+
};
31+
32+
const OnyxMerge = {
33+
applyMerge,
34+
};
35+
36+
export default OnyxMerge;

lib/OnyxMerge/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type {OnyxKey, OnyxValue} from '../types';
2+
3+
type ApplyMergeResult = {
4+
mergedValue: OnyxValue<OnyxKey>;
5+
updatePromise: Promise<void>;
6+
};
7+
8+
type ApplyMerge = <TKey extends OnyxKey>(key: TKey, existingValue: OnyxValue<TKey>, validChanges: unknown[]) => Promise<ApplyMergeResult>;
9+
10+
export type {ApplyMerge, ApplyMergeResult};

0 commit comments

Comments
 (0)