Skip to content

Commit 06304e7

Browse files
committed
Merge branch 'bugfix/615-solution2-jsonPatchReplace' into bugfix/615-solution2
2 parents cf15718 + 7b8801f commit 06304e7

20 files changed

Lines changed: 539 additions & 361 deletions

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: 45 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
/* eslint-disable no-continue */
21
import _ from 'underscore';
32
import * as Logger from './Logger';
43
import cache, {TASK} from './OnyxCache';
@@ -26,13 +25,15 @@ import type {
2625
OnyxValue,
2726
OnyxInput,
2827
OnyxMethodMap,
28+
MultiMergeReplaceNullPatches,
2929
} from './types';
3030
import OnyxUtils from './OnyxUtils';
3131
import logMessages from './logMessages';
3232
import type {Connection} from './OnyxConnectionManager';
3333
import connectionManager from './OnyxConnectionManager';
3434
import * as GlobalSettings from './GlobalSettings';
3535
import decorateWithMetrics from './metrics';
36+
import OnyxMerge from './OnyxMerge';
3637

3738
/** Initialize the store with actions and listening for storage events */
3839
function init({
@@ -170,38 +171,31 @@ function set<TKey extends OnyxKey>(key: TKey, value: OnyxSetInput<TKey>): Promis
170171
return Promise.resolve();
171172
}
172173

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

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

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

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

196190
// 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.
197191
if (!hasChanged) {
198192
return updatePromise;
199193
}
200194

201-
return Storage.setItem(key, valueWithoutNullValues)
202-
.catch((error) => OnyxUtils.evictStorageAndRetry(error, set, key, valueWithoutNullValues))
195+
return Storage.setItem(key, valueWithoutNestedNullValues)
196+
.catch((error) => OnyxUtils.evictStorageAndRetry(error, set, key, valueWithoutNestedNullValues))
203197
.then(() => {
204-
OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET, key, valueWithoutNullValues);
198+
OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET, key, valueWithoutNestedNullValues);
205199
return updatePromise;
206200
});
207201
}
@@ -313,7 +307,6 @@ function merge<TKey extends OnyxKey>(key: TKey, changes: OnyxMergeInput<TKey>):
313307
}
314308

