diff --git a/API-INTERNAL.md b/API-INTERNAL.md
index a9f75cbea..8da520402 100644
--- a/API-INTERNAL.md
+++ b/API-INTERNAL.md
@@ -17,16 +17,13 @@
getDeferredInitTask()
Getter - returns the deffered init task.
-getEvictionBlocklist()
-Getter - returns the eviction block list.
-
getSkippableCollectionMemberIDs()
Getter - returns the skippable collection member IDs.
setSkippableCollectionMemberIDs()
Setter - sets the skippable collection member IDs.
-initStoreValues(keys, initialKeyStates, evictableKeys)
+initStoreValues(keys, initialKeyStates, evictableKeys, fullyMergedSnapshotKeys)
Sets the initial values for the Onyx store
maybeFlushBatchUpdates()
@@ -71,9 +68,6 @@ is associated with a collection of keys.
Checks to see if a provided key is the exact configured key of our connected subscriber
or if the provided key is a collection member key (in case our configured key is a "collection key")
-isEvictableKey()
-Checks to see if this key has been flagged as safe for removal.
-
getCollectionKey(key) ⇒
Extracts the collection identifier of a given collection member key.
For example:
@@ -88,20 +82,6 @@ or if the provided key is a collection member key (in case our configured key is
Tries to get a value from the cache. If the value is not present in cache it will return the default value or undefined.
If the requested key is a collection, it will return an object with all the collection members.
-removeLastAccessedKey()
-Remove a key from the recently accessed key list.
-
-addLastAccessedKey()
-Add a key to the list of recently accessed keys. The least
-recently accessed key should be at the head and the most
-recently accessed key at the tail.
-
-addEvictableKeysToRecentlyAccessedList()
-Take all the keys that are safe to evict and add them to
-the recently accessed list when initializing the app. This
-enables keys that have not recently been accessed to be
-removed.
-
keysChanged()
When a collection of keys change, search for any callbacks matching the collection key and trigger those callbacks
@@ -139,18 +119,20 @@ whatever it is we attempted to do.
broadcastUpdate()
Notifies subscribers and writes current value to cache
-removeNullValues() ⇒
-Removes a key from storage if the value is null.
-Otherwise removes all nested null values in objects,
-if shouldRemoveNestedNulls is true and returns the object.
-
prepareKeyValuePairsForStorage() ⇒
Storage expects array like: [["@MyApp_user", value_1], ["@MyApp_key", value_2]]
This method transforms an object like {'@MyApp_user': myUserValue, '@MyApp_key': myKeyValue}
to an array of key-value pairs in the above format and removes key-value pairs that are being set to null
-applyMerge(changes)
-Merges an array of changes with an existing value
+mergeChanges(changes, existingValue)
+Merges an array of changes with an existing value or creates a single change.
+
+mergeAndMarkChanges(changes, existingValue)
+Merges an array of changes with an existing value or creates a single change.
+It will also mark deep nested objects that need to be entirely replaced during the merge.
+
+mergeInternal(changes, existingValue)
+Merges an array of changes with an existing value or creates a single change.
initializeWithDefaultKeyStates()
Merge user provided default key value pairs.
@@ -167,6 +149,14 @@ to an array of key-value pairs in the above format and removes key-value pairs t
unsubscribeFromKey(subscriptionID)
Disconnects and removes the listener from the Onyx key.
+mergeCollectionWithPatches(collectionKey, collection, mergeReplaceNullPatches)
+Merges a collection based on their keys.
+Serves as core implementation for Onyx.mergeCollection() public function, the difference being
+that this internal function allows passing an additional mergeReplaceNullPatches parameter.
+
+clearOnyxUtilsInternals()
+Clear internal variables used in this file, useful in test environments.
+
@@ -192,12 +182,6 @@ Getter - returns the default key states.
## getDeferredInitTask()
Getter - returns the deffered init task.
-**Kind**: global function
-
-
-## getEvictionBlocklist()
-Getter - returns the eviction block list.
-
**Kind**: global function
@@ -213,7 +197,7 @@ Setter - sets the skippable collection member IDs.
**Kind**: global function
-## initStoreValues(keys, initialKeyStates, evictableKeys)
+## initStoreValues(keys, initialKeyStates, evictableKeys, fullyMergedSnapshotKeys)
Sets the initial values for the Onyx store
**Kind**: global function
@@ -222,7 +206,8 @@ Sets the initial values for the Onyx store
| --- | --- |
| keys | `ONYXKEYS` constants object from Onyx.init() |
| initialKeyStates | initial data to set when `init()` and `clear()` are called |
-| evictableKeys | This is an array of keys (individual or collection patterns) that are eligible for automatic removal when storage limits are reached. |
+| evictableKeys | This is an array of keys (individual or collection patterns) that when provided to Onyx are flagged as "safe" for removal. |
+| fullyMergedSnapshotKeys | Array of snapshot collection keys where full merge is supported and data structure can be changed after merge. |
@@ -318,12 +303,6 @@ or throws an Error if the key is not a collection one.
Checks to see if a provided key is the exact configured key of our connected subscriber
or if the provided key is a collection member key (in case our configured key is a "collection key")
-**Kind**: global function
-
-
-## isEvictableKey()
-Checks to see if this key has been flagged as safe for removal.
-
**Kind**: global function
@@ -349,29 +328,6 @@ For example:
Tries to get a value from the cache. If the value is not present in cache it will return the default value or undefined.
If the requested key is a collection, it will return an object with all the collection members.
-**Kind**: global function
-
-
-## removeLastAccessedKey()
-Remove a key from the recently accessed key list.
-
-**Kind**: global function
-
-
-## addLastAccessedKey()
-Add a key to the list of recently accessed keys. The least
-recently accessed key should be at the head and the most
-recently accessed key at the tail.
-
-**Kind**: global function
-
-
-## addEvictableKeysToRecentlyAccessedList()
-Take all the keys that are safe to evict and add them to
-the recently accessed list when initializing the app. This
-enables keys that have not recently been accessed to be
-removed.
-
**Kind**: global function
@@ -465,15 +421,6 @@ whatever it is we attempted to do.
Notifies subscribers and writes current value to cache
**Kind**: global function
-
-
-## removeNullValues() ⇒
-Removes a key from storage if the value is null.
-Otherwise removes all nested null values in objects,
-if shouldRemoveNestedNulls is true and returns the object.
-
-**Kind**: global function
-**Returns**: The value without null values and a boolean "wasRemoved", which indicates if the key got removed completely
## prepareKeyValuePairsForStorage() ⇒
@@ -483,16 +430,42 @@ to an array of key-value pairs in the above format and removes key-value pairs t
**Kind**: global function
**Returns**: an array of key - value pairs <[key, value]>
-
+
+
+## mergeChanges(changes, existingValue)
+Merges an array of changes with an existing value or creates a single change.
+
+**Kind**: global function
+
+| Param | Description |
+| --- | --- |
+| changes | Array of changes that should be merged |
+| existingValue | The existing value that should be merged with the changes |
+
+
+
+## mergeAndMarkChanges(changes, existingValue)
+Merges an array of changes with an existing value or creates a single change.
+It will also mark deep nested objects that need to be entirely replaced during the merge.
+
+**Kind**: global function
+
+| Param | Description |
+| --- | --- |
+| changes | Array of changes that should be merged |
+| existingValue | The existing value that should be merged with the changes |
-## applyMerge(changes)
-Merges an array of changes with an existing value
+
+
+## mergeInternal(changes, existingValue)
+Merges an array of changes with an existing value or creates a single change.
**Kind**: global function
| Param | Description |
| --- | --- |
-| changes | Array of changes that should be applied to the existing value |
+| changes | Array of changes that should be merged |
+| existingValue | The existing value that should be merged with the changes |
@@ -535,3 +508,24 @@ Disconnects and removes the listener from the Onyx key.
| --- | --- |
| subscriptionID | Subscription ID returned by calling `OnyxUtils.subscribeToKey()`. |
+
+
+## mergeCollectionWithPatches(collectionKey, collection, mergeReplaceNullPatches)
+Merges a collection based on their keys.
+Serves as core implementation for `Onyx.mergeCollection()` public function, the difference being
+that this internal function allows passing an additional `mergeReplaceNullPatches` parameter.
+
+**Kind**: global function
+
+| Param | Description |
+| --- | --- |
+| collectionKey | e.g. `ONYXKEYS.COLLECTION.REPORT` |
+| collection | Object collection keyed by individual collection member keys and values |
+| mergeReplaceNullPatches | Record where the key is a collection member key and the value is a list of tuples that we'll use to replace the nested objects of that collection member record with something else. |
+
+
+
+## clearOnyxUtilsInternals()
+Clear internal variables used in this file, useful in test environments.
+
+**Kind**: global function
diff --git a/API.md b/API.md
index dc1fa112f..ec083bbbf 100644
--- a/API.md
+++ b/API.md
@@ -29,7 +29,7 @@ applied in the order they were called. Note: Onyx.set() calls do no
Onyx.merge() and Onyx.set().
mergeCollection(collectionKey, collection)
-Merges a collection based on their keys
+Merges a collection based on their keys.
clear(keysToPreserve)
Clear out all the data in the store
@@ -158,7 +158,7 @@ Onyx.merge(ONYXKEYS.POLICY, {name: 'My Workspace'}); // -> {id: 1, name: 'My Wor
## mergeCollection(collectionKey, collection)
-Merges a collection based on their keys
+Merges a collection based on their keys.
**Kind**: global function
diff --git a/jest.config.js b/jest.config.js
index b6f148ece..d904a33c4 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -15,4 +15,8 @@ module.exports = {
testTimeout: 60000,
transformIgnorePatterns: ['node_modules/(?!((@)?react-native|@ngneat/falso|uuid)/)'],
testSequencer: './jest-sequencer.js',
+ moduleNameMapper: {
+ // Redirect all imports of OnyxMerge to its web version during unit tests.
+ '^(.*)/OnyxMerge$': '/lib/OnyxMerge/index.ts',
+ },
};
diff --git a/lib/Onyx.ts b/lib/Onyx.ts
index 02c48a854..a656f4fad 100644
--- a/lib/Onyx.ts
+++ b/lib/Onyx.ts
@@ -1,5 +1,3 @@
-/* eslint-disable no-continue */
-import _ from 'underscore';
import * as Logger from './Logger';
import cache, {TASK} from './OnyxCache';
import * as PerformanceUtils from './PerformanceUtils';
@@ -33,6 +31,7 @@ import type {Connection} from './OnyxConnectionManager';
import connectionManager from './OnyxConnectionManager';
import * as GlobalSettings from './GlobalSettings';
import decorateWithMetrics from './metrics';
+import OnyxMerge from './OnyxMerge';
/** Initialize the store with actions and listening for storage events */
function init({
@@ -171,38 +170,31 @@ function set(key: TKey, value: OnyxSetInput): Promis
return Promise.resolve();
}
- // If the value is null, we remove the key from storage
- const {value: valueAfterRemoving, wasRemoved} = OnyxUtils.removeNullValues(key, value);
-
- const logSetCall = (hasChanged = true) => {
- // Logging properties only since values could be sensitive things we don't want to log
- Logger.logInfo(`set called for key: ${key}${_.isObject(value) ? ` properties: ${_.keys(value).join(',')}` : ''} hasChanged: ${hasChanged}`);
- };
-
- // Calling "OnyxUtils.removeNullValues" removes the key from storage and cache and updates the subscriber.
+ // If the change is null, we can just delete the key.
// Therefore, we don't need to further broadcast and update the value so we can return early.
- if (wasRemoved) {
- logSetCall();
+ if (value === null) {
+ OnyxUtils.remove(key);
+ OnyxUtils.logKeyRemoved(OnyxUtils.METHOD.SET, key);
return Promise.resolve();
}
- const valueWithoutNullValues = valueAfterRemoving as OnyxValue;
- const hasChanged = cache.hasValueChanged(key, valueWithoutNullValues);
+ const valueWithoutNestedNullValues = utils.removeNestedNullValues(value) as OnyxValue;
+ const hasChanged = cache.hasValueChanged(key, valueWithoutNestedNullValues);
- logSetCall(hasChanged);
+ OnyxUtils.logKeyChanged(OnyxUtils.METHOD.SET, key, value, hasChanged);
// This approach prioritizes fast UI changes without waiting for data to be stored in device storage.
- const updatePromise = OnyxUtils.broadcastUpdate(key, valueWithoutNullValues, hasChanged);
+ const updatePromise = OnyxUtils.broadcastUpdate(key, valueWithoutNestedNullValues, hasChanged);
// 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.
if (!hasChanged) {
return updatePromise;
}
- return Storage.setItem(key, valueWithoutNullValues)
- .catch((error) => OnyxUtils.evictStorageAndRetry(error, set, key, valueWithoutNullValues))
+ return Storage.setItem(key, valueWithoutNestedNullValues)
+ .catch((error) => OnyxUtils.evictStorageAndRetry(error, set, key, valueWithoutNestedNullValues))
.then(() => {
- OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET, key, valueWithoutNullValues);
+ OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET, key, valueWithoutNestedNullValues);
return updatePromise;
});
}
@@ -314,8 +306,6 @@ function merge(key: TKey, changes: OnyxMergeInput):
}
try {
- // We first only merge the changes, so we can provide these to the native implementation (SQLite uses only delta changes in "JSON_PATCH" to merge)
- // We don't want to remove null values from the "batchedDeltaChanges", because SQLite uses them to remove keys from storage natively.
const validChanges = mergeQueue[key].filter((change) => {
const {isCompatible, existingValueType, newValueType} = utils.checkCompatibilityWithExistingValue(change, existingValue);
if (!isCompatible) {
@@ -327,53 +317,21 @@ function merge(key: TKey, changes: OnyxMergeInput):
if (!validChanges.length) {
return Promise.resolve();
}
- const batchedDeltaChanges = OnyxUtils.applyMerge(undefined, validChanges, false);
- // Case (1): When there is no existing value in storage, we want to set the value instead of merge it.
- // Case (2): The presence of a top-level `null` in the merge queue instructs us to drop the whole existing value.
- // 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
- const shouldSetValue = !existingValue || mergeQueue[key].includes(null);
-
- // Clean up the write queue, so we don't apply these changes again
+ // Clean up the write queue, so we don't apply these changes again.
delete mergeQueue[key];
delete mergeQueuePromise[key];
- const logMergeCall = (hasChanged = true) => {
- // Logging properties only since values could be sensitive things we don't want to log
- Logger.logInfo(`merge called for key: ${key}${_.isObject(batchedDeltaChanges) ? ` properties: ${_.keys(batchedDeltaChanges).join(',')}` : ''} hasChanged: ${hasChanged}`);
- };
-
- // If the batched changes equal null, we want to remove the key from storage, to reduce storage size
- const {wasRemoved} = OnyxUtils.removeNullValues(key, batchedDeltaChanges);
-
- // Calling "OnyxUtils.removeNullValues" removes the key from storage and cache and updates the subscriber.
+ // If the last change is null, we can just delete the key.
// Therefore, we don't need to further broadcast and update the value so we can return early.
- if (wasRemoved) {
- logMergeCall();
+ if (validChanges.at(-1) === null) {
+ OnyxUtils.remove(key);
+ OnyxUtils.logKeyRemoved(OnyxUtils.METHOD.MERGE, key);
return Promise.resolve();
}
- // For providers that can't handle delta changes, we need to merge the batched changes with the existing value beforehand.
- // The "preMergedValue" will be directly "set" in storage instead of being merged
- // Therefore we merge the batched changes with the existing value to get the final merged value that will be stored.
- // We can remove null values from the "preMergedValue", because "null" implicates that the user wants to remove a value from storage.
- const preMergedValue = OnyxUtils.applyMerge(shouldSetValue ? undefined : existingValue, [batchedDeltaChanges], true);
-
- // In cache, we don't want to remove the key if it's null to improve performance and speed up the next merge.
- const hasChanged = cache.hasValueChanged(key, preMergedValue);
-
- logMergeCall(hasChanged);
-
- // This approach prioritizes fast UI changes without waiting for data to be stored in device storage.
- const updatePromise = OnyxUtils.broadcastUpdate(key, preMergedValue as OnyxValue, hasChanged);
-
- // If the value has not changed, calling Storage.setItem() would be redundant and a waste of performance, so return early instead.
- if (!hasChanged) {
- return updatePromise;
- }
-
- return Storage.mergeItem(key, batchedDeltaChanges as OnyxValue, preMergedValue as OnyxValue, shouldSetValue).then(() => {
- OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.MERGE, key, changes, preMergedValue);
+ return OnyxMerge.applyMerge(key, existingValue, validChanges).then(({mergedValue, updatePromise}) => {
+ OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.MERGE, key, changes, mergedValue);
return updatePromise;
});
} catch (error) {
@@ -386,7 +344,7 @@ function merge(key: TKey, changes: OnyxMergeInput):
}
/**
- * Merges a collection based on their keys
+ * Merges a collection based on their keys.
*
* @example
*
@@ -399,115 +357,7 @@ function merge(key: TKey, changes: OnyxMergeInput):
* @param collection Object collection keyed by individual collection member keys and values
*/
function mergeCollection(collectionKey: TKey, collection: OnyxMergeCollectionInput): Promise {
- if (!OnyxUtils.isValidNonEmptyCollectionForMerge(collection)) {
- Logger.logInfo('mergeCollection() called with invalid or empty value. Skipping this update.');
- return Promise.resolve();
- }
-
- let resultCollection: OnyxInputKeyValueMapping = collection;
- let resultCollectionKeys = Object.keys(resultCollection);
-
- // Confirm all the collection keys belong to the same parent
- if (!OnyxUtils.doAllCollectionItemsBelongToSameParent(collectionKey, resultCollectionKeys)) {
- return Promise.resolve();
- }
-
- const skippableCollectionMemberIDs = OnyxUtils.getSkippableCollectionMemberIDs();
- if (skippableCollectionMemberIDs.size) {
- resultCollection = resultCollectionKeys.reduce((result: OnyxInputKeyValueMapping, key) => {
- try {
- const [, collectionMemberID] = OnyxUtils.splitCollectionMemberKey(key, collectionKey);
- // If the collection member key is a skippable one we set its value to null.
- // eslint-disable-next-line no-param-reassign
- result[key] = !skippableCollectionMemberIDs.has(collectionMemberID) ? resultCollection[key] : null;
- } catch {
- // Something went wrong during split, so we assign the data to result anyway.
- // eslint-disable-next-line no-param-reassign
- result[key] = resultCollection[key];
- }
-
- return result;
- }, {});
- }
- resultCollectionKeys = Object.keys(resultCollection);
-
- return OnyxUtils.getAllKeys()
- .then((persistedKeys) => {
- // Split to keys that exist in storage and keys that don't
- const keys = resultCollectionKeys.filter((key) => {
- if (resultCollection[key] === null) {
- OnyxUtils.remove(key);
- return false;
- }
- return true;
- });
-
- const existingKeys = keys.filter((key) => persistedKeys.has(key));
-
- const cachedCollectionForExistingKeys = OnyxUtils.getCachedCollection(collectionKey, existingKeys);
-
- const existingKeyCollection = existingKeys.reduce((obj: OnyxInputKeyValueMapping, key) => {
- const {isCompatible, existingValueType, newValueType} = utils.checkCompatibilityWithExistingValue(resultCollection[key], cachedCollectionForExistingKeys[key]);
- if (!isCompatible) {
- Logger.logAlert(logMessages.incompatibleUpdateAlert(key, 'mergeCollection', existingValueType, newValueType));
- return obj;
- }
- // eslint-disable-next-line no-param-reassign
- obj[key] = resultCollection[key];
- return obj;
- }, {}) as Record>;
-
- const newCollection: Record> = {};
- keys.forEach((key) => {
- if (persistedKeys.has(key)) {
- return;
- }
- newCollection[key] = resultCollection[key];
- });
-
- // When (multi-)merging the values with the existing values in storage,
- // we don't want to remove nested null values from the data that we pass to the storage layer,
- // because the storage layer uses them to remove nested keys from storage natively.
- const keyValuePairsForExistingCollection = OnyxUtils.prepareKeyValuePairsForStorage(existingKeyCollection, false);
-
- // We can safely remove nested null values when using (multi-)set,
- // because we will simply overwrite the existing values in storage.
- const keyValuePairsForNewCollection = OnyxUtils.prepareKeyValuePairsForStorage(newCollection, true);
-
- const promises = [];
-
- // We need to get the previously existing values so we can compare the new ones
- // against them, to avoid unnecessary subscriber updates.
- const previousCollectionPromise = Promise.all(existingKeys.map((key) => OnyxUtils.get(key).then((value) => [key, value]))).then(Object.fromEntries);
-
- // New keys will be added via multiSet while existing keys will be updated using multiMerge
- // This is because setting a key that doesn't exist yet with multiMerge will throw errors
- if (keyValuePairsForExistingCollection.length > 0) {
- promises.push(Storage.multiMerge(keyValuePairsForExistingCollection));
- }
-
- if (keyValuePairsForNewCollection.length > 0) {
- promises.push(Storage.multiSet(keyValuePairsForNewCollection));
- }
-
- // finalMergedCollection contains all the keys that were merged, without the keys of incompatible updates
- const finalMergedCollection = {...existingKeyCollection, ...newCollection};
-
- // Prefill cache if necessary by calling get() on any existing keys and then merge original data to cache
- // and update all subscribers
- const promiseUpdate = previousCollectionPromise.then((previousCollection) => {
- cache.merge(finalMergedCollection);
- return OnyxUtils.scheduleNotifyCollectionSubscribers(collectionKey, finalMergedCollection, previousCollection);
- });
-
- return Promise.all(promises)
- .catch((error) => OnyxUtils.evictStorageAndRetry(error, mergeCollection, collectionKey, resultCollection))
- .then(() => {
- OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.MERGE_COLLECTION, undefined, resultCollection);
- return promiseUpdate;
- });
- })
- .then(() => undefined);
+ return OnyxUtils.mergeCollectionWithPatches(collectionKey, collection);
}
/**
@@ -718,24 +568,35 @@ function update(data: OnyxUpdate[]): Promise {
// Remove the collection-related key from the updateQueue so that it won't be processed individually.
delete updateQueue[key];
- const updatedValue = OnyxUtils.applyMerge(undefined, operations, false);
+ const batchedChanges = OnyxUtils.mergeAndMarkChanges(operations);
if (operations[0] === null) {
// eslint-disable-next-line no-param-reassign
- queue.set[key] = updatedValue;
+ queue.set[key] = batchedChanges.result;
} else {
// eslint-disable-next-line no-param-reassign
- queue.merge[key] = updatedValue;
+ queue.merge[key] = batchedChanges.result;
+ if (batchedChanges.replaceNullPatches.length > 0) {
+ // eslint-disable-next-line no-param-reassign
+ queue.mergeReplaceNullPatches[key] = batchedChanges.replaceNullPatches;
+ }
}
return queue;
},
{
merge: {},
+ mergeReplaceNullPatches: {},
set: {},
},
);
if (!utils.isEmptyObject(batchedCollectionUpdates.merge)) {
- promises.push(() => mergeCollection(collectionKey, batchedCollectionUpdates.merge as Collection));
+ promises.push(() =>
+ OnyxUtils.mergeCollectionWithPatches(
+ collectionKey,
+ batchedCollectionUpdates.merge as Collection,
+ batchedCollectionUpdates.mergeReplaceNullPatches,
+ ),
+ );
}
if (!utils.isEmptyObject(batchedCollectionUpdates.set)) {
promises.push(() => multiSet(batchedCollectionUpdates.set));
@@ -743,13 +604,16 @@ function update(data: OnyxUpdate[]): Promise {
});
Object.entries(updateQueue).forEach(([key, operations]) => {
- const batchedChanges = OnyxUtils.applyMerge(undefined, operations, false);
-
if (operations[0] === null) {
+ const batchedChanges = OnyxUtils.mergeChanges(operations).result;
promises.push(() => set(key, batchedChanges));
- } else {
- promises.push(() => merge(key, batchedChanges));
+ return;
}
+
+ const mergePromises = operations.map((operation) => {
+ return merge(key, operation);
+ });
+ promises.push(() => mergePromises.at(0) ?? Promise.resolve());
});
const snapshotPromises = OnyxUtils.updateSnapshots(data, merge);
diff --git a/lib/OnyxCache.ts b/lib/OnyxCache.ts
index f61ce4056..202720b80 100644
--- a/lib/OnyxCache.ts
+++ b/lib/OnyxCache.ts
@@ -220,7 +220,12 @@ class OnyxCache {
throw new Error('data passed to cache.merge() must be an Object of onyx key/value pairs');
}
- this.storageMap = {...utils.fastMerge(this.storageMap, data)};
+ this.storageMap = {
+ ...utils.fastMerge(this.storageMap, data, {
+ shouldRemoveNestedNulls: true,
+ objectRemovalMode: 'replace',
+ }).result,
+ };
Object.entries(data).forEach(([key, value]) => {
this.addKey(key);
diff --git a/lib/OnyxMerge/index.native.ts b/lib/OnyxMerge/index.native.ts
new file mode 100644
index 000000000..b796dfde4
--- /dev/null
+++ b/lib/OnyxMerge/index.native.ts
@@ -0,0 +1,48 @@
+import OnyxUtils from '../OnyxUtils';
+import type {OnyxInput, OnyxKey, OnyxValue} from '../types';
+import cache from '../OnyxCache';
+import Storage from '../storage';
+import type {ApplyMerge} from './types';
+
+const applyMerge: ApplyMerge = | undefined, TChange extends OnyxInput | undefined>(
+ key: TKey,
+ existingValue: TValue,
+ validChanges: TChange[],
+) => {
+ // If any of the changes is null, we need to discard the existing value.
+ const baseValue = validChanges.includes(null as TChange) ? undefined : existingValue;
+
+ // We first batch the changes into a single change with object removal marks,
+ // so that SQLite can merge the changes more efficiently.
+ const {result: batchedChanges, replaceNullPatches} = OnyxUtils.mergeAndMarkChanges(validChanges);
+
+ // We then merge the batched changes with the existing value, because we need to final merged value to broadcast to subscribers.
+ const {result: mergedValue} = OnyxUtils.mergeChanges([batchedChanges], baseValue);
+
+ // In cache, we don't want to remove the key if it's null to improve performance and speed up the next merge.
+ const hasChanged = cache.hasValueChanged(key, mergedValue);
+
+ // Logging properties only since values could be sensitive things we don't want to log.
+ OnyxUtils.logKeyChanged(OnyxUtils.METHOD.MERGE, key, mergedValue, hasChanged);
+
+ // This approach prioritizes fast UI changes without waiting for data to be stored in device storage.
+ const updatePromise = OnyxUtils.broadcastUpdate(key, mergedValue as OnyxValue, hasChanged);
+
+ // If the value has not changed, calling Storage.setItem() would be redundant and a waste of performance, so return early instead.
+ if (!hasChanged) {
+ return Promise.resolve({mergedValue, updatePromise});
+ }
+
+ // For native platforms we use `mergeItem` that will take advantage of JSON_PATCH and JSON_REPLACE SQL operations to
+ // merge the object in a performant way.
+ return Storage.mergeItem(key, batchedChanges as OnyxValue, replaceNullPatches).then(() => ({
+ mergedValue,
+ updatePromise,
+ }));
+};
+
+const OnyxMerge = {
+ applyMerge,
+};
+
+export default OnyxMerge;
diff --git a/lib/OnyxMerge/index.ts b/lib/OnyxMerge/index.ts
new file mode 100644
index 000000000..2a648499b
--- /dev/null
+++ b/lib/OnyxMerge/index.ts
@@ -0,0 +1,39 @@
+import cache from '../OnyxCache';
+import OnyxUtils from '../OnyxUtils';
+import Storage from '../storage';
+import type {OnyxInput, OnyxKey, OnyxValue} from '../types';
+import type {ApplyMerge} from './types';
+
+const applyMerge: ApplyMerge = | undefined, TChange extends OnyxInput | undefined>(
+ key: TKey,
+ existingValue: TValue,
+ validChanges: TChange[],
+) => {
+ const {result: mergedValue} = OnyxUtils.mergeChanges(validChanges, existingValue);
+
+ // In cache, we don't want to remove the key if it's null to improve performance and speed up the next merge.
+ const hasChanged = cache.hasValueChanged(key, mergedValue);
+
+ // Logging properties only since values could be sensitive things we don't want to log.
+ OnyxUtils.logKeyChanged(OnyxUtils.METHOD.MERGE, key, mergedValue, hasChanged);
+
+ // This approach prioritizes fast UI changes without waiting for data to be stored in device storage.
+ const updatePromise = OnyxUtils.broadcastUpdate(key, mergedValue as OnyxValue, hasChanged);
+
+ // If the value has not changed, calling Storage.setItem() would be redundant and a waste of performance, so return early instead.
+ if (!hasChanged) {
+ return Promise.resolve({mergedValue, updatePromise});
+ }
+
+ // For web platforms we use `setItem` since the object was already merged with its changes before.
+ return Storage.setItem(key, mergedValue as OnyxValue).then(() => ({
+ mergedValue,
+ updatePromise,
+ }));
+};
+
+const OnyxMerge = {
+ applyMerge,
+};
+
+export default OnyxMerge;
diff --git a/lib/OnyxMerge/types.ts b/lib/OnyxMerge/types.ts
new file mode 100644
index 000000000..c59b7892a
--- /dev/null
+++ b/lib/OnyxMerge/types.ts
@@ -0,0 +1,14 @@
+import type {OnyxInput, OnyxKey} from '../types';
+
+type ApplyMergeResult = {
+ mergedValue: TValue;
+ updatePromise: Promise;
+};
+
+type ApplyMerge = | undefined, TChange extends OnyxInput | null>(
+ key: TKey,
+ existingValue: TValue,
+ validChanges: TChange[],
+) => Promise>;
+
+export type {ApplyMerge, ApplyMergeResult};
diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts
index 28b7ee039..6e7581577 100644
--- a/lib/OnyxUtils.ts
+++ b/lib/OnyxUtils.ts
@@ -3,6 +3,7 @@ import {deepEqual} from 'fast-equals';
import lodashClone from 'lodash/clone';
import type {ValueOf} from 'type-fest';
import lodashPick from 'lodash/pick';
+import _ from 'underscore';
import DevTools from './DevTools';
import * as Logger from './Logger';
import type Onyx from './Onyx';
@@ -20,21 +21,26 @@ import type {
DefaultConnectOptions,
KeyValueMapping,
Mapping,
+ MultiMergeReplaceNullPatches,
OnyxCollection,
OnyxEntry,
OnyxInput,
+ OnyxInputKeyValueMapping,
OnyxKey,
OnyxMergeCollectionInput,
OnyxUpdate,
OnyxValue,
Selector,
} from './types';
+import type {FastMergeOptions, FastMergeResult} from './utils';
import utils from './utils';
import type {WithOnyxState} from './withOnyx/types';
import type {DeferredTask} from './createDeferredTask';
import createDeferredTask from './createDeferredTask';
import * as GlobalSettings from './GlobalSettings';
import decorateWithMetrics from './metrics';
+import type {StorageKeyValuePair} from './storage/providers/types';
+import logMessages from './logMessages';
// Method constants
const METHOD = {
@@ -357,7 +363,7 @@ function multiGet(keys: CollectionKeyBase[]): Promise