diff --git a/lib/Onyx.ts b/lib/Onyx.ts index bdac8c89..e6c7c6a3 100644 --- a/lib/Onyx.ts +++ b/lib/Onyx.ts @@ -160,7 +160,7 @@ function disconnect(connection: Connection): void { * @param options optional configuration object */ function set(key: TKey, value: OnyxSetInput, options?: SetOptions): Promise { - return OnyxUtils.setWithRetry({key, value, options}); + return OnyxUtils.afterInit(() => OnyxUtils.setWithRetry({key, value, options})); } /** @@ -171,7 +171,7 @@ function set(key: TKey, value: OnyxSetInput, options * @param data object keyed by ONYXKEYS and the values to set */ function multiSet(data: OnyxMultiSetInput): Promise { - return OnyxUtils.multiSetWithRetry(data); + return OnyxUtils.afterInit(() => OnyxUtils.multiSetWithRetry(data)); } /** @@ -191,79 +191,81 @@ function multiSet(data: OnyxMultiSetInput): Promise { * Onyx.merge(ONYXKEYS.POLICY, {name: 'My Workspace'}); // -> {id: 1, name: 'My Workspace'} */ function merge(key: TKey, changes: OnyxMergeInput): Promise { - const skippableCollectionMemberIDs = OnyxUtils.getSkippableCollectionMemberIDs(); - if (skippableCollectionMemberIDs.size) { - try { - const [, collectionMemberID] = OnyxUtils.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; + return OnyxUtils.afterInit(() => { + const skippableCollectionMemberIDs = OnyxUtils.getSkippableCollectionMemberIDs(); + if (skippableCollectionMemberIDs.size) { + try { + const [, collectionMemberID] = OnyxUtils.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. } - } 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.getMergeQueue(); - const mergeQueuePromise = OnyxUtils.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]; + const mergeQueue = OnyxUtils.getMergeQueue(); + const mergeQueuePromise = OnyxUtils.getMergeQueuePromise(); - mergeQueuePromise[key] = OnyxUtils.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(); + // 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(); } - try { - const validChanges = mergeQueue[key].filter((change) => { - const {isCompatible, existingValueType, newValueType} = utils.checkCompatibilityWithExistingValue(change, existingValue); - if (!isCompatible) { - Logger.logAlert(logMessages.incompatibleUpdateAlert(key, 'merge', existingValueType, newValueType)); - } - return isCompatible; - }) as Array>; - - // Clean up the write queue, so we don't apply these changes again. - delete mergeQueue[key]; - delete mergeQueuePromise[key]; + // 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]; - if (!validChanges.length) { + mergeQueuePromise[key] = OnyxUtils.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(); } - // 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.remove(key); - OnyxUtils.logKeyRemoved(OnyxUtils.METHOD.MERGE, key); + try { + const validChanges = mergeQueue[key].filter((change) => { + const {isCompatible, existingValueType, newValueType} = utils.checkCompatibilityWithExistingValue(change, existingValue); + if (!isCompatible) { + Logger.logAlert(logMessages.incompatibleUpdateAlert(key, 'merge', existingValueType, newValueType)); + } + return isCompatible; + }) as Array>; + + // 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.remove(key); + OnyxUtils.logKeyRemoved(OnyxUtils.METHOD.MERGE, key); + return Promise.resolve(); + } + + return OnyxMerge.applyMerge(key, existingValue, validChanges).then(({mergedValue, updatePromise}) => { + OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.MERGE, key, changes, mergedValue); + return updatePromise; + }); + } catch (error) { + Logger.logAlert(`An error occurred while applying merge for key: ${key}, Error: ${error}`); return Promise.resolve(); } + }); - return OnyxMerge.applyMerge(key, existingValue, validChanges).then(({mergedValue, updatePromise}) => { - OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.MERGE, key, changes, mergedValue); - return updatePromise; - }); - } catch (error) { - Logger.logAlert(`An error occurred while applying merge for key: ${key}, Error: ${error}`); - return Promise.resolve(); - } + return mergeQueuePromise[key]; }); - - return mergeQueuePromise[key]; } /** @@ -280,7 +282,7 @@ function merge(key: TKey, changes: OnyxMergeInput): * @param collection Object collection keyed by individual collection member keys and values */ function mergeCollection(collectionKey: TKey, collection: OnyxMergeCollectionInput): Promise { - return OnyxUtils.mergeCollectionWithPatches({collectionKey, collection, isProcessingCollectionUpdate: true}); + return OnyxUtils.afterInit(() => OnyxUtils.mergeCollectionWithPatches({collectionKey, collection, isProcessingCollectionUpdate: true})); } /** @@ -305,106 +307,108 @@ function mergeCollection(collectionKey: TKey, co * @param keysToPreserve is a list of ONYXKEYS that should not be cleared with the rest of the data */ function clear(keysToPreserve: OnyxKey[] = []): Promise { - const defaultKeyStates = OnyxUtils.getDefaultKeyStates(); - const initialKeys = Object.keys(defaultKeyStates); - - const promise = OnyxUtils.getAllKeys() - .then((cachedKeys) => { - cache.clearNullishStorageKeys(); - - const keysToBeClearedFromStorage: OnyxKey[] = []; - const keyValuesToResetIndividually: KeyValueMapping = {}; - // 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: Record< - OnyxKey, - {oldValues: Record; newValues: Record} - > = {}; - - 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 = cache.get(key); - const newValue = defaultKeyStates[key] ?? null; - if (newValue !== oldValue) { - cache.set(key, newValue); - - let collectionKey: string | undefined; - try { - collectionKey = OnyxUtils.getCollectionKey(key); - } catch (e) { - // If getCollectionKey() throws an error it means the key is not a collection key. - collectionKey = undefined; - } + return OnyxUtils.afterInit(() => { + const defaultKeyStates = OnyxUtils.getDefaultKeyStates(); + const initialKeys = Object.keys(defaultKeyStates); + + const promise = OnyxUtils.getAllKeys() + .then((cachedKeys) => { + cache.clearNullishStorageKeys(); + + const keysToBeClearedFromStorage: OnyxKey[] = []; + const keyValuesToResetIndividually: KeyValueMapping = {}; + // 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: Record< + OnyxKey, + {oldValues: Record; newValues: Record} + > = {}; + + 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 = cache.get(key); + const newValue = defaultKeyStates[key] ?? null; + if (newValue !== oldValue) { + cache.set(key, newValue); + + let collectionKey: string | undefined; + try { + collectionKey = OnyxUtils.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] = {oldValues: {}, newValues: {}}; + if (collectionKey) { + if (!keyValuesToResetAsCollection[collectionKey]) { + keyValuesToResetAsCollection[collectionKey] = {oldValues: {}, newValues: {}}; + } + keyValuesToResetAsCollection[collectionKey].oldValues[key] = oldValue; + keyValuesToResetAsCollection[collectionKey].newValues[key] = newValue ?? undefined; + } else { + keyValuesToResetIndividually[key] = newValue ?? undefined; } - keyValuesToResetAsCollection[collectionKey].oldValues[key] = oldValue; - keyValuesToResetAsCollection[collectionKey].newValues[key] = newValue ?? undefined; - } else { - keyValuesToResetIndividually[key] = newValue ?? undefined; } } - } - - if (isKeyToPreserve || isDefaultKey) { - continue; - } - // If it isn't preserved and doesn't have a default, we'll remove it - keysToBeClearedFromStorage.push(key); - } - - const updatePromises: Array> = []; + if (isKeyToPreserve || isDefaultKey) { + continue; + } - // Notify the subscribers for each key/value group so they can receive the new values - for (const [key, value] of Object.entries(keyValuesToResetIndividually)) { - updatePromises.push(OnyxUtils.scheduleSubscriberUpdate(key, value)); - } - for (const [key, value] of Object.entries(keyValuesToResetAsCollection)) { - updatePromises.push(OnyxUtils.scheduleNotifyCollectionSubscribers(key, value.newValues, value.oldValues)); - } + // 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.isRamOnlyKey(key)) - .reduce((obj: KeyValueMapping, key) => { - // eslint-disable-next-line no-param-reassign - obj[key] = defaultKeyStates[key]; - return obj; - }, {}), - ); + const updatePromises: Array> = []; - // Remove only the items that we want cleared from storage, and reset others to default - for (const key of keysToBeClearedFromStorage) cache.drop(key); - return Storage.removeItems(keysToBeClearedFromStorage) - .then(() => connectionManager.refreshSessionID()) - .then(() => Storage.multiSet(defaultKeyValuePairs)) - .then(() => { - DevTools.clearState(keysToPreserve); - return Promise.all(updatePromises); - }); - }) - .then(() => undefined); + // Notify the subscribers for each key/value group so they can receive the new values + for (const [key, value] of Object.entries(keyValuesToResetIndividually)) { + updatePromises.push(OnyxUtils.scheduleSubscriberUpdate(key, value)); + } + for (const [key, value] of Object.entries(keyValuesToResetAsCollection)) { + updatePromises.push(OnyxUtils.scheduleNotifyCollectionSubscribers(key, value.newValues, value.oldValues)); + } - return cache.captureTask(TASK.CLEAR, promise) as Promise; + // 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.isRamOnlyKey(key)) + .reduce((obj: KeyValueMapping, 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) cache.drop(key); + return Storage.removeItems(keysToBeClearedFromStorage) + .then(() => connectionManager.refreshSessionID()) + .then(() => Storage.multiSet(defaultKeyValuePairs)) + .then(() => { + DevTools.clearState(keysToPreserve); + return Promise.all(updatePromises); + }); + }) + .then(() => undefined); + + return cache.captureTask(TASK.CLEAR, promise) as Promise; + }); } /** @@ -414,146 +418,148 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise { * @returns resolves when all operations are complete */ function update(data: Array>): Promise { - // First, validate the Onyx object is in the format we expect - for (const {onyxMethod, key, value} of data) { - if (!Object.values(OnyxUtils.METHOD).includes(onyxMethod)) { - throw new Error(`Invalid onyxMethod ${onyxMethod} in Onyx update.`); - } - if (onyxMethod === OnyxUtils.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.'); + return OnyxUtils.afterInit(() => { + // The queue of operations within a single `update` call in the format of . + // This allows us to batch the operations per item and merge them into one operation in the order they were requested. + const updateQueue: Record>> = {}; + const enqueueSetOperation = (key: OnyxKey, value: OnyxValue) => { + // 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: OnyxKey, value: OnyxValue) => { + 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); } - } else if (onyxMethod !== OnyxUtils.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 . - // This allows us to batch the operations per item and merge them into one operation in the order they were requested. - const updateQueue: Record>> = {}; - const enqueueSetOperation = (key: OnyxKey, value: OnyxValue) => { - // 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: OnyxKey, value: OnyxValue) => { - 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: Array<() => Promise> = []; - let clearPromise: Promise = Promise.resolve(); - - for (const {onyxMethod, key, value} of data) { - const handlers: Record void> = { - [OnyxUtils.METHOD.SET]: enqueueSetOperation, - [OnyxUtils.METHOD.MERGE]: enqueueMergeOperation, - [OnyxUtils.METHOD.MERGE_COLLECTION]: () => { - const collection = value as OnyxMergeCollectionInput; - if (!OnyxUtils.isValidNonEmptyCollectionForMerge(collection)) { - Logger.logInfo('mergeCollection enqueued within update() with invalid or empty value. Skipping this operation.'); - return; - } + const promises: Array<() => Promise> = []; + let clearPromise: Promise = Promise.resolve(); - // Confirm all the collection keys belong to the same parent - const collectionKeys = Object.keys(collection); - if (OnyxUtils.doAllCollectionItemsBelongToSameParent(key, collectionKeys)) { - const mergedCollection: OnyxInputKeyValueMapping = collection; - for (const collectionKey of collectionKeys) enqueueMergeOperation(collectionKey, mergedCollection[collectionKey]); - } - }, - [OnyxUtils.METHOD.SET_COLLECTION]: (k, v) => promises.push(() => setCollection(k as TKey, v as OnyxSetCollectionInput)), - [OnyxUtils.METHOD.MULTI_SET]: (k, v) => { - for (const [entryKey, entryValue] of Object.entries(v as Partial)) enqueueSetOperation(entryKey, entryValue); - }, - [OnyxUtils.METHOD.CLEAR]: () => { - clearPromise = clear(); - }, - }; + const onyxMethods = Object.values(OnyxUtils.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.METHOD.CLEAR && onyxMethod !== OnyxUtils.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; + } - handlers[onyxMethod](key, value); - } + const handlers: Record void> = { + [OnyxUtils.METHOD.SET]: enqueueSetOperation, + [OnyxUtils.METHOD.MERGE]: enqueueMergeOperation, + [OnyxUtils.METHOD.MERGE_COLLECTION]: () => { + const collection = value as OnyxMergeCollectionInput; + if (!OnyxUtils.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.doAllCollectionItemsBelongToSameParent(key, collectionKeys)) { + const mergedCollection: OnyxInputKeyValueMapping = collection; + for (const collectionKey of collectionKeys) enqueueMergeOperation(collectionKey, mergedCollection[collectionKey]); + } + }, + [OnyxUtils.METHOD.SET_COLLECTION]: (k, v) => promises.push(() => setCollection(k as TKey, v as OnyxSetCollectionInput)), + [OnyxUtils.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; + } - // 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.getCollectionKeys()) { - const collectionItemKeys = Object.keys(updateQueue).filter((key) => OnyxUtils.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; + for (const [entryKey, entryValue] of Object.entries(v as Partial)) enqueueSetOperation(entryKey, entryValue); + }, + [OnyxUtils.METHOD.CLEAR]: () => { + clearPromise = clear(); + }, + }; + + handlers[onyxMethod](key, value); } - const batchedCollectionUpdates = collectionItemKeys.reduce( - (queue: MixedOperationsQueue, key: string) => { - const operations = updateQueue[key]; + // 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.getCollectionKeys()) { + const collectionItemKeys = Object.keys(updateQueue).filter((key) => OnyxUtils.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; + } - // Remove the collection-related key from the updateQueue so that it won't be processed individually. - delete updateQueue[key]; + const batchedCollectionUpdates = collectionItemKeys.reduce( + (queue: MixedOperationsQueue, key: string) => { + const operations = updateQueue[key]; - const batchedChanges = OnyxUtils.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) { + // Remove the collection-related key from the updateQueue so that it won't be processed individually. + delete updateQueue[key]; + + const batchedChanges = OnyxUtils.mergeAndMarkChanges(operations); + if (operations[0] === null) { // eslint-disable-next-line no-param-reassign - queue.mergeReplaceNullPatches[key] = batchedChanges.replaceNullPatches; + 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.isEmptyObject(batchedCollectionUpdates.merge)) { - promises.push(() => - OnyxUtils.mergeCollectionWithPatches({ - collectionKey, - collection: batchedCollectionUpdates.merge as OnyxMergeCollectionInput, - mergeReplaceNullPatches: batchedCollectionUpdates.mergeReplaceNullPatches, - isProcessingCollectionUpdate: true, - }), + return queue; + }, + { + merge: {}, + mergeReplaceNullPatches: {}, + set: {}, + }, ); - } - if (!utils.isEmptyObject(batchedCollectionUpdates.set)) { - promises.push(() => OnyxUtils.partialSetCollection({collectionKey, collection: batchedCollectionUpdates.set as OnyxSetCollectionInput})); - } - } - for (const [key, operations] of Object.entries(updateQueue)) { - if (operations[0] === null) { - const batchedChanges = OnyxUtils.mergeChanges(operations).result; - promises.push(() => set(key, batchedChanges)); - continue; + if (!utils.isEmptyObject(batchedCollectionUpdates.merge)) { + promises.push(() => + OnyxUtils.mergeCollectionWithPatches({ + collectionKey, + collection: batchedCollectionUpdates.merge as OnyxMergeCollectionInput, + mergeReplaceNullPatches: batchedCollectionUpdates.mergeReplaceNullPatches, + isProcessingCollectionUpdate: true, + }), + ); + } + if (!utils.isEmptyObject(batchedCollectionUpdates.set)) { + promises.push(() => OnyxUtils.partialSetCollection({collectionKey, collection: batchedCollectionUpdates.set as OnyxSetCollectionInput})); + } } - for (const operation of operations) { - promises.push(() => merge(key, operation)); + for (const [key, operations] of Object.entries(updateQueue)) { + if (operations[0] === null) { + const batchedChanges = OnyxUtils.mergeChanges(operations).result; + promises.push(() => set(key, batchedChanges)); + continue; + } + + for (const operation of operations) { + promises.push(() => merge(key, operation)); + } } - } - const snapshotPromises = OnyxUtils.updateSnapshots(data, merge); + const snapshotPromises = OnyxUtils.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); + // 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); + return clearPromise.then(() => Promise.all(finalPromises.map((p) => p()))).then(() => undefined); + }); } /** @@ -570,7 +576,7 @@ function update(data: Array>): Promise(collectionKey: TKey, collection: OnyxSetCollectionInput): Promise { - return OnyxUtils.setCollectionWithRetry({collectionKey, collection}); + return OnyxUtils.afterInit(() => OnyxUtils.setCollectionWithRetry({collectionKey, collection})); } const Onyx = { diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 446e7bd2..01553a3e 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -137,6 +137,21 @@ function getDeferredInitTask(): DeferredTask { return deferredInitTask; } +/** + * Executes an action after Onyx has been initialized. + * If Onyx is already initialized, the action is executed immediately. + * Otherwise, it waits for initialization to complete before executing. + * + * @param action The action to execute after initialization + * @returns The result of the action + */ +function afterInit(action: () => Promise): Promise { + if (deferredInitTask.isResolved) { + return action(); + } + return deferredInitTask.promise.then(action); +} + /** * Getter - returns the skippable collection member IDs. */ @@ -1749,6 +1764,7 @@ const OnyxUtils = { getMergeQueuePromise, getDefaultKeyStates, getDeferredInitTask, + afterInit, initStoreValues, sendActionToDevTools, get, diff --git a/lib/createDeferredTask.ts b/lib/createDeferredTask.ts index e697e1a7..61e41f7b 100644 --- a/lib/createDeferredTask.ts +++ b/lib/createDeferredTask.ts @@ -1,6 +1,7 @@ type DeferredTask = { promise: Promise; - resolve?: () => void; + resolve: () => void; + isResolved: boolean; }; /** @@ -9,11 +10,16 @@ type DeferredTask = { * Useful when we want to wait for a tasks that is resolved from an external action */ export default function createDeferredTask(): DeferredTask { - const deferred = {} as DeferredTask; + const {promise, resolve: originalResolve} = Promise.withResolvers(); - deferred.promise = new Promise((res) => { - deferred.resolve = res; - }); + const deferred: DeferredTask = { + promise, + resolve: () => { + deferred.isResolved = true; + originalResolve(); + }, + isResolved: false, + }; return deferred; } diff --git a/tests/unit/onyxTest.ts b/tests/unit/onyxTest.ts index 77842a9c..f66b0dc7 100644 --- a/tests/unit/onyxTest.ts +++ b/tests/unit/onyxTest.ts @@ -1,6 +1,8 @@ import lodashClone from 'lodash/clone'; import lodashCloneDeep from 'lodash/cloneDeep'; +import {act} from '@testing-library/react-native'; import Onyx from '../../lib'; +import * as Logger from '../../lib/Logger'; import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; import OnyxUtils from '../../lib/OnyxUtils'; import type OnyxCache from '../../lib/OnyxCache'; @@ -9,6 +11,7 @@ import type {OnyxCollection, OnyxKey, OnyxUpdate} from '../../lib/types'; import type {GenericDeepRecord} from '../types'; import type GenericCollection from '../utils/GenericCollection'; import type {Connection} from '../../lib/OnyxConnectionManager'; +import createDeferredTask from '../../lib/createDeferredTask'; const ONYX_KEYS = { TEST_KEY: 'test', @@ -30,22 +33,23 @@ const ONYX_KEYS = { RAM_ONLY_WITH_INITIAL_VALUE: 'ramOnlyWithInitialValue', }; -Onyx.init({ - keys: ONYX_KEYS, - initialKeyStates: { - [ONYX_KEYS.OTHER_TEST]: 42, - [ONYX_KEYS.KEY_WITH_UNDERSCORE]: 'default', - [ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE]: 'default', - }, - ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], - skippableCollectionMemberIDs: ['skippable-id'], - snapshotMergeKeys: ['pendingAction', 'pendingFields'], -}); - describe('Onyx', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYX_KEYS, + initialKeyStates: { + [ONYX_KEYS.OTHER_TEST]: 42, + [ONYX_KEYS.KEY_WITH_UNDERSCORE]: 'default', + [ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE]: 'default', + }, + ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + skippableCollectionMemberIDs: ['skippable-id'], + snapshotMergeKeys: ['pendingAction', 'pendingFields'], + }); + }); + let connection: Connection | undefined; - /** @type OnyxCache */ let cache: typeof OnyxCache; beforeEach(() => { @@ -934,43 +938,6 @@ describe('Onyx', () => { }); }); - it('should throw an error when the data object is incorrect in Onyx.update', () => { - // Given the invalid data object with onyxMethod='multiSet' - const data: unknown[] = [ - {onyxMethod: 'set', key: ONYX_KEYS.TEST_KEY, value: 'four'}, - {onyxMethod: 'murge', key: ONYX_KEYS.OTHER_TEST, value: {test2: 'test2'}}, - ]; - - try { - // When we pass it to Onyx.update - // @ts-expect-error This is an invalid call to Onyx.update - Onyx.update(data); - } catch (error) { - if (error instanceof Error) { - // Then we should expect the error message below - expect(error.message).toEqual('Invalid onyxMethod murge in Onyx update.'); - } else { - throw error; - } - } - - try { - // Given the invalid data object with key=true - data[1] = {onyxMethod: 'merge', key: true, value: {test2: 'test2'}}; - - // When we pass it to Onyx.update - // @ts-expect-error This is an invalid call to Onyx.update - Onyx.update(data); - } catch (error) { - if (error instanceof Error) { - // Then we should expect the error message below - expect(error.message).toEqual('Invalid boolean key provided in Onyx update. Onyx key must be of type string.'); - } else { - throw error; - } - } - }); - it('should properly set all keys provided in a multiSet called via update', () => { const valuesReceived: Record = {}; connection = Onyx.connect({ @@ -1018,32 +985,6 @@ describe('Onyx', () => { }); }); - it('should reject an improperly formatted multiset operation called via update', () => { - try { - Onyx.update([ - { - onyxMethod: 'multiset', - value: [ - { - ID: 123, - value: 'one', - }, - { - ID: 234, - value: 'two', - }, - ], - }, - ] as unknown as Array>); - } catch (error) { - if (error instanceof Error) { - expect(error.message).toEqual('Invalid value provided in Onyx multiSet. Onyx multiSet value must be of type object.'); - } else { - throw error; - } - } - }); - it('should return all collection keys as a single object when waitForCollectionCallback = true', () => { const mockCallback = jest.fn(); @@ -1614,6 +1555,17 @@ describe('Onyx', () => { }); describe('update', () => { + let logInfoFn = jest.fn(); + + beforeEach(() => { + logInfoFn = jest.fn(); + jest.spyOn(Logger, 'logInfo').mockImplementation(logInfoFn); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + it('should squash all updates of collection-related keys into a single mergeCollection call', () => { const connections: Connection[] = []; @@ -2426,6 +2378,53 @@ describe('Onyx', () => { expect(await StorageMock.getItem(`${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`)).toBeNull(); expect(await StorageMock.getItem(`${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}2`)).toBeNull(); }); + + describe('should log and skip invalid operations', () => { + it('invalid method', async () => { + await act(async () => + Onyx.update([ + {onyxMethod: 'set', key: ONYX_KEYS.TEST_KEY, value: 'test1'}, + // @ts-expect-error invalid method + {onyxMethod: 'invalidMethod', key: ONYX_KEYS.OTHER_TEST, value: 'test2'}, + ]), + ); + + expect(logInfoFn).toHaveBeenNthCalledWith(1, 'Invalid onyxMethod invalidMethod in Onyx update. Skipping this operation.'); + }); + + it('non-object value passed to multiSet', async () => { + await act(async () => + Onyx.update([ + // @ts-expect-error non-object value + {onyxMethod: 'multiset', key: ONYX_KEYS.TEST_KEY, value: []}, + ]), + ); + + expect(logInfoFn).toHaveBeenNthCalledWith(1, 'Invalid value provided in Onyx multiSet. Value must be of type object. Skipping this operation.'); + }); + + it('non-string value passed to key', async () => { + await act(async () => + Onyx.update([ + // @ts-expect-error invalid key + {onyxMethod: 'set', key: 1000, value: 'test'}, + ]), + ); + + expect(logInfoFn).toHaveBeenNthCalledWith(1, 'Invalid number key provided in Onyx update. Key must be of type string. Skipping this operation.'); + }); + + it('invalid or empty value passed to mergeCollection', async () => { + await act(async () => + Onyx.update([ + // @ts-expect-error invalid value + {onyxMethod: 'mergecollection', key: ONYX_KEYS.COLLECTION.TEST_KEY, value: 'test1'}, + ]), + ); + + expect(logInfoFn).toHaveBeenNthCalledWith(1, 'Invalid or empty value provided in Onyx mergeCollection. Skipping this operation.'); + }); + }); }); describe('merge', () => { @@ -3008,3 +3007,108 @@ describe('Onyx', () => { }); }); }); + +// Separate describe block for Onyx.init to control initialization during each test. +describe('Onyx.init', () => { + let cache: typeof OnyxCache; + + beforeEach(() => { + // Resets the deferred init task before each test. + Object.assign(OnyxUtils.getDeferredInitTask(), createDeferredTask()); + cache = require('../../lib/OnyxCache').default; + }); + + afterEach(() => { + jest.restoreAllMocks(); + return Onyx.clear(); + }); + + describe('should only execute Onyx methods after initialization', () => { + it('set', async () => { + Onyx.set(ONYX_KEYS.TEST_KEY, 'test'); + await act(async () => waitForPromisesToResolve()); + + expect(cache.get(ONYX_KEYS.TEST_KEY)).toBeUndefined(); + + Onyx.init({keys: ONYX_KEYS}); + await act(async () => waitForPromisesToResolve()); + + expect(cache.get(ONYX_KEYS.TEST_KEY)).toEqual('test'); + }); + + it('multiSet', async () => { + Onyx.multiSet({[`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: 'test_1'}); + await act(async () => waitForPromisesToResolve()); + + expect(cache.get(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`)).toBeUndefined(); + + Onyx.init({keys: ONYX_KEYS}); + await act(async () => waitForPromisesToResolve()); + + expect(cache.get(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`)).toEqual('test_1'); + }); + + it('merge', async () => { + Onyx.merge(ONYX_KEYS.TEST_KEY, 'test'); + await act(async () => waitForPromisesToResolve()); + + expect(cache.get(ONYX_KEYS.TEST_KEY)).toBeUndefined(); + + Onyx.init({keys: ONYX_KEYS}); + await act(async () => waitForPromisesToResolve()); + + expect(cache.get(ONYX_KEYS.TEST_KEY)).toEqual('test'); + }); + + it('mergeCollection', async () => { + Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, {[`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: 'test_1'}); + await act(async () => waitForPromisesToResolve()); + + expect(cache.get(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`)).toBeUndefined(); + + Onyx.init({keys: ONYX_KEYS}); + await act(async () => waitForPromisesToResolve()); + + expect(cache.get(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`)).toEqual('test_1'); + }); + + it('clear', async () => { + // Spies on a function that is exclusively called during Onyx.clear(). + const spyClearNullishStorageKeys = jest.spyOn(cache, 'clearNullishStorageKeys'); + + Onyx.clear(); + await act(async () => waitForPromisesToResolve()); + + expect(spyClearNullishStorageKeys).not.toHaveBeenCalled(); + + Onyx.init({keys: ONYX_KEYS}); + await act(async () => waitForPromisesToResolve()); + + expect(spyClearNullishStorageKeys).toHaveBeenCalled(); + }); + + it('update', async () => { + Onyx.update([{onyxMethod: 'set', key: ONYX_KEYS.TEST_KEY, value: 'test'}]); + await act(async () => waitForPromisesToResolve()); + + expect(cache.get(ONYX_KEYS.TEST_KEY)).toBeUndefined(); + + Onyx.init({keys: ONYX_KEYS}); + await act(async () => waitForPromisesToResolve()); + + expect(cache.get(ONYX_KEYS.TEST_KEY)).toEqual('test'); + }); + + it('setCollection', async () => { + Onyx.setCollection(ONYX_KEYS.COLLECTION.TEST_KEY, {[`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: 'test_1'}); + await act(async () => waitForPromisesToResolve()); + + expect(cache.get(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`)).toBeUndefined(); + + Onyx.init({keys: ONYX_KEYS}); + await act(async () => waitForPromisesToResolve()); + + expect(cache.get(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`)).toEqual('test_1'); + }); + }); +}); diff --git a/tests/unit/onyxUtilsTest.ts b/tests/unit/onyxUtilsTest.ts index 3448c6ad..980e5c8b 100644 --- a/tests/unit/onyxUtilsTest.ts +++ b/tests/unit/onyxUtilsTest.ts @@ -1,3 +1,4 @@ +import {act} from '@testing-library/react-native'; import Onyx from '../../lib'; import OnyxUtils from '../../lib/OnyxUtils'; import type {GenericDeepRecord} from '../types'; @@ -5,6 +6,8 @@ import utils from '../../lib/utils'; import type {Collection, OnyxCollection} from '../../lib/types'; import type GenericCollection from '../utils/GenericCollection'; import StorageMock from '../../lib/storage'; +import createDeferredTask from '../../lib/createDeferredTask'; +import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; const testObject: GenericDeepRecord = { a: 'a', @@ -79,16 +82,18 @@ const ONYXKEYS = { RAM_ONLY_KEY: 'ramOnlyKey', }; -Onyx.init({ - keys: ONYXKEYS, - ramOnlyKeys: [ONYXKEYS.RAM_ONLY_KEY, ONYXKEYS.COLLECTION.RAM_ONLY_COLLECTION], -}); +describe('OnyxUtils', () => { + beforeAll(() => + Onyx.init({ + keys: ONYXKEYS, + ramOnlyKeys: [ONYXKEYS.RAM_ONLY_KEY, ONYXKEYS.COLLECTION.RAM_ONLY_COLLECTION], + }), + ); -beforeEach(() => Onyx.clear()); + beforeEach(() => Onyx.clear()); -afterEach(() => jest.clearAllMocks()); + afterEach(() => jest.clearAllMocks()); -describe('OnyxUtils', () => { describe('splitCollectionMemberKey', () => { describe('should return correct values', () => { const dataResult: Record = { @@ -516,4 +521,42 @@ describe('OnyxUtils', () => { expect(OnyxUtils.isRamOnlyKey(`${ONYXKEYS.COLLECTION.TEST_KEY}1`)).toBeFalsy(); }); }); + + describe('afterInit', () => { + beforeEach(() => { + // Resets the deferred init task before each test. + Object.assign(OnyxUtils.getDeferredInitTask(), createDeferredTask()); + }); + + afterEach(() => { + jest.restoreAllMocks(); + return Onyx.clear(); + }); + + it('should execute the callback immediately if Onyx is already initialized', async () => { + Onyx.init({keys: ONYXKEYS}); + await act(async () => waitForPromisesToResolve()); + + const callback = jest.fn(); + OnyxUtils.afterInit(callback); + + await act(async () => waitForPromisesToResolve()); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should only execute the callback after Onyx initialization', async () => { + const callback = jest.fn(); + OnyxUtils.afterInit(callback); + + await act(async () => waitForPromisesToResolve()); + + expect(callback).not.toHaveBeenCalled(); + + Onyx.init({keys: ONYXKEYS}); + await act(async () => waitForPromisesToResolve()); + + expect(callback).toHaveBeenCalledTimes(1); + }); + }); });