UNPKG

react-native-onyx

Version:

State management for React Native

557 lines (556 loc) 29 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 () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __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 }); const Logger = __importStar(require("./Logger")); const OnyxCache_1 = __importStar(require("./OnyxCache")); const storage_1 = __importDefault(require("./storage")); const utils_1 = __importDefault(require("./utils")); const DevTools_1 = __importStar(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")); const OnyxMerge_1 = __importDefault(require("./OnyxMerge")); /** Initialize the store with actions and listening for storage events */ function init({ keys = {}, initialKeyStates = {}, evictableKeys = [], maxCachedKeysCount = 1000, shouldSyncMultipleInstances = !!global.localStorage, enablePerformanceMetrics = false, enableDevTools = true, skippableCollectionMemberIDs = [], ramOnlyKeys = [], snapshotMergeKeys = [], }) { var _a; if (enablePerformanceMetrics) { GlobalSettings.setPerformanceMetricsEnabled(true); applyDecorators(); } (0, DevTools_1.initDevTools)(enableDevTools); storage_1.default.init(); OnyxUtils_1.default.setSkippableCollectionMemberIDs(new Set(skippableCollectionMemberIDs)); OnyxUtils_1.default.setSnapshotMergeKeys(new Set(snapshotMergeKeys)); OnyxCache_1.default.setRamOnlyKeys(new Set(ramOnlyKeys)); if (shouldSyncMultipleInstances) { (_a = storage_1.default.keepInstancesSync) === null || _a === void 0 ? void 0 : _a.call(storage_1.default, (key, value) => { // RAM-only keys should never sync from storage as they may have stale persisted data // from before the key was migrated to RAM-only. if (OnyxUtils_1.default.isRamOnlyKey(key)) { return; } OnyxCache_1.default.set(key, value); // Check if this is a collection member key to prevent duplicate callbacks // When a collection is updated, individual members sync separately to other tabs // Setting isProcessingCollectionUpdate=true prevents triggering collection callbacks for each individual update const isKeyCollectionMember = OnyxUtils_1.default.isCollectionMember(key); OnyxUtils_1.default.keyChanged(key, value, undefined, isKeyCollectionMember); }); } 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. * This method will be deprecated soon. Please use `Onyx.connectWithoutView()` instead. * * @example * ```ts * const connection = Onyx.connectWithoutView({ * 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.selector This will be used to subscribe to a subset of an Onyx key's data. **Only used inside `useOnyx()` hook.** * Using this setting on `useOnyx()` 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); } /** * Connects to an Onyx key given the options passed and listens to its changes. * * @example * ```ts * const connection = Onyx.connectWithoutView({ * 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.selector This will be used to subscribe to a subset of an Onyx key's data. **Only used inside `useOnyx()` hook.** * Using this setting on `useOnyx()` 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 connectWithoutView(connectOptions) { return OnyxConnectionManager_1.default.connect(connectOptions); } /** * Disconnects and removes the listener from the Onyx key. * * @example * ```ts * const connection = Onyx.connectWithoutView({ * key: ONYXKEYS.SESSION, * callback: onSessionChange, * }); * * Onyx.disconnect(connection); * ``` * * @param connection Connection object returned by calling `Onyx.connect()` or `Onyx.connectWithoutView()`. */ 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 * @param options optional configuration object */ function set(key, value, options) { return OnyxUtils_1.default.afterInit(() => OnyxUtils_1.default.setWithRetry({ key, value, options })); } /** * 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) { return OnyxUtils_1.default.afterInit(() => OnyxUtils_1.default.multiSetWithRetry(data)); } /** * 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) { return OnyxUtils_1.default.afterInit(() => { 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 { 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; }); // Clean up the write queue, so we don't apply these changes again. delete mergeQueue[key]; delete mergeQueuePromise[key]; if (!validChanges.length) { return Promise.resolve(); } // 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 (validChanges.at(-1) === null) { OnyxUtils_1.default.remove(key); OnyxUtils_1.default.logKeyRemoved(OnyxUtils_1.default.METHOD.MERGE, key); return Promise.resolve(); } return OnyxMerge_1.default.applyMerge(key, existingValue, validChanges).then(({ mergedValue }) => { OnyxUtils_1.default.sendActionToDevTools(OnyxUtils_1.default.METHOD.MERGE, key, changes, mergedValue); }); } 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) { return OnyxUtils_1.default.afterInit(() => OnyxUtils_1.default.mergeCollectionWithPatches({ collectionKey, collection, isProcessingCollectionUpdate: true })); } /** * 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 = []) { return OnyxUtils_1.default.afterInit(() => { const defaultKeyStates = OnyxUtils_1.default.getDefaultKeyStates(); const initialKeys = Object.keys(defaultKeyStates); const promise = OnyxUtils_1.default.getAllKeys() .then((cachedKeys) => { var _a; OnyxCache_1.default.clearNullishStorageKeys(); const keysToBeClearedFromStorage = []; const keyValuesToResetIndividually = {}; // We need to store old and new values for collection keys to properly notify subscribers when clearing Onyx // because the notification process needs the old values in cache but at that point they will be already removed from it. const keyValuesToResetAsCollection = {}; 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 for (const key of allKeys) { 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); const collectionKey = OnyxUtils_1.default.getCollectionKey(key); if (collectionKey) { if (!keyValuesToResetAsCollection[collectionKey]) { keyValuesToResetAsCollection[collectionKey] = { oldValues: {}, newValues: {} }; } keyValuesToResetAsCollection[collectionKey].oldValues[key] = oldValue; keyValuesToResetAsCollection[collectionKey].newValues[key] = newValue !== null && newValue !== void 0 ? newValue : undefined; } else { keyValuesToResetIndividually[key] = newValue !== null && newValue !== void 0 ? newValue : undefined; } } } if (isKeyToPreserve || isDefaultKey) { continue; } // If it isn't preserved and doesn't have a default, we'll remove it keysToBeClearedFromStorage.push(key); } // Exclude RAM-only keys to prevent them from being saved to storage const defaultKeyValuePairs = Object.entries(Object.keys(defaultKeyStates) .filter((key) => !keysToPreserve.includes(key) && !OnyxUtils_1.default.isRamOnlyKey(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 for (const key of keysToBeClearedFromStorage) 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); // Notify the subscribers for each key/value group so they can receive the new values for (const [key, value] of Object.entries(keyValuesToResetIndividually)) { OnyxUtils_1.default.keyChanged(key, value); } for (const [key, value] of Object.entries(keyValuesToResetAsCollection)) { OnyxUtils_1.default.keysChanged(key, value.newValues, value.oldValues); } }); }) .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) { return OnyxUtils_1.default.afterInit(() => { // 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(); const onyxMethods = Object.values(OnyxUtils_1.default.METHOD); for (const { onyxMethod, key, value } of data) { if (!onyxMethods.includes(onyxMethod)) { Logger.logInfo(`Invalid onyxMethod ${onyxMethod} in Onyx update. Skipping this operation.`); continue; } if (onyxMethod !== OnyxUtils_1.default.METHOD.CLEAR && onyxMethod !== OnyxUtils_1.default.METHOD.MULTI_SET && typeof key !== 'string') { Logger.logInfo(`Invalid ${typeof key} key provided in Onyx update. Key must be of type string. Skipping this operation.`); continue; } 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('Invalid or empty value provided in Onyx mergeCollection. 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; for (const collectionKey of collectionKeys) 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) => { if (typeof value !== 'object' || Array.isArray(value) || typeof value === 'function') { Logger.logInfo(`Invalid value provided in Onyx multiSet. Value must be of type object. Skipping this operation.`); return; } for (const [entryKey, entryValue] of Object.entries(v)) 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. for (const collectionKey of OnyxUtils_1.default.getCollectionKeys()) { 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. continue; } 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 batchedChanges = OnyxUtils_1.default.mergeAndMarkChanges(operations); if (operations[0] === null) { // eslint-disable-next-line no-param-reassign queue.set[key] = batchedChanges.result; } else { // eslint-disable-next-line no-param-reassign 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_1.default.isEmptyObject(batchedCollectionUpdates.merge)) { promises.push(() => OnyxUtils_1.default.mergeCollectionWithPatches({ collectionKey, collection: batchedCollectionUpdates.merge, mergeReplaceNullPatches: batchedCollectionUpdates.mergeReplaceNullPatches, isProcessingCollectionUpdate: true, })); } if (!utils_1.default.isEmptyObject(batchedCollectionUpdates.set)) { promises.push(() => OnyxUtils_1.default.partialSetCollection({ collectionKey, collection: batchedCollectionUpdates.set })); } } for (const [key, operations] of Object.entries(updateQueue)) { if (operations[0] === null) { const batchedChanges = OnyxUtils_1.default.mergeChanges(operations).result; promises.push(() => set(key, batchedChanges)); continue; } for (const operation of operations) { promises.push(() => merge(key, operation)); } } 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) { return OnyxUtils_1.default.afterInit(() => OnyxUtils_1.default.setCollectionWithRetry({ collectionKey, collection })); } const Onyx = { METHOD: OnyxUtils_1.default.METHOD, connect, connectWithoutView, 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 // @ts-expect-error Reassign connect = (0, metrics_1.default)(connect, 'Onyx.connect'); // @ts-expect-error Reassign connectWithoutView = (0, metrics_1.default)(connectWithoutView, 'Onyx.connectWithoutView'); // @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'); } exports.default = Onyx;