From e176ab0db36a0c027994101c41b1893aa22d7686 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Thu, 29 Jan 2026 18:05:11 -0800 Subject: [PATCH 01/10] Wait for deferred initialization before Onyx operations to ensure proper ordering --- lib/Onyx.ts | 380 ++++++++++++++++++++++++++-------------------------- 1 file changed, 193 insertions(+), 187 deletions(-) diff --git a/lib/Onyx.ts b/lib/Onyx.ts index 15991694..c5ef70ac 100644 --- a/lib/Onyx.ts +++ b/lib/Onyx.ts @@ -155,7 +155,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.getDeferredInitTask().promise.then(() => OnyxUtils.setWithRetry({key, value, options})); } /** @@ -166,7 +166,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.getDeferredInitTask().promise.then(() => OnyxUtils.multiSetWithRetry(data)); } /** @@ -186,79 +186,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.getDeferredInitTask().promise.then(() => { + 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]; } /** @@ -275,7 +277,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.getDeferredInitTask().promise.then(() => OnyxUtils.mergeCollectionWithPatches({collectionKey, collection, isProcessingCollectionUpdate: true})); } /** @@ -300,10 +302,11 @@ 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); + return OnyxUtils.getDeferredInitTask().promise.then(() => { + const defaultKeyStates = OnyxUtils.getDefaultKeyStates(); + const initialKeys = Object.keys(defaultKeyStates); - const promise = OnyxUtils.getAllKeys() + const promise = OnyxUtils.getAllKeys() .then((cachedKeys) => { cache.clearNullishStorageKeys(); @@ -398,7 +401,8 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise { }) .then(() => undefined); - return cache.captureTask(TASK.CLEAR, promise) as Promise; + return cache.captureTask(TASK.CLEAR, promise) as Promise; + }); } /** @@ -408,146 +412,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.getDeferredInitTask().promise.then(() => { + // 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.`); } - } 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; + 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.'); } + } 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.`); + } + } - // 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(); - }, + // 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); + } }; - handlers[onyxMethod](key, 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; + } - // 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; + // 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(); + }, + }; + + 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); + }); } /** @@ -564,7 +570,7 @@ function update(data: Array>): Promise(collectionKey: TKey, collection: OnyxSetCollectionInput): Promise { - return OnyxUtils.setCollectionWithRetry({collectionKey, collection}); + return OnyxUtils.getDeferredInitTask().promise.then(() => OnyxUtils.setCollectionWithRetry({collectionKey, collection})); } const Onyx = { From fd8dc85d5de576cf3fd8d4814d2b1357bf13b706 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Thu, 29 Jan 2026 18:22:20 -0800 Subject: [PATCH 02/10] run prettier --- lib/Onyx.ts | 166 ++++++++++++++++++++++++++-------------------------- 1 file changed, 83 insertions(+), 83 deletions(-) diff --git a/lib/Onyx.ts b/lib/Onyx.ts index c5ef70ac..cb053dab 100644 --- a/lib/Onyx.ts +++ b/lib/Onyx.ts @@ -307,99 +307,99 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise { 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; - } + .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 (isKeyToPreserve || isDefaultKey) { + continue; + } - // If it isn't preserved and doesn't have a default, we'll remove it - keysToBeClearedFromStorage.push(key); - } + // If it isn't preserved and doesn't have a default, we'll remove it + keysToBeClearedFromStorage.push(key); + } - const updatePromises: Array> = []; + const updatePromises: Array> = []; - // 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)); - } + // 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)); + } - const defaultKeyValuePairs = Object.entries( - Object.keys(defaultKeyStates) - .filter((key) => !keysToPreserve.includes(key)) - .reduce((obj: KeyValueMapping, key) => { - // eslint-disable-next-line no-param-reassign - obj[key] = defaultKeyStates[key]; - return obj; - }, {}), - ); + const defaultKeyValuePairs = Object.entries( + Object.keys(defaultKeyStates) + .filter((key) => !keysToPreserve.includes(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); + // 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; }); From 772918fc8d6af5dad9ec7eb6165185577944ddf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Mon, 2 Feb 2026 18:00:51 +0000 Subject: [PATCH 03/10] Execute Onyx methods directly if Onyx initialization is already done --- lib/Onyx.ts | 46 ++++++++++++++++++++++++++++++++++----- lib/createDeferredTask.ts | 7 +++++- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/lib/Onyx.ts b/lib/Onyx.ts index cb053dab..75fc834b 100644 --- a/lib/Onyx.ts +++ b/lib/Onyx.ts @@ -155,6 +155,10 @@ function disconnect(connection: Connection): void { * @param options optional configuration object */ function set(key: TKey, value: OnyxSetInput, options?: SetOptions): Promise { + if (OnyxUtils.getDeferredInitTask().isResolved) { + return OnyxUtils.setWithRetry({key, value, options}); + } + return OnyxUtils.getDeferredInitTask().promise.then(() => OnyxUtils.setWithRetry({key, value, options})); } @@ -166,6 +170,10 @@ function set(key: TKey, value: OnyxSetInput, options * @param data object keyed by ONYXKEYS and the values to set */ function multiSet(data: OnyxMultiSetInput): Promise { + if (OnyxUtils.getDeferredInitTask().isResolved) { + return OnyxUtils.multiSetWithRetry(data); + } + return OnyxUtils.getDeferredInitTask().promise.then(() => OnyxUtils.multiSetWithRetry(data)); } @@ -186,7 +194,7 @@ function multiSet(data: OnyxMultiSetInput): Promise { * Onyx.merge(ONYXKEYS.POLICY, {name: 'My Workspace'}); // -> {id: 1, name: 'My Workspace'} */ function merge(key: TKey, changes: OnyxMergeInput): Promise { - return OnyxUtils.getDeferredInitTask().promise.then(() => { + const mergeOperation = () => { const skippableCollectionMemberIDs = OnyxUtils.getSkippableCollectionMemberIDs(); if (skippableCollectionMemberIDs.size) { try { @@ -260,7 +268,13 @@ function merge(key: TKey, changes: OnyxMergeInput): }); return mergeQueuePromise[key]; - }); + }; + + if (OnyxUtils.getDeferredInitTask().isResolved) { + return mergeOperation(); + } + + return OnyxUtils.getDeferredInitTask().promise.then(mergeOperation); } /** @@ -277,6 +291,10 @@ function merge(key: TKey, changes: OnyxMergeInput): * @param collection Object collection keyed by individual collection member keys and values */ function mergeCollection(collectionKey: TKey, collection: OnyxMergeCollectionInput): Promise { + if (OnyxUtils.getDeferredInitTask().isResolved) { + return OnyxUtils.mergeCollectionWithPatches({collectionKey, collection, isProcessingCollectionUpdate: true}); + } + return OnyxUtils.getDeferredInitTask().promise.then(() => OnyxUtils.mergeCollectionWithPatches({collectionKey, collection, isProcessingCollectionUpdate: true})); } @@ -302,7 +320,7 @@ 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 { - return OnyxUtils.getDeferredInitTask().promise.then(() => { + const clearOperation = () => { const defaultKeyStates = OnyxUtils.getDefaultKeyStates(); const initialKeys = Object.keys(defaultKeyStates); @@ -402,7 +420,13 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise { .then(() => undefined); return cache.captureTask(TASK.CLEAR, promise) as Promise; - }); + }; + + if (OnyxUtils.getDeferredInitTask().isResolved) { + return clearOperation(); + } + + return OnyxUtils.getDeferredInitTask().promise.then(clearOperation); } /** @@ -412,7 +436,7 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise { * @returns resolves when all operations are complete */ function update(data: Array>): Promise { - return OnyxUtils.getDeferredInitTask().promise.then(() => { + const updateOperation = () => { // 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)) { @@ -553,7 +577,13 @@ function update(data: Array>): Promise Promise.all(finalPromises.map((p) => p()))).then(() => undefined); - }); + }; + + if (OnyxUtils.getDeferredInitTask().isResolved) { + return updateOperation(); + } + + return OnyxUtils.getDeferredInitTask().promise.then(updateOperation); } /** @@ -570,6 +600,10 @@ function update(data: Array>): Promise(collectionKey: TKey, collection: OnyxSetCollectionInput): Promise { + if (OnyxUtils.getDeferredInitTask().isResolved) { + return OnyxUtils.setCollectionWithRetry({collectionKey, collection}); + } + return OnyxUtils.getDeferredInitTask().promise.then(() => OnyxUtils.setCollectionWithRetry({collectionKey, collection})); } diff --git a/lib/createDeferredTask.ts b/lib/createDeferredTask.ts index e697e1a7..5aec9700 100644 --- a/lib/createDeferredTask.ts +++ b/lib/createDeferredTask.ts @@ -1,6 +1,7 @@ type DeferredTask = { promise: Promise; resolve?: () => void; + isResolved: boolean; }; /** @@ -10,9 +11,13 @@ type DeferredTask = { */ export default function createDeferredTask(): DeferredTask { const deferred = {} as DeferredTask; + deferred.isResolved = false; deferred.promise = new Promise((res) => { - deferred.resolve = res; + deferred.resolve = () => { + deferred.isResolved = true; + res(); + }; }); return deferred; From 82c773504a74a5d7e56bcf618fcd7fb73dd81115 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Tue, 3 Feb 2026 09:37:22 +0100 Subject: [PATCH 04/10] Refactor Onyx operations to use afterInit helper Replace repeated deferredInitTask checks with OnyxUtils.afterInit to simplify async initialization handling for set, multiSet, merge, clear, update, and collection methods. --- lib/Onyx.ts | 42 +++++++----------------------------------- lib/OnyxUtils.ts | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 35 deletions(-) diff --git a/lib/Onyx.ts b/lib/Onyx.ts index 75fc834b..535189ae 100644 --- a/lib/Onyx.ts +++ b/lib/Onyx.ts @@ -155,11 +155,7 @@ function disconnect(connection: Connection): void { * @param options optional configuration object */ function set(key: TKey, value: OnyxSetInput, options?: SetOptions): Promise { - if (OnyxUtils.getDeferredInitTask().isResolved) { - return OnyxUtils.setWithRetry({key, value, options}); - } - - return OnyxUtils.getDeferredInitTask().promise.then(() => OnyxUtils.setWithRetry({key, value, options})); + return OnyxUtils.afterInit(() => OnyxUtils.setWithRetry({key, value, options})); } /** @@ -170,11 +166,7 @@ function set(key: TKey, value: OnyxSetInput, options * @param data object keyed by ONYXKEYS and the values to set */ function multiSet(data: OnyxMultiSetInput): Promise { - if (OnyxUtils.getDeferredInitTask().isResolved) { - return OnyxUtils.multiSetWithRetry(data); - } - - return OnyxUtils.getDeferredInitTask().promise.then(() => OnyxUtils.multiSetWithRetry(data)); + return OnyxUtils.afterInit(() => OnyxUtils.multiSetWithRetry(data)); } /** @@ -270,11 +262,7 @@ function merge(key: TKey, changes: OnyxMergeInput): return mergeQueuePromise[key]; }; - if (OnyxUtils.getDeferredInitTask().isResolved) { - return mergeOperation(); - } - - return OnyxUtils.getDeferredInitTask().promise.then(mergeOperation); + return OnyxUtils.afterInit(mergeOperation); } /** @@ -291,11 +279,7 @@ function merge(key: TKey, changes: OnyxMergeInput): * @param collection Object collection keyed by individual collection member keys and values */ function mergeCollection(collectionKey: TKey, collection: OnyxMergeCollectionInput): Promise { - if (OnyxUtils.getDeferredInitTask().isResolved) { - return OnyxUtils.mergeCollectionWithPatches({collectionKey, collection, isProcessingCollectionUpdate: true}); - } - - return OnyxUtils.getDeferredInitTask().promise.then(() => OnyxUtils.mergeCollectionWithPatches({collectionKey, collection, isProcessingCollectionUpdate: true})); + return OnyxUtils.afterInit(() => OnyxUtils.mergeCollectionWithPatches({collectionKey, collection, isProcessingCollectionUpdate: true})); } /** @@ -422,11 +406,7 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise { return cache.captureTask(TASK.CLEAR, promise) as Promise; }; - if (OnyxUtils.getDeferredInitTask().isResolved) { - return clearOperation(); - } - - return OnyxUtils.getDeferredInitTask().promise.then(clearOperation); + return OnyxUtils.afterInit(clearOperation); } /** @@ -579,11 +559,7 @@ function update(data: Array>): Promise Promise.all(finalPromises.map((p) => p()))).then(() => undefined); }; - if (OnyxUtils.getDeferredInitTask().isResolved) { - return updateOperation(); - } - - return OnyxUtils.getDeferredInitTask().promise.then(updateOperation); + return OnyxUtils.afterInit(updateOperation); } /** @@ -600,11 +576,7 @@ function update(data: Array>): Promise(collectionKey: TKey, collection: OnyxSetCollectionInput): Promise { - if (OnyxUtils.getDeferredInitTask().isResolved) { - return OnyxUtils.setCollectionWithRetry({collectionKey, collection}); - } - - return OnyxUtils.getDeferredInitTask().promise.then(() => OnyxUtils.setCollectionWithRetry({collectionKey, collection})); + return OnyxUtils.afterInit(() => OnyxUtils.setCollectionWithRetry({collectionKey, collection})); } const Onyx = { diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 88939742..1475efeb 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -136,6 +136,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: () => T): T { + if (deferredInitTask.isResolved) { + return action(); + } + return deferredInitTask.promise.then(action) as T; +} + /** * Getter - returns the skippable collection member IDs. */ @@ -1667,6 +1682,7 @@ const OnyxUtils = { getMergeQueuePromise, getDefaultKeyStates, getDeferredInitTask, + afterInit, initStoreValues, sendActionToDevTools, get, From ceb5a164651b0c61def4b719eeed9c41f1ebabd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Wed, 4 Feb 2026 09:53:26 +0000 Subject: [PATCH 05/10] Implement tests for Onyx initialization and afterInit() --- tests/unit/onyxTest.ts | 128 +++++++++++++++++++++++++++++++++--- tests/unit/onyxUtilsTest.ts | 51 ++++++++++++-- 2 files changed, 163 insertions(+), 16 deletions(-) diff --git a/tests/unit/onyxTest.ts b/tests/unit/onyxTest.ts index b432bf82..eccb38eb 100644 --- a/tests/unit/onyxTest.ts +++ b/tests/unit/onyxTest.ts @@ -1,5 +1,6 @@ import lodashClone from 'lodash/clone'; import lodashCloneDeep from 'lodash/cloneDeep'; +import {act} from '@testing-library/react-native'; import Onyx from '../../lib'; import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; import OnyxUtils from '../../lib/OnyxUtils'; @@ -9,6 +10,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', @@ -27,19 +29,20 @@ const ONYX_KEYS = { }, }; -Onyx.init({ - keys: ONYX_KEYS, - initialKeyStates: { - [ONYX_KEYS.OTHER_TEST]: 42, - [ONYX_KEYS.KEY_WITH_UNDERSCORE]: 'default', - }, - skippableCollectionMemberIDs: ['skippable-id'], -}); - describe('Onyx', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYX_KEYS, + initialKeyStates: { + [ONYX_KEYS.OTHER_TEST]: 42, + [ONYX_KEYS.KEY_WITH_UNDERSCORE]: 'default', + }, + skippableCollectionMemberIDs: ['skippable-id'], + }); + }); + let connection: Connection | undefined; - /** @type OnyxCache */ let cache: typeof OnyxCache; beforeEach(() => { @@ -2833,3 +2836,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 36e1dd0e..35d47c0c 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', @@ -77,15 +80,13 @@ const ONYXKEYS = { }, }; -Onyx.init({ - keys: ONYXKEYS, -}); +describe('OnyxUtils', () => { + beforeAll(() => Onyx.init({keys: ONYXKEYS})); -beforeEach(() => Onyx.clear()); + beforeEach(() => Onyx.clear()); -afterEach(() => jest.clearAllMocks()); + afterEach(() => jest.clearAllMocks()); -describe('OnyxUtils', () => { describe('splitCollectionMemberKey', () => { describe('should return correct values', () => { const dataResult: Record = { @@ -487,4 +488,42 @@ describe('OnyxUtils', () => { expect(retryOperationSpy).toHaveBeenCalledTimes(1); }); }); + + 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); + }); + }); }); From fdf95e5e6b24d29a63df81cbb771f4aaa48c0c9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Wed, 4 Feb 2026 18:13:47 +0000 Subject: [PATCH 06/10] Improve afterInit TS --- lib/OnyxUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 36133cc1..6595a004 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -145,11 +145,11 @@ function getDeferredInitTask(): DeferredTask { * @param action The action to execute after initialization * @returns The result of the action */ -function afterInit(action: () => T): T { +function afterInit(action: () => Promise): Promise { if (deferredInitTask.isResolved) { return action(); } - return deferredInitTask.promise.then(action) as T; + return deferredInitTask.promise.then(action); } /** From 6d22df13eeabe948c587d5825c6e7d09059a35ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Wed, 4 Feb 2026 18:14:18 +0000 Subject: [PATCH 07/10] Adjust Onyx methods logic to be inside afterInit --- lib/Onyx.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/lib/Onyx.ts b/lib/Onyx.ts index fb03a2b3..09e9a8c6 100644 --- a/lib/Onyx.ts +++ b/lib/Onyx.ts @@ -188,7 +188,7 @@ 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 mergeOperation = () => { + return OnyxUtils.afterInit(() => { const skippableCollectionMemberIDs = OnyxUtils.getSkippableCollectionMemberIDs(); if (skippableCollectionMemberIDs.size) { try { @@ -262,9 +262,7 @@ function merge(key: TKey, changes: OnyxMergeInput): }); return mergeQueuePromise[key]; - }; - - return OnyxUtils.afterInit(mergeOperation); + }); } /** @@ -306,7 +304,7 @@ 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 clearOperation = () => { + return OnyxUtils.afterInit(() => { const defaultKeyStates = OnyxUtils.getDefaultKeyStates(); const initialKeys = Object.keys(defaultKeyStates); @@ -406,9 +404,7 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise { .then(() => undefined); return cache.captureTask(TASK.CLEAR, promise) as Promise; - }; - - return OnyxUtils.afterInit(clearOperation); + }); } /** @@ -418,7 +414,7 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise { * @returns resolves when all operations are complete */ function update(data: Array>): Promise { - const updateOperation = () => { + return OnyxUtils.afterInit(() => { // 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)) { @@ -559,9 +555,7 @@ function update(data: Array>): Promise Promise.all(finalPromises.map((p) => p()))).then(() => undefined); - }; - - return OnyxUtils.afterInit(updateOperation); + }); } /** From 5cf81ca51148767f627d68764e2ad08532c4ef71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Thu, 5 Feb 2026 08:45:39 +0000 Subject: [PATCH 08/10] Standartise validations in Onyx.update --- lib/Onyx.ts | 32 +++++------ tests/unit/onyxTest.ts | 122 ++++++++++++++++++++--------------------- 2 files changed, 75 insertions(+), 79 deletions(-) diff --git a/lib/Onyx.ts b/lib/Onyx.ts index 09e9a8c6..b64f9a9f 100644 --- a/lib/Onyx.ts +++ b/lib/Onyx.ts @@ -415,21 +415,6 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise { */ function update(data: Array>): Promise { return OnyxUtils.afterInit(() => { - // 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.'); - } - } 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>> = {}; @@ -453,14 +438,24 @@ function update(data: Array>): Promise Promise> = []; let clearPromise: Promise = Promise.resolve(); + 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; + } + 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.'); + Logger.logInfo('Invalid or empty value provided in Onyx mergeCollection. Skipping this operation.'); return; } @@ -473,6 +468,11 @@ function update(data: Array>): Promise 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; + } + for (const [entryKey, entryValue] of Object.entries(v as Partial)) enqueueSetOperation(entryKey, entryValue); }, [OnyxUtils.METHOD.CLEAR]: () => { diff --git a/tests/unit/onyxTest.ts b/tests/unit/onyxTest.ts index e6d4e552..3ee27ecf 100644 --- a/tests/unit/onyxTest.ts +++ b/tests/unit/onyxTest.ts @@ -2,6 +2,7 @@ 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'; @@ -932,43 +933,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({ @@ -1016,32 +980,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(); @@ -1612,6 +1550,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[] = []; @@ -2367,6 +2316,53 @@ describe('Onyx', () => { Onyx.disconnect(connection1); Onyx.disconnect(connection2); }); + + 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', () => { From f351223e341b6daf3a83d8ed314d384bb93184fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Thu, 5 Feb 2026 09:31:57 +0000 Subject: [PATCH 09/10] Use Promise.withResolvers in createDeferredTask --- lib/createDeferredTask.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/lib/createDeferredTask.ts b/lib/createDeferredTask.ts index 5aec9700..61e41f7b 100644 --- a/lib/createDeferredTask.ts +++ b/lib/createDeferredTask.ts @@ -1,6 +1,6 @@ type DeferredTask = { promise: Promise; - resolve?: () => void; + resolve: () => void; isResolved: boolean; }; @@ -10,15 +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; - deferred.isResolved = false; + const {promise, resolve: originalResolve} = Promise.withResolvers(); - deferred.promise = new Promise((res) => { - deferred.resolve = () => { + const deferred: DeferredTask = { + promise, + resolve: () => { deferred.isResolved = true; - res(); - }; - }); + originalResolve(); + }, + isResolved: false, + }; return deferred; } From 78c9a19135eaf1ef9f314eab32a88fcea4ea010b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Mon, 9 Feb 2026 10:45:11 +0000 Subject: [PATCH 10/10] Fix tests --- tests/unit/onyxTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/onyxTest.ts b/tests/unit/onyxTest.ts index 8f992cbd..f66b0dc7 100644 --- a/tests/unit/onyxTest.ts +++ b/tests/unit/onyxTest.ts @@ -40,6 +40,7 @@ describe('Onyx', () => { 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'],