315309
try {
316-
// We first only merge the changes, so we use OnyxUtils.batchMergeChanges() to combine all the changes into just one.
317310
const validChanges = mergeQueue[key].filter((change) => {
318311
const {isCompatible, existingValueType, newValueType} = utils.checkCompatibilityWithExistingValue(change, existingValue);
319312
if (!isCompatible) {
@@ -325,54 +318,21 @@ function merge<TKey extends OnyxKey>(key: TKey, changes: OnyxMergeInput<TKey>):
325318
if (!validChanges.length) {
326319
return Promise.resolve();
327320
}
328-
const batchedDeltaChanges = OnyxUtils.batchMergeChanges(validChanges);
329-
330-
// Case (1): When there is no existing value in storage, we want to set the value instead of merge it.
331-
// Case (2): The presence of a top-level `null` in the merge queue instructs us to drop the whole existing value.
332-
// 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.
333-
const shouldSetValue = !existingValue || mergeQueue[key].includes(null);
334321

335322
// Clean up the write queue, so we don't apply these changes again.
336323
delete mergeQueue[key];
337324
delete mergeQueuePromise[key];
338325

339-
const logMergeCall = (hasChanged = true) => {
340-
// Logging properties only since values could be sensitive things we don't want to log.
341-
Logger.logInfo(`merge called for key: ${key}${_.isObject(batchedDeltaChanges) ? ` properties: ${_.keys(batchedDeltaChanges).join(',')}` : ''} hasChanged: ${hasChanged}`);
342-
};
343-
344-
// If the batched changes equal null, we want to remove the key from storage, to reduce storage size.
345-
const {wasRemoved} = OnyxUtils.removeNullValues(key, batchedDeltaChanges);
346-
347-
// Calling "OnyxUtils.removeNullValues" removes the key from storage and cache and updates the subscriber.
326+
// If the last change is null, we can just delete the key.
348327
// Therefore, we don't need to further broadcast and update the value so we can return early.
349-
if (wasRemoved) {
350-
logMergeCall();
328+
if (validChanges.at(-1) === null) {
329+
Logger.logInfo(`merge called for key: ${key} => null passed, so key was removed`);
330+
OnyxUtils.remove(key);
351331
return Promise.resolve();
352332
}
353333

354-
// If "shouldSetValue" is true, it means that we want to completely replace the existing value with the batched changes,
355-
// so we pass `undefined` to OnyxUtils.applyMerge() first parameter to make it use "batchedDeltaChanges" to
356-
// create a new object for us.
357-
// If "shouldSetValue" is false, it means that we want to merge the batched changes into the existing value,
358-
// so we pass "existingValue" to the first parameter.
359-
const resultValue = OnyxUtils.applyMerge(shouldSetValue ? undefined : existingValue, [batchedDeltaChanges]);
360-
361-
// In cache, we don't want to remove the key if it's null to improve performance and speed up the next merge.
362-
const hasChanged = cache.hasValueChanged(key, resultValue);
363-
364-
logMergeCall(hasChanged);
365-
366-
// This approach prioritizes fast UI changes without waiting for data to be stored in device storage.
367-
const updatePromise = OnyxUtils.broadcastUpdate(key, resultValue as OnyxValue<TKey>, hasChanged);
368-
369-
// If the value has not changed, calling Storage.setItem() would be redundant and a waste of performance, so return early instead.
370-
if (!hasChanged) {
371-
return updatePromise;
372-
}
373-
374-
return Storage.mergeItem(key, resultValue as OnyxValue<TKey>).then(() => {
375-
OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.MERGE, key, changes, resultValue);
334+
return OnyxMerge.applyMerge(key, existingValue, validChanges).then(({mergedValue, updatePromise}) => {
335+
OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.MERGE, key, changes, mergedValue);
376336
return updatePromise;
377337
});
378338
} catch (error) {
@@ -397,7 +357,11 @@ function merge<TKey extends OnyxKey>(key: TKey, changes: OnyxMergeInput<TKey>):
397357
* @param collectionKey e.g. `ONYXKEYS.COLLECTION.REPORT`
398358
* @param collection Object collection keyed by individual collection member keys and values
399359
*/
400-
function mergeCollection<TKey extends CollectionKeyBase, TMap>(collectionKey: TKey, collection: OnyxMergeCollectionInput<TKey, TMap>): Promise<void> {
360+
function mergeCollection<TKey extends CollectionKeyBase, TMap>(
361+
collectionKey: TKey,
362+
collection: OnyxMergeCollectionInput<TKey, TMap>,
363+
mergeReplaceNullPatches?: MultiMergeReplaceNullPatches,
364+
): Promise<void> {
401365
if (!OnyxUtils.isValidNonEmptyCollectionForMerge(collection)) {
402366
Logger.logInfo('mergeCollection() called with invalid or empty value. Skipping this update.');
403367
return Promise.resolve();
@@ -447,10 +411,12 @@ function mergeCollection<TKey extends CollectionKeyBase, TMap>(collectionKey: TK
447411

448412
const existingKeyCollection = existingKeys.reduce((obj: OnyxInputKeyValueMapping, key) => {
449413
const {isCompatible, existingValueType, newValueType} = utils.checkCompatibilityWithExistingValue(resultCollection[key], cachedCollectionForExistingKeys[key]);
414+
450415
if (!isCompatible) {
451416
Logger.logAlert(logMessages.incompatibleUpdateAlert(key, 'mergeCollection', existingValueType, newValueType));
452417
return obj;
453418
}
419+
454420
// eslint-disable-next-line no-param-reassign
455421
obj[key] = resultCollection[key];
456422
return obj;
@@ -467,7 +433,7 @@ function mergeCollection<TKey extends CollectionKeyBase, TMap>(collectionKey: TK
467433
// When (multi-)merging the values with the existing values in storage,
468434
// we don't want to remove nested null values from the data that we pass to the storage layer,
469435
// because the storage layer uses them to remove nested keys from storage natively.
470-
const keyValuePairsForExistingCollection = OnyxUtils.prepareKeyValuePairsForStorage(existingKeyCollection, false);
436+
const keyValuePairsForExistingCollection = OnyxUtils.prepareKeyValuePairsForStorage(existingKeyCollection, false, mergeReplaceNullPatches);
471437

472438
// We can safely remove nested null values when using (multi-)set,
473439
// because we will simply overwrite the existing values in storage.
@@ -717,38 +683,48 @@ function update(data: OnyxUpdate[]): Promise<void> {
717683
// Remove the collection-related key from the updateQueue so that it won't be processed individually.
718684
delete updateQueue[key];
719685

720-
const batchedChanges = OnyxUtils.batchMergeChanges(operations);
686+
const batchedChanges = OnyxUtils.mergeAndMarkChanges(operations);
721687
if (operations[0] === null) {
722688
// eslint-disable-next-line no-param-reassign
723-
queue.set[key] = batchedChanges;
689+
queue.set[key] = batchedChanges.result;
724690
} else {
725691
// eslint-disable-next-line no-param-reassign
726-
queue.merge[key] = batchedChanges;
692+
queue.merge[key] = batchedChanges.result;
693+
if (batchedChanges.replaceNullPatches.length > 0) {
694+
// eslint-disable-next-line no-param-reassign
695+
queue.mergeReplaceNullPatches[key] = batchedChanges.replaceNullPatches;
696+
}
727697
}
728698
return queue;
729699
},
730700
{
731701
merge: {},
702+
mergeReplaceNullPatches: {},
732703
set: {},
733704
},
734705
);
735706

736707
if (!utils.isEmptyObject(batchedCollectionUpdates.merge)) {
737-
promises.push(() => mergeCollection(collectionKey, batchedCollectionUpdates.merge as Collection<CollectionKey, unknown, unknown>));
708+
promises.push(() =>
709+
mergeCollection(collectionKey, batchedCollectionUpdates.merge as Collection<CollectionKey, unknown, unknown>, batchedCollectionUpdates.mergeReplaceNullPatches),
710+
);
738711
}
739712
if (!utils.isEmptyObject(batchedCollectionUpdates.set)) {
740713
promises.push(() => multiSet(batchedCollectionUpdates.set));
741714
}
742715
});
743716

744717
Object.entries(updateQueue).forEach(([key, operations]) => {
745-
const batchedChanges = OnyxUtils.batchMergeChanges(operations);
746-
747718
if (operations[0] === null) {
719+
const batchedChanges = OnyxUtils.mergeAndMarkChanges(operations).result;
748720
promises.push(() => set(key, batchedChanges));
749-
} else {
750-
promises.push(() => merge(key, batchedChanges));
721+
return;
751722
}
723+
724+
const mergePromises = operations.map((operation) => {
725+
return merge(key, operation);
726+
});
727+
promises.push(() => mergePromises.at(0) ?? Promise.resolve());
752728
});
753729

754730
const snapshotPromises = OnyxUtils.updateSnapshots(data, merge);

lib/OnyxCache.ts

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

185-
this.storageMap = {...utils.fastMerge(this.storageMap, data, true, false, true)};
185+
this.storageMap = {
186+
...utils.fastMerge(this.storageMap, data, {
187+
shouldRemoveNestedNulls: true,
188+
objectRemovalMode: 'replace',
189+
}).result,
190+
};
186191

187192
Object.entries(data).forEach(([key, value]) => {
188193
this.addKey(key);

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} from './types';
8+
9+
const applyMerge: ApplyMerge = <TKey extends OnyxKey>(key: TKey, existingValue: OnyxValue<TKey>, validChanges: unknown[]) => {
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} from './types';
8+
9+
const applyMerge: ApplyMerge = <TKey extends OnyxKey>(key: TKey, existingValue: OnyxValue<TKey>, validChanges: unknown[]) => {
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)