UNPKG

react-native-onyx

Version:

State management for React Native

762 lines (761 loc) 41.2 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); /* eslint-disable no-continue */ const underscore_1 = __importDefault(require("underscore")); const Logger = __importStar(require("./Logger")); const OnyxCache_1 = __importStar(require("./OnyxCache")); const PerformanceUtils = __importStar(require("./PerformanceUtils")); const storage_1 = __importDefault(require("./storage")); const utils_1 = __importDefault(require("./utils")); const DevTools_1 = __importDefault(require("./DevTools")); const OnyxUtils_1 = __importDefault(require("./OnyxUtils")); const logMessages_1 = __importDefault(require("./logMessages")); const OnyxConnectionManager_1 = __importDefault(require("./OnyxConnectionManager")); const GlobalSettings = __importStar(require("./GlobalSettings")); const metrics_1 = __importDefault(require("./metrics")); /** Initialize the store with actions and listening for storage events */ function init({ keys = {}, initialKeyStates = {}, evictableKeys = [], maxCachedKeysCount = 1000, shouldSyncMultipleInstances = !!global.localStorage, debugSetState = false, enablePerformanceMetrics = false, skippableCollectionMemberIDs = [], }) { var _a; if (enablePerformanceMetrics) { GlobalSettings.setPerformanceMetricsEnabled(true); applyDecorators(); } storage_1.default.init(); OnyxUtils_1.default.setSkippableCollectionMemberIDs(new Set(skippableCollectionMemberIDs)); if (shouldSyncMultipleInstances) { (_a = storage_1.default.keepInstancesSync) === null || _a === void 0 ? void 0 : _a.call(storage_1.default, (key, value) => { const prevValue = OnyxCache_1.default.get(key, false); OnyxCache_1.default.set(key, value); OnyxUtils_1.default.keyChanged(key, value, prevValue); }); } if (debugSetState) { PerformanceUtils.setShouldDebugSetState(true); } if (maxCachedKeysCount > 0) { OnyxCache_1.default.setRecentKeysLimit(maxCachedKeysCount); } OnyxUtils_1.default.initStoreValues(keys, initialKeyStates, evictableKeys); // Initialize all of our keys with data provided then give green light to any pending connections Promise.all([OnyxCache_1.default.addEvictableKeysToRecentlyAccessedList(OnyxUtils_1.default.isCollectionKey, OnyxUtils_1.default.getAllKeys), OnyxUtils_1.default.initializeWithDefaultKeyStates()]).then(OnyxUtils_1.default.getDeferredInitTask().resolve); } /** * Connects to an Onyx key given the options passed and listens to its changes. * * @example * ```ts * const connection = Onyx.connect({ * key: ONYXKEYS.SESSION, * callback: onSessionChange, * }); * ``` * * @param connectOptions The options object that will define the behavior of the connection. * @param connectOptions.key The Onyx key to subscribe to. * @param connectOptions.callback A function that will be called when the Onyx data we are subscribed changes. * @param connectOptions.waitForCollectionCallback If set to `true`, it will return the entire collection to the callback as a single object. * @param connectOptions.withOnyxInstance The `withOnyx` class instance to be internally passed. **Only used inside `withOnyx()` HOC.** * @param connectOptions.statePropertyName The name of the component's prop that is connected to the Onyx key. **Only used inside `withOnyx()` HOC.** * @param connectOptions.displayName The component's display name. **Only used inside `withOnyx()` HOC.** * @param connectOptions.selector This will be used to subscribe to a subset of an Onyx key's data. **Only used inside `useOnyx()` hook or `withOnyx()` HOC.** * Using this setting on `useOnyx()` or `withOnyx()` can have very positive performance benefits because the component will only re-render * when the subset of data changes. Otherwise, any change of data on any property would normally * cause the component to re-render (and that can be expensive from a performance standpoint). * @returns The connection object to use when calling `Onyx.disconnect()`. */ function connect(connectOptions) { return OnyxConnectionManager_1.default.connect(connectOptions); } /** * Disconnects and removes the listener from the Onyx key. * * @example * ```ts * const connection = Onyx.connect({ * key: ONYXKEYS.SESSION, * callback: onSessionChange, * }); * * Onyx.disconnect(connection); * ``` * * @param connection Connection object returned by calling `Onyx.connect()`. */ function disconnect(connection) { OnyxConnectionManager_1.default.disconnect(connection); } /** * Write a value to our store with the given key * * @param key ONYXKEY to set * @param value value to store */ function set(key, value) { // When we use Onyx.set to set a key we want to clear the current delta changes from Onyx.merge that were queued // before the value was set. If Onyx.merge is currently reading the old value from storage, it will then not apply the changes. if (OnyxUtils_1.default.hasPendingMergeForKey(key)) { delete OnyxUtils_1.default.getMergeQueue()[key]; } const skippableCollectionMemberIDs = OnyxUtils_1.default.getSkippableCollectionMemberIDs(); if (skippableCollectionMemberIDs.size) { try { const [, collectionMemberID] = OnyxUtils_1.default.splitCollectionMemberKey(key); if (skippableCollectionMemberIDs.has(collectionMemberID)) { // The key is a skippable one, so we set the new value to null. // eslint-disable-next-line no-param-reassign value = null; } } catch (e) { // The key is not a collection one or something went wrong during split, so we proceed with the function's logic. } } // Onyx.set will ignore `undefined` values as inputs, therefore we can return early. if (value === undefined) { return Promise.resolve(); } const existingValue = OnyxCache_1.default.get(key, false); // If the existing value as well as the new value are null, we can return early. if (existingValue === undefined && value === null) { return Promise.resolve(); } // Check if the value is compatible with the existing value in the storage const { isCompatible, existingValueType, newValueType } = utils_1.default.checkCompatibilityWithExistingValue(value, existingValue); if (!isCompatible) { Logger.logAlert(logMessages_1.default.incompatibleUpdateAlert(key, 'set', existingValueType, newValueType)); return Promise.resolve(); } // If the value is null, we remove the key from storage const { value: valueAfterRemoving, wasRemoved } = OnyxUtils_1.default.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}${underscore_1.default.isObject(value) ? ` properties: ${underscore_1.default.keys(value).join(',')}` : ''} hasChanged: ${hasChanged}`); }; // Calling "OnyxUtils.removeNullValues" removes the key from storage and cache and updates the subscriber. // Therefore, we don't need to further broadcast and update the value so we can return early. if (wasRemoved) { logSetCall(); return Promise.resolve(); } const valueWithoutNullValues = valueAfterRemoving; const hasChanged = OnyxCache_1.default.hasValueChanged(key, valueWithoutNullValues); logSetCall(hasChanged); // This approach prioritizes fast UI changes without waiting for data to be stored in device storage. const updatePromise = OnyxUtils_1.default.broadcastUpdate(key, valueWithoutNullValues, 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_1.default.setItem(key, valueWithoutNullValues) .catch((error) => OnyxUtils_1.default.evictStorageAndRetry(error, set, key, valueWithoutNullValues)) .then(() => { OnyxUtils_1.default.sendActionToDevTools(OnyxUtils_1.default.METHOD.SET, key, valueWithoutNullValues); return updatePromise; }); } /** * Sets multiple keys and values * * @example Onyx.multiSet({'key1': 'a', 'key2': 'b'}); * * @param data object keyed by ONYXKEYS and the values to set */ function multiSet(data) { let newData = data; const skippableCollectionMemberIDs = OnyxUtils_1.default.getSkippableCollectionMemberIDs(); if (skippableCollectionMemberIDs.size) { newData = Object.keys(newData).reduce((result, key) => { try { const [, collectionMemberID] = OnyxUtils_1.default.splitCollectionMemberKey(key); // 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) ? newData[key] : null; } catch (_a) { // The key is not a collection one or something went wrong during split, so we assign the data to result anyway. // eslint-disable-next-line no-param-reassign result[key] = newData[key]; } return result; }, {}); } const keyValuePairsToSet = OnyxUtils_1.default.prepareKeyValuePairsForStorage(newData, true); const updatePromises = keyValuePairsToSet.map(([key, value]) => { const prevValue = OnyxCache_1.default.get(key, false); // When we use multiSet to set a key we want to clear the current delta changes from Onyx.merge that were queued // before the value was set. If Onyx.merge is currently reading the old value from storage, it will then not apply the changes. if (OnyxUtils_1.default.hasPendingMergeForKey(key)) { delete OnyxUtils_1.default.getMergeQueue()[key]; } // Update cache and optimistically inform subscribers on the next tick OnyxCache_1.default.set(key, value); return OnyxUtils_1.default.scheduleSubscriberUpdate(key, value, prevValue); }); return storage_1.default.multiSet(keyValuePairsToSet) .catch((error) => OnyxUtils_1.default.evictStorageAndRetry(error, multiSet, newData)) .then(() => { OnyxUtils_1.default.sendActionToDevTools(OnyxUtils_1.default.METHOD.MULTI_SET, undefined, newData); return Promise.all(updatePromises); }) .then(() => undefined); } /** * Merge a new value into an existing value at a key. * * The types of values that can be merged are `Object` and `Array`. To set another type of value use `Onyx.set()`. * Values of type `Object` get merged with the old value, whilst for `Array`'s we simply replace the current value with the new one. * * Calls to `Onyx.merge()` are batched so that any calls performed in a single tick will stack in a queue and get * applied in the order they were called. Note: `Onyx.set()` calls do not work this way so use caution when mixing * `Onyx.merge()` and `Onyx.set()`. * * @example * Onyx.merge(ONYXKEYS.EMPLOYEE_LIST, ['Joe']); // -> ['Joe'] * Onyx.merge(ONYXKEYS.EMPLOYEE_LIST, ['Jack']); // -> ['Joe', 'Jack'] * Onyx.merge(ONYXKEYS.POLICY, {id: 1}); // -> {id: 1} * Onyx.merge(ONYXKEYS.POLICY, {name: 'My Workspace'}); // -> {id: 1, name: 'My Workspace'} */ function merge(key, changes) { const skippableCollectionMemberIDs = OnyxUtils_1.default.getSkippableCollectionMemberIDs(); if (skippableCollectionMemberIDs.size) { try { const [, collectionMemberID] = OnyxUtils_1.default.splitCollectionMemberKey(key); if (skippableCollectionMemberIDs.has(collectionMemberID)) { // The key is a skippable one, so we set the new changes to undefined. // eslint-disable-next-line no-param-reassign changes = undefined; } } catch (e) { // The key is not a collection one or something went wrong during split, so we proceed with the function's logic. } } const mergeQueue = OnyxUtils_1.default.getMergeQueue(); const mergeQueuePromise = OnyxUtils_1.default.getMergeQueuePromise(); // Top-level undefined values are ignored // Therefore, we need to prevent adding them to the merge queue if (changes === undefined) { return mergeQueue[key] ? mergeQueuePromise[key] : Promise.resolve(); } // Merge attempts are batched together. The delta should be applied after a single call to get() to prevent a race condition. // Using the initial value from storage in subsequent merge attempts will lead to an incorrect final merged value. if (mergeQueue[key]) { mergeQueue[key].push(changes); return mergeQueuePromise[key]; } mergeQueue[key] = [changes]; mergeQueuePromise[key] = OnyxUtils_1.default.get(key).then((existingValue) => { // Calls to Onyx.set after a merge will terminate the current merge process and clear the merge queue if (mergeQueue[key] == null) { return Promise.resolve(); } 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_1.default.checkCompatibilityWithExistingValue(change, existingValue); if (!isCompatible) { Logger.logAlert(logMessages_1.default.incompatibleUpdateAlert(key, 'merge', existingValueType, newValueType)); } return isCompatible; }); if (!validChanges.length) { return Promise.resolve(); } const batchedDeltaChanges = OnyxUtils_1.default.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 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}${underscore_1.default.isObject(batchedDeltaChanges) ? ` properties: ${underscore_1.default.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_1.default.removeNullValues(key, batchedDeltaChanges); // Calling "OnyxUtils.removeNullValues" removes the key from storage and cache and updates the subscriber. // Therefore, we don't need to further broadcast and update the value so we can return early. if (wasRemoved) { logMergeCall(); 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_1.default.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 = OnyxCache_1.default.hasValueChanged(key, preMergedValue); logMergeCall(hasChanged); // This approach prioritizes fast UI changes without waiting for data to be stored in device storage. const updatePromise = OnyxUtils_1.default.broadcastUpdate(key, preMergedValue, 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_1.default.mergeItem(key, batchedDeltaChanges, preMergedValue, shouldSetValue).then(() => { OnyxUtils_1.default.sendActionToDevTools(OnyxUtils_1.default.METHOD.MERGE, key, changes, preMergedValue); return updatePromise; }); } catch (error) { Logger.logAlert(`An error occurred while applying merge for key: ${key}, Error: ${error}`); return Promise.resolve(); } }); return mergeQueuePromise[key]; } /** * Merges a collection based on their keys * * @example * * Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, { * [`${ONYXKEYS.COLLECTION.REPORT}1`]: report1, * [`${ONYXKEYS.COLLECTION.REPORT}2`]: report2, * }); * * @param collectionKey e.g. `ONYXKEYS.COLLECTION.REPORT` * @param collection Object collection keyed by individual collection member keys and values */ function mergeCollection(collectionKey, collection) { if (!OnyxUtils_1.default.isValidNonEmptyCollectionForMerge(collection)) { Logger.logInfo('mergeCollection() called with invalid or empty value. Skipping this update.'); return Promise.resolve(); } let resultCollection = collection; let resultCollectionKeys = Object.keys(resultCollection); // Confirm all the collection keys belong to the same parent if (!OnyxUtils_1.default.doAllCollectionItemsBelongToSameParent(collectionKey, resultCollectionKeys)) { return Promise.resolve(); } const skippableCollectionMemberIDs = OnyxUtils_1.default.getSkippableCollectionMemberIDs(); if (skippableCollectionMemberIDs.size) { resultCollection = resultCollectionKeys.reduce((result, key) => { try { const [, collectionMemberID] = OnyxUtils_1.default.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 (_a) { // 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_1.default.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_1.default.remove(key); return false; } return true; }); const existingKeys = keys.filter((key) => persistedKeys.has(key)); const cachedCollectionForExistingKeys = OnyxUtils_1.default.getCachedCollection(collectionKey, existingKeys); const existingKeyCollection = existingKeys.reduce((obj, key) => { const { isCompatible, existingValueType, newValueType } = utils_1.default.checkCompatibilityWithExistingValue(resultCollection[key], cachedCollectionForExistingKeys[key]); if (!isCompatible) { Logger.logAlert(logMessages_1.default.incompatibleUpdateAlert(key, 'mergeCollection', existingValueType, newValueType)); return obj; } // eslint-disable-next-line no-param-reassign obj[key] = resultCollection[key]; return obj; }, {}); const newCollection = {}; 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_1.default.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_1.default.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_1.default.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_1.default.multiMerge(keyValuePairsForExistingCollection)); } if (keyValuePairsForNewCollection.length > 0) { promises.push(storage_1.default.multiSet(keyValuePairsForNewCollection)); } // finalMergedCollection contains all the keys that were merged, without the keys of incompatible updates const finalMergedCollection = Object.assign(Object.assign({}, 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) => { OnyxCache_1.default.merge(finalMergedCollection); return OnyxUtils_1.default.scheduleNotifyCollectionSubscribers(collectionKey, finalMergedCollection, previousCollection); }); return Promise.all(promises) .catch((error) => OnyxUtils_1.default.evictStorageAndRetry(error, mergeCollection, collectionKey, resultCollection)) .then(() => { OnyxUtils_1.default.sendActionToDevTools(OnyxUtils_1.default.METHOD.MERGE_COLLECTION, undefined, resultCollection); return promiseUpdate; }); }) .then(() => undefined); } /** * Clear out all the data in the store * * Note that calling Onyx.clear() and then Onyx.set() on a key with a default * key state may store an unexpected value in Storage. * * E.g. * Onyx.clear(); * Onyx.set(ONYXKEYS.DEFAULT_KEY, 'default'); * Storage.getItem(ONYXKEYS.DEFAULT_KEY) * .then((storedValue) => console.log(storedValue)); * null is logged instead of the expected 'default' * * Onyx.set() might call Storage.setItem() before Onyx.clear() calls * Storage.setItem(). Use Onyx.merge() instead if possible. Onyx.merge() calls * Onyx.get(key) before calling Storage.setItem() via Onyx.set(). * Storage.setItem() from Onyx.clear() will have already finished and the merged * value will be saved to storage after the default value. * * @param keysToPreserve is a list of ONYXKEYS that should not be cleared with the rest of the data */ function clear(keysToPreserve = []) { const defaultKeyStates = OnyxUtils_1.default.getDefaultKeyStates(); const initialKeys = Object.keys(defaultKeyStates); const promise = OnyxUtils_1.default.getAllKeys() .then((cachedKeys) => { OnyxCache_1.default.clearNullishStorageKeys(); const keysToBeClearedFromStorage = []; const keyValuesToResetAsCollection = {}; const keyValuesToResetIndividually = {}; const allKeys = new Set([...cachedKeys, ...initialKeys]); // 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) // 2.1 However, if a default key was explicitly set to null, we need to reset it to the default value allKeys.forEach((key) => { var _a; const isKeyToPreserve = keysToPreserve.includes(key); const isDefaultKey = key in defaultKeyStates; // 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 = OnyxCache_1.default.get(key); const newValue = (_a = defaultKeyStates[key]) !== null && _a !== void 0 ? _a : null; if (newValue !== oldValue) { OnyxCache_1.default.set(key, newValue); let collectionKey; try { collectionKey = OnyxUtils_1.default.getCollectionKey(key); } catch (e) { // If getCollectionKey() throws an error it means the key is not a collection key. collectionKey = undefined; } if (collectionKey) { if (!keyValuesToResetAsCollection[collectionKey]) { keyValuesToResetAsCollection[collectionKey] = {}; } keyValuesToResetAsCollection[collectionKey][key] = newValue !== null && newValue !== void 0 ? newValue : undefined; } else { keyValuesToResetIndividually[key] = newValue !== null && newValue !== void 0 ? newValue : undefined; } } } if (isKeyToPreserve || isDefaultKey) { return; } // If it isn't preserved and doesn't have a default, we'll remove it keysToBeClearedFromStorage.push(key); }); const updatePromises = []; // Notify the subscribers for each key/value group so they can receive the new values Object.entries(keyValuesToResetIndividually).forEach(([key, value]) => { updatePromises.push(OnyxUtils_1.default.scheduleSubscriberUpdate(key, value, OnyxCache_1.default.get(key, false))); }); Object.entries(keyValuesToResetAsCollection).forEach(([key, value]) => { updatePromises.push(OnyxUtils_1.default.scheduleNotifyCollectionSubscribers(key, value)); }); const defaultKeyValuePairs = Object.entries(Object.keys(defaultKeyStates) .filter((key) => !keysToPreserve.includes(key)) .reduce((obj, key) => { // eslint-disable-next-line no-param-reassign obj[key] = defaultKeyStates[key]; return obj; }, {})); // Remove only the items that we want cleared from storage, and reset others to default keysToBeClearedFromStorage.forEach((key) => OnyxCache_1.default.drop(key)); return storage_1.default.removeItems(keysToBeClearedFromStorage) .then(() => OnyxConnectionManager_1.default.refreshSessionID()) .then(() => storage_1.default.multiSet(defaultKeyValuePairs)) .then(() => { DevTools_1.default.clearState(keysToPreserve); return Promise.all(updatePromises); }); }) .then(() => undefined); return OnyxCache_1.default.captureTask(OnyxCache_1.TASK.CLEAR, promise); } /** * Insert API responses and lifecycle data into Onyx * * @param data An array of objects with update expressions * @returns resolves when all operations are complete */ function update(data) { // First, validate the Onyx object is in the format we expect data.forEach(({ onyxMethod, key, value }) => { if (!Object.values(OnyxUtils_1.default.METHOD).includes(onyxMethod)) { throw new Error(`Invalid onyxMethod ${onyxMethod} in Onyx update.`); } if (onyxMethod === OnyxUtils_1.default.METHOD.MULTI_SET) { // For multiset, we just expect the value to be an object if (typeof value !== 'object' || Array.isArray(value) || typeof value === 'function') { throw new Error('Invalid value provided in Onyx multiSet. Onyx multiSet value must be of type object.'); } } else if (onyxMethod !== OnyxUtils_1.default.METHOD.CLEAR && typeof key !== 'string') { throw new Error(`Invalid ${typeof key} key provided in Onyx update. Onyx key must be of type string.`); } }); // The queue of operations within a single `update` call in the format of <item key - list of operations updating the item>. // This allows us to batch the operations per item and merge them into one operation in the order they were requested. const updateQueue = {}; const enqueueSetOperation = (key, value) => { // If a `set` operation is enqueued, we should clear the whole queue. // Since the `set` operation replaces the value entirely, there's no need to perform any previous operations. // To do this, we first put `null` in the queue, which removes the existing value, and then merge the new value. updateQueue[key] = [null, value]; }; const enqueueMergeOperation = (key, value) => { if (value === null) { // If we merge `null`, the value is removed and all the previous operations are discarded. updateQueue[key] = [null]; } else if (!updateQueue[key]) { updateQueue[key] = [value]; } else { updateQueue[key].push(value); } }; const promises = []; let clearPromise = Promise.resolve(); data.forEach(({ onyxMethod, key, value }) => { const handlers = { [OnyxUtils_1.default.METHOD.SET]: enqueueSetOperation, [OnyxUtils_1.default.METHOD.MERGE]: enqueueMergeOperation, [OnyxUtils_1.default.METHOD.MERGE_COLLECTION]: () => { const collection = value; if (!OnyxUtils_1.default.isValidNonEmptyCollectionForMerge(collection)) { Logger.logInfo('mergeCollection enqueued within update() with invalid or empty value. Skipping this operation.'); return; } // Confirm all the collection keys belong to the same parent const collectionKeys = Object.keys(collection); if (OnyxUtils_1.default.doAllCollectionItemsBelongToSameParent(key, collectionKeys)) { const mergedCollection = collection; collectionKeys.forEach((collectionKey) => enqueueMergeOperation(collectionKey, mergedCollection[collectionKey])); } }, [OnyxUtils_1.default.METHOD.SET_COLLECTION]: (k, v) => promises.push(() => setCollection(k, v)), [OnyxUtils_1.default.METHOD.MULTI_SET]: (k, v) => Object.entries(v).forEach(([entryKey, entryValue]) => enqueueSetOperation(entryKey, entryValue)), [OnyxUtils_1.default.METHOD.CLEAR]: () => { clearPromise = clear(); }, }; handlers[onyxMethod](key, value); }); // Group all the collection-related keys and update each collection in a single `mergeCollection` call. // This is needed to prevent multiple `mergeCollection` calls for the same collection and `merge` calls for the individual items of the said collection. // This way, we ensure there is no race condition in the queued updates of the same key. OnyxUtils_1.default.getCollectionKeys().forEach((collectionKey) => { const collectionItemKeys = Object.keys(updateQueue).filter((key) => OnyxUtils_1.default.isKeyMatch(collectionKey, key)); if (collectionItemKeys.length <= 1) { // If there are no items of this collection in the updateQueue, we should skip it. // If there is only one item, we should update it individually, therefore retain it in the updateQueue. return; } const batchedCollectionUpdates = collectionItemKeys.reduce((queue, key) => { const operations = updateQueue[key]; // Remove the collection-related key from the updateQueue so that it won't be processed individually. delete updateQueue[key]; const updatedValue = OnyxUtils_1.default.applyMerge(undefined, operations, false); if (operations[0] === null) { // eslint-disable-next-line no-param-reassign queue.set[key] = updatedValue; } else { // eslint-disable-next-line no-param-reassign queue.merge[key] = updatedValue; } return queue; }, { merge: {}, set: {}, }); if (!utils_1.default.isEmptyObject(batchedCollectionUpdates.merge)) { promises.push(() => mergeCollection(collectionKey, batchedCollectionUpdates.merge)); } if (!utils_1.default.isEmptyObject(batchedCollectionUpdates.set)) { promises.push(() => multiSet(batchedCollectionUpdates.set)); } }); Object.entries(updateQueue).forEach(([key, operations]) => { const batchedChanges = OnyxUtils_1.default.applyMerge(undefined, operations, false); if (operations[0] === null) { promises.push(() => set(key, batchedChanges)); } else { promises.push(() => merge(key, batchedChanges)); } }); const snapshotPromises = OnyxUtils_1.default.updateSnapshots(data, merge); // We need to run the snapshot updates before the other updates so the snapshot data can be updated before the loading state in the snapshot const finalPromises = snapshotPromises.concat(promises); return clearPromise.then(() => Promise.all(finalPromises.map((p) => p()))).then(() => undefined); } /** * Sets a collection by replacing all existing collection members with new values. * Any existing collection members not included in the new data will be removed. * * @example * Onyx.setCollection(ONYXKEYS.COLLECTION.REPORT, { * [`${ONYXKEYS.COLLECTION.REPORT}1`]: report1, * [`${ONYXKEYS.COLLECTION.REPORT}2`]: report2, * }); * * @param collectionKey e.g. `ONYXKEYS.COLLECTION.REPORT` * @param collection Object collection keyed by individual collection member keys and values */ function setCollection(collectionKey, collection) { let resultCollection = collection; let resultCollectionKeys = Object.keys(resultCollection); // Confirm all the collection keys belong to the same parent if (!OnyxUtils_1.default.doAllCollectionItemsBelongToSameParent(collectionKey, resultCollectionKeys)) { Logger.logAlert(`setCollection called with keys that do not belong to the same parent ${collectionKey}. Skipping this update.`); return Promise.resolve(); } const skippableCollectionMemberIDs = OnyxUtils_1.default.getSkippableCollectionMemberIDs(); if (skippableCollectionMemberIDs.size) { resultCollection = resultCollectionKeys.reduce((result, key) => { try { const [, collectionMemberID] = OnyxUtils_1.default.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 (_a) { // 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_1.default.getAllKeys().then((persistedKeys) => { const mutableCollection = Object.assign({}, resultCollection); persistedKeys.forEach((key) => { if (!key.startsWith(collectionKey)) { return; } if (resultCollectionKeys.includes(key)) { return; } mutableCollection[key] = null; }); const keyValuePairs = OnyxUtils_1.default.prepareKeyValuePairsForStorage(mutableCollection, true); const previousCollection = OnyxUtils_1.default.getCachedCollection(collectionKey); keyValuePairs.forEach(([key, value]) => OnyxCache_1.default.set(key, value)); const updatePromise = OnyxUtils_1.default.scheduleNotifyCollectionSubscribers(collectionKey, mutableCollection, previousCollection); return storage_1.default.multiSet(keyValuePairs) .catch((error) => OnyxUtils_1.default.evictStorageAndRetry(error, setCollection, collectionKey, collection)) .then(() => { OnyxUtils_1.default.sendActionToDevTools(OnyxUtils_1.default.METHOD.SET_COLLECTION, undefined, mutableCollection); return updatePromise; }); }); } const Onyx = { METHOD: OnyxUtils_1.default.METHOD, connect, disconnect, set, multiSet, merge, mergeCollection, setCollection, update, clear, init, registerLogger: Logger.registerLogger, }; function applyDecorators() { // We are reassigning the functions directly so that internal function calls are also decorated /* eslint-disable rulesdir/prefer-actions-set-data */ // @ts-expect-error Reassign connect = (0, metrics_1.default)(connect, 'Onyx.connect'); // @ts-expect-error Reassign set = (0, metrics_1.default)(set, 'Onyx.set'); // @ts-expect-error Reassign multiSet = (0, metrics_1.default)(multiSet, 'Onyx.multiSet'); // @ts-expect-error Reassign merge = (0, metrics_1.default)(merge, 'Onyx.merge'); // @ts-expect-error Reassign mergeCollection = (0, metrics_1.default)(mergeCollection, 'Onyx.mergeCollection'); // @ts-expect-error Reassign update = (0, metrics_1.default)(update, 'Onyx.update'); // @ts-expect-error Reassign clear = (0, metrics_1.default)(clear, 'Onyx.clear'); /* eslint-enable rulesdir/prefer-actions-set-data */ } exports.default = Onyx;