diff --git a/lib/Onyx.js b/lib/Onyx.js index 2de92b24..57a2a508 100644 --- a/lib/Onyx.js +++ b/lib/Onyx.js @@ -1,6 +1,5 @@ /* eslint-disable no-continue */ import {deepEqual} from 'fast-equals'; -import lodashGet from 'lodash/get'; import _ from 'underscore'; import * as Logger from './Logger'; import cache from './OnyxCache'; @@ -56,9 +55,7 @@ const deferredInitTask = createDeferredTask(); * If it's a function, it is passed the sourceData and it should return the simplified data * @returns {Mixed} */ -const getSubsetOfData = (sourceData, selector, withOnyxInstanceState) => (_.isFunction(selector) - ? selector(sourceData, withOnyxInstanceState) - : lodashGet(sourceData, selector)); +const getSubsetOfData = (sourceData, selector, withOnyxInstanceState) => selector(sourceData, withOnyxInstanceState); /** * Takes a collection of items (eg. {testKey_1:{a:'a'}, testKey_2:{b:'b'}}) @@ -69,12 +66,24 @@ const getSubsetOfData = (sourceData, selector, withOnyxInstanceState) => (_.isFu * @param {Object} [withOnyxInstanceState] * @returns {Object} */ -const reduceCollectionWithSelector = (collection, selector, withOnyxInstanceState) => _.reduce(collection, (finalCollection, item, key) => { - // eslint-disable-next-line no-param-reassign - finalCollection[key] = getSubsetOfData(item, selector, withOnyxInstanceState); +const reduceCollectionWithSelector = ( + collection, + selector, + withOnyxInstanceState, +) => _.reduce( + collection, + (finalCollection, item, key) => { + // eslint-disable-next-line no-param-reassign + finalCollection[key] = getSubsetOfData( + item, + selector, + withOnyxInstanceState, + ); - return finalCollection; -}, {}); + return finalCollection; + }, + {}, +); /** * Get some data from the store @@ -102,7 +111,9 @@ function get(key) { cache.set(key, val); return val; }) - .catch(err => Logger.logInfo(`Unable to get item from persistent storage. Key: ${key} Error: ${err}`)); + .catch(err => Logger.logInfo( + `Unable to get item from persistent storage. Key: ${key} Error: ${err}`, + )); return cache.captureTask(taskName, promise); } @@ -127,11 +138,10 @@ function getAllKeys() { } // Otherwise retrieve the keys from storage and capture a promise to aid concurrent usages - const promise = Storage.getAllKeys() - .then((keys) => { - _.each(keys, key => cache.addKey(key)); - return keys; - }); + const promise = Storage.getAllKeys().then((keys) => { + _.each(keys, key => cache.addKey(key)); + return keys; + }); return cache.captureTask(taskName, promise); } @@ -154,7 +164,9 @@ function isCollectionKey(key) { * @returns {Boolean} */ function isCollectionMemberKey(collectionKey, key) { - return Str.startsWith(key, collectionKey) && key.length > collectionKey.length; + return ( + Str.startsWith(key, collectionKey) && key.length > collectionKey.length + ); } /** @@ -221,7 +233,10 @@ function addLastAccessedKey(key) { * @param {Number} connectionID */ function removeFromEvictionBlockList(key, connectionID) { - evictionBlocklist[key] = _.without(evictionBlocklist[key] || [], connectionID); + evictionBlocklist[key] = _.without( + evictionBlocklist[key] || [], + connectionID, + ); // Remove the key if there are no more subscribers if (evictionBlocklist[key].length === 0) { @@ -256,17 +271,16 @@ function addToEvictionBlockList(key, connectionID) { * @returns {Promise} */ function addAllSafeEvictionKeysToRecentlyAccessedList() { - return getAllKeys() - .then((keys) => { - _.each(evictionAllowList, (safeEvictionKey) => { - _.each(keys, (key) => { - if (!isKeyMatch(safeEvictionKey, key)) { - return; - } - addLastAccessedKey(key); - }); + return getAllKeys().then((keys) => { + _.each(evictionAllowList, (safeEvictionKey) => { + _.each(keys, (key) => { + if (!isKeyMatch(safeEvictionKey, key)) { + return; + } + addLastAccessedKey(key); }); }); + }); } /** @@ -275,20 +289,22 @@ function addAllSafeEvictionKeysToRecentlyAccessedList() { * @returns {Object} */ function getCachedCollection(collectionKey) { - const collectionMemberKeys = _.filter(cache.getAllKeys(), ( - storedKey => isCollectionMemberKey(collectionKey, storedKey) - )); + const collectionMemberKeys = _.filter(cache.getAllKeys(), storedKey => isCollectionMemberKey(collectionKey, storedKey)); + + return _.reduce( + collectionMemberKeys, + (prev, curr) => { + const cachedValue = cache.getValue(curr); + if (!cachedValue) { + return prev; + } - return _.reduce(collectionMemberKeys, (prev, curr) => { - const cachedValue = cache.getValue(curr); - if (!cachedValue) { + // eslint-disable-next-line no-param-reassign + prev[curr] = cachedValue; return prev; - } - - // eslint-disable-next-line no-param-reassign - prev[curr] = cachedValue; - return prev; - }, {}); + }, + {}, + ); } /** @@ -322,7 +338,10 @@ function keysChanged(collectionKey, partialCollection) { /** * e.g. Onyx.connect({key: `${ONYXKEYS.COLLECTION.REPORT}{reportID}`, callback: ...}); */ - const isSubscribedToCollectionMemberKey = isCollectionMemberKey(collectionKey, subscriber.key); + const isSubscribedToCollectionMemberKey = isCollectionMemberKey( + collectionKey, + subscriber.key, + ); // We prepare the "cached collection" which is the entire collection + the new partial data that // was merged in via mergeCollection(). @@ -351,7 +370,10 @@ function keysChanged(collectionKey, partialCollection) { // And if the subscriber is specifically only tracking a particular collection member key then we will // notify them with the cached data for that key only. if (isSubscribedToCollectionMemberKey) { - subscriber.callback(cachedCollection[subscriber.key], subscriber.key); + subscriber.callback( + cachedCollection[subscriber.key], + subscriber.key, + ); continue; } @@ -368,7 +390,11 @@ function keysChanged(collectionKey, partialCollection) { if (subscriber.selector) { subscriber.withOnyxInstance.setState((prevState) => { const previousData = prevState[subscriber.statePropertyName]; - const newData = reduceCollectionWithSelector(cachedCollection, subscriber.selector, subscriber.withOnyxInstance.state); + const newData = reduceCollectionWithSelector( + cachedCollection, + subscriber.selector, + subscriber.withOnyxInstance.state, + ); if (!deepEqual(previousData, newData)) { return { @@ -381,14 +407,22 @@ function keysChanged(collectionKey, partialCollection) { } subscriber.withOnyxInstance.setState((prevState) => { - const finalCollection = _.clone(prevState[subscriber.statePropertyName] || {}); + const finalCollection = _.clone( + prevState[subscriber.statePropertyName] || {}, + ); const dataKeys = _.keys(partialCollection); for (let j = 0; j < dataKeys.length; j++) { const dataKey = dataKeys[j]; finalCollection[dataKey] = cachedCollection[dataKey]; } - PerformanceUtils.logSetStateCall(subscriber, prevState[subscriber.statePropertyName], finalCollection, 'keysChanged', collectionKey); + PerformanceUtils.logSetStateCall( + subscriber, + prevState[subscriber.statePropertyName], + finalCollection, + 'keysChanged', + collectionKey, + ); return { [subscriber.statePropertyName]: finalCollection, }; @@ -411,9 +445,19 @@ function keysChanged(collectionKey, partialCollection) { if (subscriber.selector) { subscriber.withOnyxInstance.setState((prevState) => { const prevData = prevState[subscriber.statePropertyName]; - const newData = getSubsetOfData(cachedCollection[subscriber.key], subscriber.selector, subscriber.withOnyxInstance.state); + const newData = getSubsetOfData( + cachedCollection[subscriber.key], + subscriber.selector, + subscriber.withOnyxInstance.state, + ); if (!deepEqual(prevData, newData)) { - PerformanceUtils.logSetStateCall(subscriber, prevData, newData, 'keysChanged', collectionKey); + PerformanceUtils.logSetStateCall( + subscriber, + prevData, + newData, + 'keysChanged', + collectionKey, + ); return { [subscriber.statePropertyName]: newData, }; @@ -431,7 +475,13 @@ function keysChanged(collectionKey, partialCollection) { return null; } - PerformanceUtils.logSetStateCall(subscriber, previousData, data, 'keysChanged', collectionKey); + PerformanceUtils.logSetStateCall( + subscriber, + previousData, + data, + 'keysChanged', + collectionKey, + ); return { [subscriber.statePropertyName]: data, }; @@ -466,13 +516,21 @@ function keyChanged(key, data, canUpdateSubscriber) { const stateMappingKeys = _.keys(callbackToStateMapping); for (let i = 0; i < stateMappingKeys.length; i++) { const subscriber = callbackToStateMapping[stateMappingKeys[i]]; - if (!subscriber || !isKeyMatch(subscriber.key, key) || (_.isFunction(canUpdateSubscriber) && !canUpdateSubscriber(subscriber))) { + if ( + !subscriber + || !isKeyMatch(subscriber.key, key) + || (_.isFunction(canUpdateSubscriber) + && !canUpdateSubscriber(subscriber)) + ) { continue; } // Subscriber is a regular call to connect() and provided a callback if (_.isFunction(subscriber.callback)) { - if (isCollectionKey(subscriber.key) && subscriber.waitForCollectionCallback) { + if ( + isCollectionKey(subscriber.key) + && subscriber.waitForCollectionCallback + ) { const cachedCollection = getCachedCollection(subscriber.key); cachedCollection[key] = data; subscriber.callback(cachedCollection); @@ -493,16 +551,27 @@ function keyChanged(key, data, canUpdateSubscriber) { subscriber.withOnyxInstance.setState((prevState) => { const prevData = prevState[subscriber.statePropertyName]; const newData = { - [key]: getSubsetOfData(data, subscriber.selector, subscriber.withOnyxInstance.state), + [key]: getSubsetOfData( + data, + subscriber.selector, + subscriber.withOnyxInstance.state, + ), }; const prevDataWithNewData = { ...prevData, ...newData, }; if (!deepEqual(prevData, prevDataWithNewData)) { - PerformanceUtils.logSetStateCall(subscriber, prevData, newData, 'keyChanged', key); + PerformanceUtils.logSetStateCall( + subscriber, + prevData, + newData, + 'keyChanged', + key, + ); return { - [subscriber.statePropertyName]: prevDataWithNewData, + [subscriber.statePropertyName]: + prevDataWithNewData, }; } return null; @@ -516,7 +585,13 @@ function keyChanged(key, data, canUpdateSubscriber) { ...collection, [key]: data, }; - PerformanceUtils.logSetStateCall(subscriber, collection, newCollection, 'keyChanged', key); + PerformanceUtils.logSetStateCall( + subscriber, + collection, + newCollection, + 'keyChanged', + key, + ); return { [subscriber.statePropertyName]: newCollection, }; @@ -528,8 +603,16 @@ function keyChanged(key, data, canUpdateSubscriber) { // returned by the selector and only if the selected data has changed. if (subscriber.selector) { subscriber.withOnyxInstance.setState((prevState) => { - const previousValue = getSubsetOfData(prevState[subscriber.statePropertyName], subscriber.selector, subscriber.withOnyxInstance.state); - const newValue = getSubsetOfData(data, subscriber.selector, subscriber.withOnyxInstance.state); + const previousValue = getSubsetOfData( + prevState[subscriber.statePropertyName], + subscriber.selector, + subscriber.withOnyxInstance.state, + ); + const newValue = getSubsetOfData( + data, + subscriber.selector, + subscriber.withOnyxInstance.state, + ); if (!deepEqual(previousValue, newValue)) { return { [subscriber.statePropertyName]: newValue, @@ -547,7 +630,13 @@ function keyChanged(key, data, canUpdateSubscriber) { return null; } - PerformanceUtils.logSetStateCall(subscriber, previousData, data, 'keyChanged', key); + PerformanceUtils.logSetStateCall( + subscriber, + previousData, + data, + 'keyChanged', + key, + ); return { [subscriber.statePropertyName]: data, }; @@ -555,7 +644,9 @@ function keyChanged(key, data, canUpdateSubscriber) { continue; } - console.error('Warning: Found a matching subscriber to a key that changed, but no callback or withOnyxInstance could be found.'); + console.error( + 'Warning: Found a matching subscriber to a key that changed, but no callback or withOnyxInstance could be found.', + ); } } @@ -587,14 +678,30 @@ function sendDataToConnection(mapping, val, matchedKey) { // returned by the selector. if (mapping.selector) { if (isCollectionKey(mapping.key)) { - newData = reduceCollectionWithSelector(val, mapping.selector, mapping.withOnyxInstance.state); + newData = reduceCollectionWithSelector( + val, + mapping.selector, + mapping.withOnyxInstance.state, + ); } else { - newData = getSubsetOfData(val, mapping.selector, mapping.withOnyxInstance.state); + newData = getSubsetOfData( + val, + mapping.selector, + mapping.withOnyxInstance.state, + ); } } - PerformanceUtils.logSetStateCall(mapping, null, newData, 'sendDataToConnection'); - mapping.withOnyxInstance.setWithOnyxState(mapping.statePropertyName, newData); + PerformanceUtils.logSetStateCall( + mapping, + null, + newData, + 'sendDataToConnection', + ); + mapping.withOnyxInstance.setWithOnyxState( + mapping.statePropertyName, + newData, + ); return; } @@ -639,11 +746,15 @@ function addKeyToRecentlyAccessedIfNeeded(mapping) { */ function getCollectionDataAndSendAsObject(matchingKeys, mapping) { Promise.all(_.map(matchingKeys, key => get(key))) - .then(values => _.reduce(values, (finalObject, value, i) => { - // eslint-disable-next-line no-param-reassign - finalObject[matchingKeys[i]] = value; - return finalObject; - }, {})) + .then(values => _.reduce( + values, + (finalObject, value, i) => { + // eslint-disable-next-line no-param-reassign + finalObject[matchingKeys[i]] = value; + return finalObject; + }, + {}, + )) .then(val => sendDataToConnection(mapping, val)); } @@ -736,7 +847,9 @@ function connect(mapping) { return; } - console.error('Warning: Onyx.connect() was found without a callback or withOnyxInstance'); + console.error( + 'Warning: Onyx.connect() was found without a callback or withOnyxInstance', + ); }); // The connectionID is returned back to the caller so that it can be used to clean up the connection when it's no longer needed @@ -760,7 +873,10 @@ function disconnect(connectionID, keyToRemoveFromEvictionBlocklist) { // Remove this key from the eviction block list as we are no longer // subscribing to it and it should be safe to delete again if (keyToRemoveFromEvictionBlocklist) { - removeFromEvictionBlockList(keyToRemoveFromEvictionBlocklist, connectionID); + removeFromEvictionBlockList( + keyToRemoveFromEvictionBlocklist, + connectionID, + ); } delete callbackToStateMapping[connectionID]; @@ -824,23 +940,37 @@ function remove(key) { function evictStorageAndRetry(error, onyxMethod, ...args) { Logger.logInfo(`Handled error: ${error}`); - if (error && Str.startsWith(error.message, 'Failed to execute \'put\' on \'IDBObjectStore\'')) { - Logger.logAlert('Attempted to set invalid data set in Onyx. Please ensure all data is serializable.'); + if ( + error + && Str.startsWith( + error.message, + "Failed to execute 'put' on 'IDBObjectStore'", + ) + ) { + Logger.logAlert( + 'Attempted to set invalid data set in Onyx. Please ensure all data is serializable.', + ); throw error; } // Find the first key that we can remove that has no subscribers in our blocklist - const keyForRemoval = _.find(recentlyAccessedKeys, key => !evictionBlocklist[key]); + const keyForRemoval = _.find( + recentlyAccessedKeys, + key => !evictionBlocklist[key], + ); if (!keyForRemoval) { - Logger.logAlert('Out of storage. But found no acceptable keys to remove.'); + Logger.logAlert( + 'Out of storage. But found no acceptable keys to remove.', + ); throw error; } // Remove the least recently viewed key that is not currently being accessed and retry. - Logger.logInfo(`Out of storage. Evicting least recently accessed key (${keyForRemoval}) and retrying.`); - return remove(keyForRemoval) - .then(() => onyxMethod(...args)); + Logger.logInfo( + `Out of storage. Evicting least recently accessed key (${keyForRemoval}) and retrying.`, + ); + return remove(keyForRemoval).then(() => onyxMethod(...args)); } /** @@ -852,13 +982,21 @@ function evictStorageAndRetry(error, onyxMethod, ...args) { */ function broadcastUpdate(key, value, method) { // Logging properties only since values could be sensitive things we don't want to log - Logger.logInfo(`${method}() called for key: ${key}${_.isObject(value) ? ` properties: ${_.keys(value).join(',')}` : ''}`); + Logger.logInfo( + `${method}() called for key: ${key}${ + _.isObject(value) ? ` properties: ${_.keys(value).join(',')}` : '' + }`, + ); // Update subscribers if the cached value has changed, or when the subscriber specifically requires // all updates regardless of value changes (indicated by initWithStoredValues set to false). const hasChanged = cache.hasValueChanged(key, value); cache.set(key, value); - notifySubscribersOnNextTick(key, value, subscriber => hasChanged || subscriber.initWithStoredValues === false); + notifySubscribersOnNextTick( + key, + value, + subscriber => hasChanged || subscriber.initWithStoredValues === false, + ); } /** @@ -877,8 +1015,7 @@ function set(key, value) { // This approach prioritizes fast UI changes without waiting for data to be stored in device storage. broadcastUpdate(key, value, 'set'); - return Storage.setItem(key, value) - .catch(error => evictStorageAndRetry(error, set, key, value)); + return Storage.setItem(key, value).catch(error => evictStorageAndRetry(error, set, key, value)); } /** @@ -910,8 +1047,7 @@ function multiSet(data) { notifySubscribersOnNextTick(key, val); }); - return Storage.multiSet(keyValuePairs) - .catch(error => evictStorageAndRetry(error, multiSet, data)); + return Storage.multiSet(keyValuePairs).catch(error => evictStorageAndRetry(error, multiSet, data)); } /** @@ -931,14 +1067,21 @@ function applyMerge(changes, existingValue) { if (_.isObject(existingValue) || _.every(changes, _.isObject)) { // Object values are merged one after the other - return _.reduce(changes, (modifiedData, change) => { - // lodash adds a small overhead so we don't use it here - // eslint-disable-next-line prefer-object-spread, rulesdir/prefer-underscore-method - const newData = Object.assign({}, fastMerge(modifiedData, change)); - - // Remove all first level keys that are explicitly set to null. - return _.omit(newData, value => _.isNull(value)); - }, existingValue || {}); + return _.reduce( + changes, + (modifiedData, change) => { + // lodash adds a small overhead so we don't use it here + // eslint-disable-next-line prefer-object-spread, rulesdir/prefer-underscore-method + const newData = Object.assign( + {}, + fastMerge(modifiedData, change), + ); + + // Remove all first level keys that are explicitly set to null. + return _.omit(newData, value => _.isNull(value)); + }, + existingValue || {}, + ); } // If we have anything else we can't merge it so we'll @@ -975,29 +1118,30 @@ function merge(key, changes) { } mergeQueue[key] = [changes]; - return get(key) - .then((existingValue) => { - 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) - const batchedChanges = applyMerge(mergeQueue[key]); + return get(key).then((existingValue) => { + 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) + const batchedChanges = applyMerge(mergeQueue[key]); - // Clean up the write queue so we - // don't apply these changes again - delete mergeQueue[key]; + // Clean up the write queue so we + // don't apply these changes again + delete mergeQueue[key]; - // After that we merge the batched changes with the existing value - const modifiedData = applyMerge([batchedChanges], existingValue); + // After that we merge the batched changes with the existing value + const modifiedData = applyMerge([batchedChanges], existingValue); - // This approach prioritizes fast UI changes without waiting for data to be stored in device storage. - broadcastUpdate(key, modifiedData, 'merge'); + // This approach prioritizes fast UI changes without waiting for data to be stored in device storage. + broadcastUpdate(key, modifiedData, 'merge'); - return Storage.mergeItem(key, batchedChanges, modifiedData); - } catch (error) { - Logger.logAlert(`An error occurred while applying merge for key: ${key}, Error: ${error}`); - } + return Storage.mergeItem(key, batchedChanges, modifiedData); + } catch (error) { + Logger.logAlert( + `An error occurred while applying merge for key: ${key}, Error: ${error}`, + ); + } - return Promise.resolve(); - }); + return Promise.resolve(); + }); } /** @@ -1015,14 +1159,13 @@ function hasPendingMergeForKey(key) { * @returns {Promise} */ function initializeWithDefaultKeyStates() { - return Storage.multiGet(_.keys(defaultKeyStates)) - .then((pairs) => { - const asObject = _.object(pairs); + return Storage.multiGet(_.keys(defaultKeyStates)).then((pairs) => { + const asObject = _.object(pairs); - const merged = fastMerge(asObject, defaultKeyStates); - cache.merge(merged); - _.each(merged, (val, key) => keyChanged(key, val)); - }); + const merged = fastMerge(asObject, defaultKeyStates); + cache.merge(merged); + _.each(merged, (val, key) => keyChanged(key, val)); + }); } /** @@ -1048,64 +1191,68 @@ function initializeWithDefaultKeyStates() { * @returns {Promise} */ function clear(keysToPreserve = []) { - return getAllKeys() - .then((keys) => { - const keysToBeClearedFromStorage = []; - const keyValuesToResetAsCollection = {}; - const keyValuesToResetIndividually = {}; - - // The only keys that should not be cleared are: - // 1. Anything specifically passed in keysToPreserve (because some keys like language preferences, offline - // status, or activeClients need to remain in Onyx even when signed out) - // 2. Any keys with a default state (because they need to remain in Onyx as their default, and setting them - // to null would cause unknown behavior) - _.each(keys, (key) => { - const isKeyToPreserve = _.contains(keysToPreserve, key); - const isDefaultKey = _.has(defaultKeyStates, key); - - // If the key is being removed or reset to default: - // 1. Update it in the cache - // 2. Figure out whether it is a collection key or not, - // since collection key subscribers need to be updated differently - if (!isKeyToPreserve) { - const oldValue = cache.getValue(key); - const newValue = _.get(defaultKeyStates, key, null); - if (newValue !== oldValue) { - cache.set(key, newValue); - const collectionKey = key.substring(0, key.indexOf('_') + 1); - if (collectionKey) { - if (!keyValuesToResetAsCollection[collectionKey]) { - keyValuesToResetAsCollection[collectionKey] = {}; - } - keyValuesToResetAsCollection[collectionKey][key] = newValue; - } else { - keyValuesToResetIndividually[key] = newValue; + return getAllKeys().then((keys) => { + const keysToBeClearedFromStorage = []; + const keyValuesToResetAsCollection = {}; + const keyValuesToResetIndividually = {}; + + // The only keys that should not be cleared are: + // 1. Anything specifically passed in keysToPreserve (because some keys like language preferences, offline + // status, or activeClients need to remain in Onyx even when signed out) + // 2. Any keys with a default state (because they need to remain in Onyx as their default, and setting them + // to null would cause unknown behavior) + _.each(keys, (key) => { + const isKeyToPreserve = _.contains(keysToPreserve, key); + const isDefaultKey = _.has(defaultKeyStates, key); + + // If the key is being removed or reset to default: + // 1. Update it in the cache + // 2. Figure out whether it is a collection key or not, + // since collection key subscribers need to be updated differently + if (!isKeyToPreserve) { + const oldValue = cache.getValue(key); + const newValue = _.get(defaultKeyStates, key, null); + if (newValue !== oldValue) { + cache.set(key, newValue); + const collectionKey = key.substring( + 0, + key.indexOf('_') + 1, + ); + if (collectionKey) { + if (!keyValuesToResetAsCollection[collectionKey]) { + keyValuesToResetAsCollection[collectionKey] = {}; } + keyValuesToResetAsCollection[collectionKey][key] = newValue; + } else { + keyValuesToResetIndividually[key] = newValue; } } + } - if (isKeyToPreserve || isDefaultKey) { - return; - } + if (isKeyToPreserve || isDefaultKey) { + return; + } - // If it isn't preserved and doesn't have a default, we'll remove it - keysToBeClearedFromStorage.push(key); - }); + // If it isn't preserved and doesn't have a default, we'll remove it + keysToBeClearedFromStorage.push(key); + }); - // Notify the subscribers for each key/value group so they can receive the new values - _.each(keyValuesToResetIndividually, (value, key) => { - notifySubscribersOnNextTick(key, value); - }); - _.each(keyValuesToResetAsCollection, (value, key) => { - notifyCollectionSubscribersOnNextTick(key, value); - }); + // Notify the subscribers for each key/value group so they can receive the new values + _.each(keyValuesToResetIndividually, (value, key) => { + notifySubscribersOnNextTick(key, value); + }); + _.each(keyValuesToResetAsCollection, (value, key) => { + notifyCollectionSubscribersOnNextTick(key, value); + }); - const defaultKeyValuePairs = _.pairs(_.omit(defaultKeyStates, keysToPreserve)); + const defaultKeyValuePairs = _.pairs( + _.omit(defaultKeyStates, keysToPreserve), + ); - // Remove only the items that we want cleared from storage, and reset others to default - _.each(keysToBeClearedFromStorage, key => cache.drop(key)); - return Storage.removeItems(keysToBeClearedFromStorage).then(() => Storage.multiSet(defaultKeyValuePairs)); - }); + // Remove only the items that we want cleared from storage, and reset others to default + _.each(keysToBeClearedFromStorage, key => cache.drop(key)); + return Storage.removeItems(keysToBeClearedFromStorage).then(() => Storage.multiSet(defaultKeyValuePairs)); + }); } /** @@ -1123,8 +1270,14 @@ function clear(keysToPreserve = []) { * @returns {Promise} */ function mergeCollection(collectionKey, collection) { - if (!_.isObject(collection) || _.isArray(collection) || _.isEmpty(collection)) { - Logger.logInfo('mergeCollection() called with invalid or empty value. Skipping this update.'); + if ( + !_.isObject(collection) + || _.isArray(collection) + || _.isEmpty(collection) + ) { + Logger.logInfo( + 'mergeCollection() called with invalid or empty value. Skipping this update.', + ); return Promise.resolve(); } @@ -1136,11 +1289,15 @@ function mergeCollection(collectionKey, collection) { } if (process.env.NODE_ENV === 'development') { - throw new Error(`Provided collection doesn't have all its data belonging to the same parent. CollectionKey: ${collectionKey}, DataKey: ${dataKey}`); + throw new Error( + `Provided collection doesn't have all its data belonging to the same parent. CollectionKey: ${collectionKey}, DataKey: ${dataKey}`, + ); } hasCollectionKeyCheckFailed = true; - Logger.logAlert(`Provided collection doesn't have all its data belonging to the same parent. CollectionKey: ${collectionKey}, DataKey: ${dataKey}`); + Logger.logAlert( + `Provided collection doesn't have all its data belonging to the same parent. CollectionKey: ${collectionKey}, DataKey: ${dataKey}`, + ); }); // Gracefully handle bad mergeCollection updates so it doesn't block the merge queue @@ -1148,41 +1305,41 @@ function mergeCollection(collectionKey, collection) { return Promise.resolve(); } - return getAllKeys() - .then((persistedKeys) => { - // Split to keys that exist in storage and keys that don't - const [existingKeys, newKeys] = _.chain(collection) - .keys() - .partition(key => persistedKeys.includes(key)) - .value(); - - const existingKeyCollection = _.pick(collection, existingKeys); - const newCollection = _.pick(collection, newKeys); - const keyValuePairsForExistingCollection = prepareKeyValuePairsForStorage(existingKeyCollection); - const keyValuePairsForNewCollection = prepareKeyValuePairsForStorage(newCollection); - - const promises = []; - - // 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)); - } + return getAllKeys().then((persistedKeys) => { + // Split to keys that exist in storage and keys that don't + const [existingKeys, newKeys] = _.chain(collection) + .keys() + .partition(key => persistedKeys.includes(key)) + .value(); + + const existingKeyCollection = _.pick(collection, existingKeys); + const newCollection = _.pick(collection, newKeys); + const keyValuePairsForExistingCollection = prepareKeyValuePairsForStorage(existingKeyCollection); + const keyValuePairsForNewCollection = prepareKeyValuePairsForStorage(newCollection); + + const promises = []; + + // 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), + ); + } - // Prefill cache if necessary by calling get() on any existing keys and then merge original data to cache - // and update all subscribers - Promise.all(_.map(existingKeys, get)).then(() => { - cache.merge(collection); - keysChanged(collectionKey, collection); - }); + if (keyValuePairsForNewCollection.length > 0) { + promises.push(Storage.multiSet(keyValuePairsForNewCollection)); + } - return Promise.all(promises) - .catch(error => evictStorageAndRetry(error, mergeCollection, collection)); + // Prefill cache if necessary by calling get() on any existing keys and then merge original data to cache + // and update all subscribers + Promise.all(_.map(existingKeys, get)).then(() => { + cache.merge(collection); + keysChanged(collectionKey, collection); }); + + return Promise.all(promises).catch(error => evictStorageAndRetry(error, mergeCollection, collection)); + }); } /** @@ -1194,11 +1351,23 @@ function mergeCollection(collectionKey, collection) { function update(data) { // First, validate the Onyx object is in the format we expect _.each(data, ({onyxMethod, key}) => { - if (!_.contains([METHOD.CLEAR, METHOD.SET, METHOD.MERGE, METHOD.MERGE_COLLECTION], onyxMethod)) { + if ( + !_.contains( + [ + METHOD.CLEAR, + METHOD.SET, + METHOD.MERGE, + METHOD.MERGE_COLLECTION, + ], + onyxMethod, + ) + ) { throw new Error(`Invalid onyxMethod ${onyxMethod} in Onyx update.`); } if (onyxMethod !== METHOD.CLEAR && !_.isString(key)) { - throw new Error(`Invalid ${typeof key} key provided in Onyx update. Onyx key must be of type string.`); + throw new Error( + `Invalid ${typeof key} key provided in Onyx update. Onyx key must be of type string.`, + ); } }); @@ -1299,10 +1468,12 @@ function init({ Promise.all([ addAllSafeEvictionKeysToRecentlyAccessedList(), initializeWithDefaultKeyStates(), - ]) - .then(deferredInitTask.resolve); + ]).then(deferredInitTask.resolve); - if (shouldSyncMultipleInstances && _.isFunction(Storage.keepInstancesSync)) { + if ( + shouldSyncMultipleInstances + && _.isFunction(Storage.keepInstancesSync) + ) { Storage.keepInstancesSync((key, value) => { cache.set(key, value); keyChanged(key, value); @@ -1346,9 +1517,15 @@ function applyDecorators() { multiSet = decorate.decorateWithMetrics(multiSet, 'Onyx:multiSet'); clear = decorate.decorateWithMetrics(clear, 'Onyx:clear'); merge = decorate.decorateWithMetrics(merge, 'Onyx:merge'); - mergeCollection = decorate.decorateWithMetrics(mergeCollection, 'Onyx:mergeCollection'); + mergeCollection = decorate.decorateWithMetrics( + mergeCollection, + 'Onyx:mergeCollection', + ); getAllKeys = decorate.decorateWithMetrics(getAllKeys, 'Onyx:getAllKeys'); - initializeWithDefaultKeyStates = decorate.decorateWithMetrics(initializeWithDefaultKeyStates, 'Onyx:defaults'); + initializeWithDefaultKeyStates = decorate.decorateWithMetrics( + initializeWithDefaultKeyStates, + 'Onyx:defaults', + ); update = decorate.decorateWithMetrics(update, 'Onyx:update'); /* eslint-enable */ diff --git a/tests/unit/subscribeToPropertiesTest.js b/tests/unit/subscribeToPropertiesTest.js index 557c28cf..c4ac7870 100644 --- a/tests/unit/subscribeToPropertiesTest.js +++ b/tests/unit/subscribeToPropertiesTest.js @@ -50,56 +50,73 @@ describe('Only the specific property changes when using withOnyx() and ', () => * @returns {Promise} */ const runAssertionsWithComponent = (TestComponentWithOnyx) => { - let renderedComponent = render(); - return waitForPromisesToResolve() - - // When Onyx is updated with an object that has multiple properties - .then(() => Onyx.merge(ONYX_KEYS.TEST_KEY, {a: 'one', b: 'two'})) - .then(() => { - renderedComponent = render(); - return waitForPromisesToResolve(); - }) - - // Then the props passed to the component should only include the property "a" that was specified - .then(() => { - expect(renderedComponent.getByTestId('text-element').props.children).toEqual('{"propertyA":"one"}'); - }) - - // When Onyx is updated with a change to property a - .then(() => Onyx.merge(ONYX_KEYS.TEST_KEY, {a: 'two'})) - .then(() => { - renderedComponent = render(); - return waitForPromisesToResolve(); - }) - - // Then the props passed should have the new value of property "a" - .then(() => { - expect(renderedComponent.getByTestId('text-element').props.children).toEqual('{"propertyA":"two"}'); - }) - - // When Onyx is updated with a change to property b - .then(() => Onyx.merge(ONYX_KEYS.TEST_KEY, {b: 'two'})) - .then(() => { - renderedComponent = render(); - return waitForPromisesToResolve(); - }) - - // Then the props passed should not have changed - .then(() => { - expect(renderedComponent.getByTestId('text-element').props.children).toEqual('{"propertyA":"two"}'); - }); + let renderedComponent = render( + + + , + ); + return ( + waitForPromisesToResolve() + + // When Onyx is updated with an object that has multiple properties + .then(() => Onyx.merge(ONYX_KEYS.TEST_KEY, {a: 'one', b: 'two'})) + .then(() => { + renderedComponent = render( + + + , + ); + return waitForPromisesToResolve(); + }) + + // Then the props passed to the component should only include the property "a" that was specified + .then(() => { + expect( + renderedComponent.getByTestId('text-element').props + .children, + ).toEqual('{"propertyA":"one"}'); + }) + + // When Onyx is updated with a change to property a + .then(() => Onyx.merge(ONYX_KEYS.TEST_KEY, {a: 'two'})) + .then(() => { + renderedComponent = render( + + + , + ); + return waitForPromisesToResolve(); + }) + + // Then the props passed should have the new value of property "a" + .then(() => { + expect( + renderedComponent.getByTestId('text-element').props + .children, + ).toEqual('{"propertyA":"two"}'); + }) + + // When Onyx is updated with a change to property b + .then(() => Onyx.merge(ONYX_KEYS.TEST_KEY, {b: 'two'})) + .then(() => { + renderedComponent = render( + + + , + ); + return waitForPromisesToResolve(); + }) + + // Then the props passed should not have changed + .then(() => { + expect( + renderedComponent.getByTestId('text-element').props + .children, + ).toEqual('{"propertyA":"two"}'); + }) + ); }; - it('connecting to a single non-collection key with a selector string', () => { - const TestComponentWithOnyx = withOnyx({ - propertyA: { - key: ONYX_KEYS.TEST_KEY, - selector: 'a', - }, - })(ViewWithObject); - return runAssertionsWithComponent(TestComponentWithOnyx); - }); - it('connecting to a single non-collection key with a selector function', () => { const mockedSelector = jest.fn(obj => obj && obj.a); const TestComponentWithOnyx = withOnyx({ @@ -108,12 +125,14 @@ describe('Only the specific property changes when using withOnyx() and ', () => selector: mockedSelector, }, })(ViewWithObject); - return runAssertionsWithComponent(TestComponentWithOnyx) - .then(() => { - // This checks to make sure a bug doesn't occur where the entire state object was being passed to - // the selector - expect(mockedSelector).not.toHaveBeenCalledWith({loading: false, propertyA: null}); + return runAssertionsWithComponent(TestComponentWithOnyx).then(() => { + // This checks to make sure a bug doesn't occur where the entire state object was being passed to + // the selector + expect(mockedSelector).not.toHaveBeenCalledWith({ + loading: false, + propertyA: null, }); + }); }); /** @@ -123,64 +142,81 @@ describe('Only the specific property changes when using withOnyx() and ', () => * @returns {Promise} */ const runAllAssertionsForCollection = (TestComponentWithOnyx) => { - let renderedComponent = render(); - return waitForPromisesToResolve() - - // When Onyx is updated with an object that has multiple properties - .then(() => { - Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { - [`${ONYX_KEYS.COLLECTION.TEST_KEY}1`]: {a: 'one', b: 'two'}, - [`${ONYX_KEYS.COLLECTION.TEST_KEY}2`]: {c: 'three', d: 'four'}, - }); - return waitForPromisesToResolve(); - }) - .then(() => { - renderedComponent = render(); - return waitForPromisesToResolve(); - }) - - // Then the props passed to the component should only include the property "a" that was specified - .then(() => { - expect(renderedComponent.getByTestId('text-element').props.children).toEqual('{"collectionWithPropertyA":{"test_1":"one"}}'); - }) - - // When Onyx is updated with a change to property a using merge() - // This uses merge() just to make sure that everything works as expected when mixing merge() - // and mergeCollection() - .then(() => { - Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_KEY}1`, {a: 'two'}); - return waitForPromisesToResolve(); - }) - - // Then the props passed should have the new value of property "a" - .then(() => { - expect(renderedComponent.getByTestId('text-element').props.children).toEqual('{"collectionWithPropertyA":{"test_1":"two"}}'); - }) - - // When Onyx is updated with a change to property b using mergeCollection() - .then(() => { - Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { - [`${ONYX_KEYS.COLLECTION.TEST_KEY}1`]: {b: 'three'}, - }); - return waitForPromisesToResolve(); - }) - - // Then the props passed should not have changed - .then(() => { - expect(renderedComponent.getByTestId('text-element').props.children).toEqual('{"collectionWithPropertyA":{"test_1":"two"}}'); - }); + let renderedComponent = render( + + + , + ); + return ( + waitForPromisesToResolve() + + // When Onyx is updated with an object that has multiple properties + .then(() => { + Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { + [`${ONYX_KEYS.COLLECTION.TEST_KEY}1`]: { + a: 'one', + b: 'two', + }, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}2`]: { + c: 'three', + d: 'four', + }, + }); + return waitForPromisesToResolve(); + }) + .then(() => { + renderedComponent = render( + + + , + ); + return waitForPromisesToResolve(); + }) + + // Then the props passed to the component should only include the property "a" that was specified + .then(() => { + expect( + renderedComponent.getByTestId('text-element').props + .children, + ).toEqual('{"collectionWithPropertyA":{"test_1":"one"}}'); + }) + + // When Onyx is updated with a change to property a using merge() + // This uses merge() just to make sure that everything works as expected when mixing merge() + // and mergeCollection() + .then(() => { + Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_KEY}1`, { + a: 'two', + }); + return waitForPromisesToResolve(); + }) + + // Then the props passed should have the new value of property "a" + .then(() => { + expect( + renderedComponent.getByTestId('text-element').props + .children, + ).toEqual('{"collectionWithPropertyA":{"test_1":"two"}}'); + }) + + // When Onyx is updated with a change to property b using mergeCollection() + .then(() => { + Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { + [`${ONYX_KEYS.COLLECTION.TEST_KEY}1`]: {b: 'three'}, + }); + return waitForPromisesToResolve(); + }) + + // Then the props passed should not have changed + .then(() => { + expect( + renderedComponent.getByTestId('text-element').props + .children, + ).toEqual('{"collectionWithPropertyA":{"test_1":"two"}}'); + }) + ); }; - it('connecting to a collection with a selector string', () => { - const TestComponentWithOnyx = withOnyx({ - collectionWithPropertyA: { - key: ONYX_KEYS.COLLECTION.TEST_KEY, - selector: 'a', - }, - })(ViewWithObject); - return runAllAssertionsForCollection(TestComponentWithOnyx); - }); - it('connecting to a collection with a selector function', () => { const mockedSelector = jest.fn(obj => obj && obj.a); const TestComponentWithOnyx = withOnyx({ @@ -189,19 +225,28 @@ describe('Only the specific property changes when using withOnyx() and ', () => selector: mockedSelector, }, })(ViewWithObject); - return runAllAssertionsForCollection(TestComponentWithOnyx) - .then(() => { - // Expect that the selector always gets called with the full object - // from the onyx state, and not with the selector result value (string in this case). - for (let i = 0; i < mockedSelector.mock.calls.length; i++) { - const firstArg = mockedSelector.mock.calls[i][0]; - expect(firstArg).toBeDefined(); - expect(firstArg).toBeInstanceOf(Object); - } - - // Check to make sure that the selector was called with the props that are passed to the rendered component - expect(mockedSelector).toHaveBeenNthCalledWith(5, {a: 'two', b: 'two'}, {loading: false, collectionWithPropertyA: {test_1: 'one', test_2: undefined}}); - }); + return runAllAssertionsForCollection(TestComponentWithOnyx).then(() => { + // Expect that the selector always gets called with the full object + // from the onyx state, and not with the selector result value (string in this case). + for (let i = 0; i < mockedSelector.mock.calls.length; i++) { + const firstArg = mockedSelector.mock.calls[i][0]; + expect(firstArg).toBeDefined(); + expect(firstArg).toBeInstanceOf(Object); + } + + // Check to make sure that the selector was called with the props that are passed to the rendered component + expect(mockedSelector).toHaveBeenNthCalledWith( + 5, + {a: 'two', b: 'two'}, + { + loading: false, + collectionWithPropertyA: { + test_1: 'one', + test_2: undefined, + }, + }, + ); + }); }); /** @@ -211,64 +256,81 @@ describe('Only the specific property changes when using withOnyx() and ', () => * @returns {Promise} */ const runAllAssertionsForCollectionMemberKey = (TestComponentWithOnyx) => { - let renderedComponent = render(); - return waitForPromisesToResolve() - - // When Onyx is updated with an object that has multiple properties - .then(() => { - Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { - [`${ONYX_KEYS.COLLECTION.TEST_KEY}1`]: {a: 'one', b: 'two'}, - [`${ONYX_KEYS.COLLECTION.TEST_KEY}2`]: {c: 'three', d: 'four'}, - }); - return waitForPromisesToResolve(); - }) - .then(() => { - renderedComponent = render(); - return waitForPromisesToResolve(); - }) - - // Then the props passed to the component should only include the property "a" that was specified - .then(() => { - expect(renderedComponent.getByTestId('text-element').props.children).toEqual('{"itemWithPropertyA":"one"}'); - }) - - // When Onyx is updated with a change to property a using merge() - // This uses merge() just to make sure that everything works as expected when mixing merge() - // and mergeCollection() - .then(() => { - Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_KEY}1`, {a: 'two'}); - return waitForPromisesToResolve(); - }) - - // Then the props passed should have the new value of property "a" - .then(() => { - expect(renderedComponent.getByTestId('text-element').props.children).toEqual('{"itemWithPropertyA":"two"}'); - }) - - // When Onyx is updated with a change to property b using mergeCollection() - .then(() => { - Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { - [`${ONYX_KEYS.COLLECTION.TEST_KEY}1`]: {b: 'three'}, - }); - return waitForPromisesToResolve(); - }) - - // Then the props passed should not have changed - .then(() => { - expect(renderedComponent.getByTestId('text-element').props.children).toEqual('{"itemWithPropertyA":"two"}'); - }); + let renderedComponent = render( + + + , + ); + return ( + waitForPromisesToResolve() + + // When Onyx is updated with an object that has multiple properties + .then(() => { + Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { + [`${ONYX_KEYS.COLLECTION.TEST_KEY}1`]: { + a: 'one', + b: 'two', + }, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}2`]: { + c: 'three', + d: 'four', + }, + }); + return waitForPromisesToResolve(); + }) + .then(() => { + renderedComponent = render( + + + , + ); + return waitForPromisesToResolve(); + }) + + // Then the props passed to the component should only include the property "a" that was specified + .then(() => { + expect( + renderedComponent.getByTestId('text-element').props + .children, + ).toEqual('{"itemWithPropertyA":"one"}'); + }) + + // When Onyx is updated with a change to property a using merge() + // This uses merge() just to make sure that everything works as expected when mixing merge() + // and mergeCollection() + .then(() => { + Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_KEY}1`, { + a: 'two', + }); + return waitForPromisesToResolve(); + }) + + // Then the props passed should have the new value of property "a" + .then(() => { + expect( + renderedComponent.getByTestId('text-element').props + .children, + ).toEqual('{"itemWithPropertyA":"two"}'); + }) + + // When Onyx is updated with a change to property b using mergeCollection() + .then(() => { + Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { + [`${ONYX_KEYS.COLLECTION.TEST_KEY}1`]: {b: 'three'}, + }); + return waitForPromisesToResolve(); + }) + + // Then the props passed should not have changed + .then(() => { + expect( + renderedComponent.getByTestId('text-element').props + .children, + ).toEqual('{"itemWithPropertyA":"two"}'); + }) + ); }; - it('connecting to a collection member with a selector string', () => { - const TestComponentWithOnyx = withOnyx({ - itemWithPropertyA: { - key: `${ONYX_KEYS.COLLECTION.TEST_KEY}1`, - selector: 'a', - }, - })(ViewWithObject); - return runAllAssertionsForCollectionMemberKey(TestComponentWithOnyx); - }); - it('connecting to a collection member with a selector function', () => { const TestComponentWithOnyx = withOnyx({ itemWithPropertyA: {