From b0eb40b7d366d0397d287b7a24291c9a9260ff98 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Fri, 23 Jan 2026 09:09:38 +0100 Subject: [PATCH 01/19] Remove macro/micro tasks during subscriber update --- lib/Onyx.ts | 21 +++++-------- lib/OnyxMerge/index.native.ts | 5 ++-- lib/OnyxMerge/types.ts | 1 - lib/OnyxUtils.ts | 41 +++++--------------------- tests/perf-test/OnyxUtils.perf-test.ts | 4 +-- 5 files changed, 20 insertions(+), 52 deletions(-) diff --git a/lib/Onyx.ts b/lib/Onyx.ts index 7be474720..c2fca41fc 100644 --- a/lib/Onyx.ts +++ b/lib/Onyx.ts @@ -249,9 +249,8 @@ function merge(key: TKey, changes: OnyxMergeInput): return Promise.resolve(); } - return OnyxMerge.applyMerge(key, existingValue, validChanges).then(({mergedValue, updatePromise}) => { + return OnyxMerge.applyMerge(key, existingValue, validChanges).then(({mergedValue}) => { 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}`); @@ -361,16 +360,6 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise { keysToBeClearedFromStorage.push(key); } - 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)); - } - const defaultKeyValuePairs = Object.entries( Object.keys(defaultKeyStates) .filter((key) => !keysToPreserve.includes(key)) @@ -388,7 +377,13 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise { .then(() => Storage.multiSet(defaultKeyValuePairs)) .then(() => { DevTools.clearState(keysToPreserve); - return Promise.all(updatePromises); + // Notify the subscribers for each key/value group so they can receive the new values + for (const [key, value] of Object.entries(keyValuesToResetIndividually)) { + OnyxUtils.scheduleSubscriberUpdate(key, value); + } + for (const [key, value] of Object.entries(keyValuesToResetAsCollection)) { + OnyxUtils.scheduleNotifyCollectionSubscribers(key, value); + } }); }) .then(() => undefined); diff --git a/lib/OnyxMerge/index.native.ts b/lib/OnyxMerge/index.native.ts index b796dfde4..ad4eb4355 100644 --- a/lib/OnyxMerge/index.native.ts +++ b/lib/OnyxMerge/index.native.ts @@ -26,18 +26,17 @@ const applyMerge: ApplyMerge = , hasChanged); + OnyxUtils.broadcastUpdate(key, mergedValue as OnyxValue, hasChanged); // If the value has not changed, calling Storage.setItem() would be redundant and a waste of performance, so return early instead. if (!hasChanged) { - return Promise.resolve({mergedValue, updatePromise}); + return Promise.resolve({mergedValue}); } // For native platforms we use `mergeItem` that will take advantage of JSON_PATCH and JSON_REPLACE SQL operations to // merge the object in a performant way. return Storage.mergeItem(key, batchedChanges as OnyxValue, replaceNullPatches).then(() => ({ mergedValue, - updatePromise, })); }; diff --git a/lib/OnyxMerge/types.ts b/lib/OnyxMerge/types.ts index c59b7892a..e53d8ff32 100644 --- a/lib/OnyxMerge/types.ts +++ b/lib/OnyxMerge/types.ts @@ -2,7 +2,6 @@ import type {OnyxInput, OnyxKey} from '../types'; type ApplyMergeResult = { mergedValue: TValue; - updatePromise: Promise; }; type ApplyMerge = | undefined, TChange extends OnyxInput | null>( diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 889397424..b34ccac37 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -75,9 +75,6 @@ type OnyxMethod = ValueOf; let mergeQueue: Record>> = {}; let mergeQueuePromise: Record> = {}; -// Used to schedule subscriber update to the macro task queue -let nextMacrotaskPromise: Promise | null = null; - // Holds a mapping of all the React components that want their state subscribed to a store key let callbackToStateMapping: Record> = {}; @@ -803,23 +800,6 @@ function getCollectionDataAndSendAsObject(matchingKeys: Co }); } -/** - * Delays promise resolution until the next macrotask to prevent race condition if the key subscription is in progress. - * - * @param callback The keyChanged/keysChanged callback - * */ -function prepareSubscriberUpdate(callback: () => void): Promise { - if (!nextMacrotaskPromise) { - nextMacrotaskPromise = new Promise((resolve) => { - setTimeout(() => { - nextMacrotaskPromise = null; - resolve(); - }, 0); - }); - } - return Promise.all([nextMacrotaskPromise, Promise.resolve().then(callback)]).then(); -} - /** * Schedules an update that will be appended to the macro task queue (so it doesn't update the subscribers immediately). * @@ -831,8 +811,8 @@ function scheduleSubscriberUpdate( value: OnyxValue, canUpdateSubscriber: (subscriber?: CallbackToStateMapping) => boolean = () => true, isProcessingCollectionUpdate = false, -): Promise { - return prepareSubscriberUpdate(() => keyChanged(key, value, canUpdateSubscriber, isProcessingCollectionUpdate)); +): void { + keyChanged(key, value, canUpdateSubscriber, isProcessingCollectionUpdate); } /** @@ -840,12 +820,8 @@ function scheduleSubscriberUpdate( * so that keysChanged() is triggered for the collection and not keyChanged(). If this was not done, then the * subscriber callbacks receive the data in a different format than they normally expect and it breaks code. */ -function scheduleNotifyCollectionSubscribers( - key: TKey, - value: OnyxCollection, - previousValue?: OnyxCollection, -): Promise { - return prepareSubscriberUpdate(() => keysChanged(key, value, previousValue)); +function scheduleNotifyCollectionSubscribers(key: TKey, value: OnyxCollection, previousValue?: OnyxCollection): void { + keysChanged(key, value, previousValue); } /** @@ -919,7 +895,7 @@ function retryOperation(error: Error, on /** * Notifies subscribers and writes current value to cache */ -function broadcastUpdate(key: TKey, value: OnyxValue, hasChanged?: boolean): Promise { +function broadcastUpdate(key: TKey, value: OnyxValue, hasChanged?: boolean): void { // Update subscribers if the cached value has changed, or when the subscriber specifically requires // all updates regardless of value changes (indicated by initWithStoredValues set to false). if (hasChanged) { @@ -928,7 +904,7 @@ function broadcastUpdate(key: TKey, value: OnyxValue cache.addToAccessedKeys(key); } - return scheduleSubscriberUpdate(key, value, (subscriber) => hasChanged || subscriber?.initWithStoredValues === false).then(() => undefined); + scheduleSubscriberUpdate(key, value, (subscriber) => hasChanged || subscriber?.initWithStoredValues === false); } function hasPendingMergeForKey(key: OnyxKey): boolean { @@ -1314,18 +1290,17 @@ function setWithRetry({key, value, options}: SetParams OnyxUtils.retryOperation(error, setWithRetry, {key, value: valueWithoutNestedNullValues, options}, retryAttempt)) .then(() => { OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET, key, valueWithoutNestedNullValues); - return updatePromise; }); } diff --git a/tests/perf-test/OnyxUtils.perf-test.ts b/tests/perf-test/OnyxUtils.perf-test.ts index 28e30e798..90bf540a0 100644 --- a/tests/perf-test/OnyxUtils.perf-test.ts +++ b/tests/perf-test/OnyxUtils.perf-test.ts @@ -452,7 +452,7 @@ describe('OnyxUtils', () => { Object.entries(mockedReportActionsMap).map(([k, v]) => [k, createRandomReportAction(Number(v.reportActionID))] as const), ) as GenericCollection; - await measureAsyncFunction(() => OnyxUtils.scheduleNotifyCollectionSubscribers(collectionKey, changedReportActions, mockedReportActionsMap), { + await measureFunction(() => OnyxUtils.scheduleNotifyCollectionSubscribers(collectionKey, changedReportActions, mockedReportActionsMap), { beforeEach: async () => { await Onyx.multiSet(mockedReportActionsMap); for (const key of mockedReportActionsKeys) { @@ -517,7 +517,7 @@ describe('OnyxUtils', () => { const reportAction = mockedReportActionsMap[`${collectionKey}0`]; const changedReportAction = createRandomReportAction(Number(reportAction.reportActionID)); - await measureAsyncFunction(() => OnyxUtils.broadcastUpdate(key, changedReportAction, true), { + await measureFunction(() => OnyxUtils.broadcastUpdate(key, changedReportAction, true), { beforeEach: async () => { await Onyx.set(key, reportAction); }, From 62ceec26c538a8f432e147303a3d65cd46af0179 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Mon, 26 Jan 2026 15:42:05 +0100 Subject: [PATCH 02/19] Don't send data during connection, if it's already there --- lib/OnyxUtils.ts | 3 ++- tests/unit/onyxTest.ts | 5 ++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index b34ccac37..0253d64a1 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -736,6 +736,7 @@ function keyChanged( } cachedCollection[key] = value; + lastConnectionCallbackData.set(subscriber.subscriptionID, cachedCollection); subscriber.callback(cachedCollection, subscriber.key, {[key]: value}); continue; } @@ -767,7 +768,7 @@ function sendDataToConnection(mapping: CallbackToStateMapp lastConnectionCallbackData.get(mapping.subscriptionID); // If the value has not changed we do not need to trigger the callback - if (lastConnectionCallbackData.has(mapping.subscriptionID) && valueToPass === lastValue) { + if (lastConnectionCallbackData.has(mapping.subscriptionID) && lastValue) { return; } diff --git a/tests/unit/onyxTest.ts b/tests/unit/onyxTest.ts index b432bf82c..56e3efa2c 100644 --- a/tests/unit/onyxTest.ts +++ b/tests/unit/onyxTest.ts @@ -1535,10 +1535,9 @@ describe('Onyx', () => { return waitForPromisesToResolve(); }) .then(() => { - expect(collectionCallback).toHaveBeenCalledTimes(3); + expect(collectionCallback).toHaveBeenCalledTimes(2); expect(collectionCallback).toHaveBeenNthCalledWith(1, {[cat]: initialValue}, ONYX_KEYS.COLLECTION.ANIMALS, {[cat]: initialValue}); - expect(collectionCallback).toHaveBeenNthCalledWith(2, {[cat]: initialValue}, ONYX_KEYS.COLLECTION.ANIMALS, undefined); - expect(collectionCallback).toHaveBeenNthCalledWith(3, collectionDiff, ONYX_KEYS.COLLECTION.ANIMALS, {[cat]: initialValue, [dog]: {name: 'Rex'}}); + expect(collectionCallback).toHaveBeenNthCalledWith(2, collectionDiff, ONYX_KEYS.COLLECTION.ANIMALS, {[cat]: initialValue, [dog]: {name: 'Rex'}}); // Cat hasn't changed from its original value, expect only the initial connect callback expect(catCallback).toHaveBeenCalledTimes(1); From e0b0fd738759964bb0ee02cc620094cc408a63a8 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 11 Feb 2026 14:37:09 +0100 Subject: [PATCH 03/19] Adjustment after merging main --- lib/OnyxUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index b5657b001..6680c8dd8 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -1358,7 +1358,7 @@ function setWithRetry({key, value, options}: SetParams Date: Wed, 11 Feb 2026 14:58:25 +0100 Subject: [PATCH 04/19] Clean up the code --- lib/OnyxMerge/index.ts | 5 ++--- lib/OnyxUtils.ts | 14 ++++++-------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/lib/OnyxMerge/index.ts b/lib/OnyxMerge/index.ts index 7eac789cb..ef92293d3 100644 --- a/lib/OnyxMerge/index.ts +++ b/lib/OnyxMerge/index.ts @@ -18,20 +18,19 @@ const applyMerge: ApplyMerge = , hasChanged); + OnyxUtils.broadcastUpdate(key, mergedValue as OnyxValue, hasChanged); const shouldSkipStorageOperations = !hasChanged || OnyxUtils.isRamOnlyKey(key); // If the value has not changed, calling Storage.setItem() would be redundant and a waste of performance, so return early instead. // If the key is marked as RAM-only, it should not be saved nor updated in the storage. if (shouldSkipStorageOperations) { - return Promise.resolve({mergedValue, updatePromise}); + return Promise.resolve({mergedValue}); } // For web platforms we use `setItem` since the object was already merged with its changes before. return Storage.setItem(key, mergedValue as OnyxValue).then(() => ({ mergedValue, - updatePromise, })); }; diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 6680c8dd8..5e8e6ee4b 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -808,11 +808,10 @@ function sendDataToConnection(mapping: CallbackToStateMapp // For regular callbacks, we never want to pass null values, but always just undefined if a value is not set in cache or storage. const valueToPass = value === null ? undefined : value; - const lastValue = lastConnectionCallbackData.get(mapping.subscriptionID); - lastConnectionCallbackData.get(mapping.subscriptionID); - // If the value has not changed we do not need to trigger the callback - if (lastConnectionCallbackData.has(mapping.subscriptionID) && lastValue) { + // If the subscriber was already notified (e.g. by a synchronous keyChanged call), + // skip the initial data delivery to prevent duplicate callbacks. + if (lastConnectionCallbackData.has(mapping.subscriptionID)) { return; } @@ -846,7 +845,7 @@ function getCollectionDataAndSendAsObject(matchingKeys: Co } /** - * Schedules an update that will be appended to the macro task queue (so it doesn't update the subscribers immediately). + * Notifies subscribers about a key change. * * @example * scheduleSubscriberUpdate(key, value, subscriber => subscriber.initWithStoredValues === false) @@ -861,9 +860,8 @@ function scheduleSubscriberUpdate( } /** - * This method is similar to scheduleSubscriberUpdate but it is built for working specifically with collections - * so that keysChanged() is triggered for the collection and not keyChanged(). If this was not done, then the - * subscriber callbacks receive the data in a different format than they normally expect and it breaks code. + * Notifies collection subscribers about changes. Uses keysChanged() instead of keyChanged() so that + * subscriber callbacks receive data in the expected collection format. */ function scheduleNotifyCollectionSubscribers(key: TKey, value: OnyxCollection, previousValue?: OnyxCollection): void { keysChanged(key, value, previousValue); From 864857a0390c954937b9c71b2119bcaddb8e4791 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Fri, 13 Feb 2026 11:29:10 +0100 Subject: [PATCH 05/19] Remove scheduleSubscriberUpdate and scheduleNotifyCollectionSubscribers functions --- lib/Onyx.ts | 4 +-- lib/OnyxUtils.ts | 41 ++++---------------------- tests/perf-test/OnyxUtils.perf-test.ts | 8 ++--- 3 files changed, 12 insertions(+), 41 deletions(-) diff --git a/lib/Onyx.ts b/lib/Onyx.ts index 3e0ec180a..983d5520e 100644 --- a/lib/Onyx.ts +++ b/lib/Onyx.ts @@ -390,10 +390,10 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise { DevTools.clearState(keysToPreserve); // Notify the subscribers for each key/value group so they can receive the new values for (const [key, value] of Object.entries(keyValuesToResetIndividually)) { - OnyxUtils.scheduleSubscriberUpdate(key, value); + OnyxUtils.keyChanged(key, value); } for (const [key, value] of Object.entries(keyValuesToResetAsCollection)) { - OnyxUtils.scheduleNotifyCollectionSubscribers(key, value.newValues, value.oldValues); + OnyxUtils.keysChanged(key, value.newValues, value.oldValues); } }); }) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 5e8e6ee4b..32a4672ab 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -844,35 +844,12 @@ function getCollectionDataAndSendAsObject(matchingKeys: Co }); } -/** - * Notifies subscribers about a key change. - * - * @example - * scheduleSubscriberUpdate(key, value, subscriber => subscriber.initWithStoredValues === false) - */ -function scheduleSubscriberUpdate( - key: TKey, - value: OnyxValue, - canUpdateSubscriber: (subscriber?: CallbackToStateMapping) => boolean = () => true, - isProcessingCollectionUpdate = false, -): void { - keyChanged(key, value, canUpdateSubscriber, isProcessingCollectionUpdate); -} - -/** - * Notifies collection subscribers about changes. Uses keysChanged() instead of keyChanged() so that - * subscriber callbacks receive data in the expected collection format. - */ -function scheduleNotifyCollectionSubscribers(key: TKey, value: OnyxCollection, previousValue?: OnyxCollection): void { - keysChanged(key, value, previousValue); -} - /** * Remove a key from Onyx and update the subscribers */ function remove(key: TKey, isProcessingCollectionUpdate?: boolean): Promise { cache.drop(key); - scheduleSubscriberUpdate(key, undefined as OnyxValue, undefined, isProcessingCollectionUpdate); + keyChanged(key, undefined as OnyxValue, undefined, isProcessingCollectionUpdate); if (isRamOnlyKey(key)) { return Promise.resolve(); @@ -952,7 +929,7 @@ function broadcastUpdate(key: TKey, value: OnyxValue cache.addToAccessedKeys(key); } - scheduleSubscriberUpdate(key, value, (subscriber) => hasChanged || subscriber?.initWithStoredValues === false); + keyChanged(key, value, (subscriber) => hasChanged || subscriber?.initWithStoredValues === false); } function hasPendingMergeForKey(key: OnyxKey): boolean { @@ -1405,7 +1382,7 @@ function multiSetWithRetry(data: OnyxMultiSetInput, retryAttempt?: number): Prom // Update cache and optimistically inform subscribers on the next tick cache.set(key, value); - return OnyxUtils.scheduleSubscriberUpdate(key, value); + return OnyxUtils.keyChanged(key, value); }); const keyValuePairsToStore = keyValuePairsToSet.filter((keyValuePair) => { @@ -1481,7 +1458,7 @@ function setCollectionWithRetry({collectionKey, for (const [key, value] of keyValuePairs) cache.set(key, value); - const updatePromise = OnyxUtils.scheduleNotifyCollectionSubscribers(collectionKey, mutableCollection, previousCollection); + const updatePromise = OnyxUtils.keysChanged(collectionKey, mutableCollection, previousCollection); // RAM-only keys are not supposed to be saved to storage if (isRamOnlyKey(collectionKey)) { @@ -1616,7 +1593,7 @@ function mergeCollectionWithPatches( // and update all subscribers const promiseUpdate = previousCollectionPromise.then((previousCollection) => { cache.merge(finalMergedCollection); - return scheduleNotifyCollectionSubscribers(collectionKey, finalMergedCollection, previousCollection); + return keysChanged(collectionKey, finalMergedCollection, previousCollection); }); return Promise.all(promises) @@ -1682,7 +1659,7 @@ function partialSetCollection({collectionKey, co for (const [key, value] of keyValuePairs) cache.set(key, value); - const updatePromise = scheduleNotifyCollectionSubscribers(collectionKey, mutableCollection, previousCollection); + const updatePromise = keysChanged(collectionKey, mutableCollection, previousCollection); if (isRamOnlyKey(collectionKey)) { sendActionToDevTools(METHOD.SET_COLLECTION, undefined, mutableCollection); @@ -1740,8 +1717,6 @@ const OnyxUtils = { sendDataToConnection, getCollectionKey, getCollectionDataAndSendAsObject, - scheduleSubscriberUpdate, - scheduleNotifyCollectionSubscribers, remove, reportStorageQuota, retryOperation, @@ -1798,10 +1773,6 @@ GlobalSettings.addGlobalSettingsChangeListener(({enablePerformanceMetrics}) => { // @ts-expect-error Reassign sendDataToConnection = decorateWithMetrics(sendDataToConnection, 'OnyxUtils.sendDataToConnection'); // @ts-expect-error Reassign - scheduleSubscriberUpdate = decorateWithMetrics(scheduleSubscriberUpdate, 'OnyxUtils.scheduleSubscriberUpdate'); - // @ts-expect-error Reassign - scheduleNotifyCollectionSubscribers = decorateWithMetrics(scheduleNotifyCollectionSubscribers, 'OnyxUtils.scheduleNotifyCollectionSubscribers'); - // @ts-expect-error Reassign remove = decorateWithMetrics(remove, 'OnyxUtils.remove'); // @ts-expect-error Reassign reportStorageQuota = decorateWithMetrics(reportStorageQuota, 'OnyxUtils.reportStorageQuota'); diff --git a/tests/perf-test/OnyxUtils.perf-test.ts b/tests/perf-test/OnyxUtils.perf-test.ts index edeba4544..3b1a6320d 100644 --- a/tests/perf-test/OnyxUtils.perf-test.ts +++ b/tests/perf-test/OnyxUtils.perf-test.ts @@ -431,7 +431,7 @@ describe('OnyxUtils', () => { }); }); - describe('scheduleSubscriberUpdate', () => { + describe('keyChanged', () => { test('10k calls scheduling updates', async () => { const subscriptionMap = new Map(); @@ -439,7 +439,7 @@ describe('OnyxUtils', () => { Object.entries(mockedReportActionsMap).map(([k, v]) => [k, createRandomReportAction(Number(v.reportActionID))] as const), ) as GenericCollection; - await measureAsyncFunction(() => Promise.all(Object.entries(changedReportActions).map(([key, value]) => OnyxUtils.scheduleSubscriberUpdate(key, value))), { + await measureAsyncFunction(() => Promise.all(Object.entries(changedReportActions).map(([key, value]) => OnyxUtils.keyChanged(key, value))), { beforeEach: async () => { await Onyx.multiSet(mockedReportActionsMap); for (const key of mockedReportActionsKeys) { @@ -461,7 +461,7 @@ describe('OnyxUtils', () => { }); }); - describe('scheduleNotifyCollectionSubscribers', () => { + describe('keysChanged', () => { test('one call with 10k heavy objects to update 10k subscribers', async () => { const subscriptionMap = new Map(); @@ -469,7 +469,7 @@ describe('OnyxUtils', () => { Object.entries(mockedReportActionsMap).map(([k, v]) => [k, createRandomReportAction(Number(v.reportActionID))] as const), ) as GenericCollection; - await measureFunction(() => OnyxUtils.scheduleNotifyCollectionSubscribers(collectionKey, changedReportActions, mockedReportActionsMap), { + await measureFunction(() => OnyxUtils.keysChanged(collectionKey, changedReportActions, mockedReportActionsMap), { beforeEach: async () => { await Onyx.multiSet(mockedReportActionsMap); for (const key of mockedReportActionsKeys) { From 7813305b5e137581bdf446882beb26a10463f2c4 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Fri, 13 Feb 2026 11:44:03 +0100 Subject: [PATCH 06/19] Keep the logic consistent with how it was --- lib/Onyx.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/lib/Onyx.ts b/lib/Onyx.ts index 983d5520e..8023807ed 100644 --- a/lib/Onyx.ts +++ b/lib/Onyx.ts @@ -370,6 +370,14 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise { keysToBeClearedFromStorage.push(key); } + // Notify the subscribers for each key/value group so they can receive the new values + for (const [key, value] of Object.entries(keyValuesToResetIndividually)) { + OnyxUtils.keyChanged(key, value); + } + for (const [key, value] of Object.entries(keyValuesToResetAsCollection)) { + OnyxUtils.keysChanged(key, value.newValues, value.oldValues); + } + // Exclude RAM-only keys to prevent them from being saved to storage const defaultKeyValuePairs = Object.entries( Object.keys(defaultKeyStates) @@ -388,13 +396,6 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise { .then(() => Storage.multiSet(defaultKeyValuePairs)) .then(() => { DevTools.clearState(keysToPreserve); - // Notify the subscribers for each key/value group so they can receive the new values - for (const [key, value] of Object.entries(keyValuesToResetIndividually)) { - OnyxUtils.keyChanged(key, value); - } - for (const [key, value] of Object.entries(keyValuesToResetAsCollection)) { - OnyxUtils.keysChanged(key, value.newValues, value.oldValues); - } }); }) .then(() => undefined); From c5263aa381f1f74fd080999c33fd49fb26872e15 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Fri, 13 Feb 2026 12:19:11 +0100 Subject: [PATCH 07/19] Run prettier --- lib/Onyx.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/Onyx.ts b/lib/Onyx.ts index e8b9902b6..59b4e14d7 100644 --- a/lib/Onyx.ts +++ b/lib/Onyx.ts @@ -255,7 +255,7 @@ function merge(key: TKey, changes: OnyxMergeInput): } return OnyxMerge.applyMerge(key, existingValue, validChanges).then(({mergedValue}) => { - OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.MERGE, key, changes, mergedValue); + OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.MERGE, key, changes, mergedValue); }); } catch (error) { Logger.logAlert(`An error occurred while applying merge for key: ${key}, Error: ${error}`); @@ -374,8 +374,8 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise { } // Notify the subscribers for each key/value group so they can receive the new values - for (const [key, value] of Object.entries(keyValuesToResetIndividually)) { - OnyxUtils.keyChanged(key, value); + for (const [key, value] of Object.entries(keyValuesToResetIndividually)) { + OnyxUtils.keyChanged(key, value); } for (const [key, value] of Object.entries(keyValuesToResetAsCollection)) { OnyxUtils.keysChanged(key, value.newValues, value.oldValues); @@ -392,16 +392,16 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise { }, {}), ); - // 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); - }); - }) - .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); + }); + }) + .then(() => undefined); return cache.captureTask(TASK.CLEAR, promise) as Promise; }); From 790ce25091b9be08dd66aa020b43a47c4f9a1333 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Fri, 13 Feb 2026 14:26:24 +0100 Subject: [PATCH 08/19] Remove outdated perf tests --- tests/perf-test/OnyxUtils.perf-test.ts | 60 -------------------------- 1 file changed, 60 deletions(-) diff --git a/tests/perf-test/OnyxUtils.perf-test.ts b/tests/perf-test/OnyxUtils.perf-test.ts index 3b1a6320d..fdc1d1076 100644 --- a/tests/perf-test/OnyxUtils.perf-test.ts +++ b/tests/perf-test/OnyxUtils.perf-test.ts @@ -431,66 +431,6 @@ describe('OnyxUtils', () => { }); }); - describe('keyChanged', () => { - test('10k calls scheduling updates', async () => { - const subscriptionMap = new Map(); - - const changedReportActions = Object.fromEntries( - Object.entries(mockedReportActionsMap).map(([k, v]) => [k, createRandomReportAction(Number(v.reportActionID))] as const), - ) as GenericCollection; - - await measureAsyncFunction(() => Promise.all(Object.entries(changedReportActions).map(([key, value]) => OnyxUtils.keyChanged(key, value))), { - beforeEach: async () => { - await Onyx.multiSet(mockedReportActionsMap); - for (const key of mockedReportActionsKeys) { - const id = OnyxUtils.subscribeToKey({key, callback: jest.fn(), initWithStoredValues: false}); - subscriptionMap.set(key, id); - } - }, - afterEach: async () => { - for (const key of mockedReportActionsKeys) { - const id = subscriptionMap.get(key); - if (id) { - OnyxUtils.unsubscribeFromKey(id); - } - } - subscriptionMap.clear(); - await clearOnyxAfterEachMeasure(); - }, - }); - }); - }); - - describe('keysChanged', () => { - test('one call with 10k heavy objects to update 10k subscribers', async () => { - const subscriptionMap = new Map(); - - const changedReportActions = Object.fromEntries( - Object.entries(mockedReportActionsMap).map(([k, v]) => [k, createRandomReportAction(Number(v.reportActionID))] as const), - ) as GenericCollection; - - await measureFunction(() => OnyxUtils.keysChanged(collectionKey, changedReportActions, mockedReportActionsMap), { - beforeEach: async () => { - await Onyx.multiSet(mockedReportActionsMap); - for (const key of mockedReportActionsKeys) { - const id = OnyxUtils.subscribeToKey({key, callback: jest.fn(), initWithStoredValues: false}); - subscriptionMap.set(key, id); - } - }, - afterEach: async () => { - for (const key of mockedReportActionsKeys) { - const id = subscriptionMap.get(key); - if (id) { - OnyxUtils.unsubscribeFromKey(id); - } - } - subscriptionMap.clear(); - await clearOnyxAfterEachMeasure(); - }, - }); - }); - }); - describe('remove', () => { test('10k calls', async () => { await measureAsyncFunction(() => Promise.all(mockedReportActionsKeys.map((key) => OnyxUtils.remove(key))), { From 90c26de930fa3c0a8e0bcdba89b4a259504e7c0a Mon Sep 17 00:00:00 2001 From: VickyStash Date: Fri, 13 Feb 2026 15:20:11 +0100 Subject: [PATCH 09/19] Return updates back --- lib/Onyx.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/Onyx.ts b/lib/Onyx.ts index 59b4e14d7..99064c171 100644 --- a/lib/Onyx.ts +++ b/lib/Onyx.ts @@ -373,14 +373,6 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise { keysToBeClearedFromStorage.push(key); } - // Notify the subscribers for each key/value group so they can receive the new values - for (const [key, value] of Object.entries(keyValuesToResetIndividually)) { - OnyxUtils.keyChanged(key, value); - } - for (const [key, value] of Object.entries(keyValuesToResetAsCollection)) { - OnyxUtils.keysChanged(key, value.newValues, value.oldValues); - } - // Exclude RAM-only keys to prevent them from being saved to storage const defaultKeyValuePairs = Object.entries( Object.keys(defaultKeyStates) @@ -399,6 +391,14 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise { .then(() => Storage.multiSet(defaultKeyValuePairs)) .then(() => { DevTools.clearState(keysToPreserve); + + // Notify the subscribers for each key/value group so they can receive the new values + for (const [key, value] of Object.entries(keyValuesToResetIndividually)) { + OnyxUtils.keyChanged(key, value); + } + for (const [key, value] of Object.entries(keyValuesToResetAsCollection)) { + OnyxUtils.keysChanged(key, value.newValues, value.oldValues); + } }); }) .then(() => undefined); From d21a2fc3fd3f3eefeac34e5a589a61e2712cc4e2 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 17 Feb 2026 14:28:26 +0100 Subject: [PATCH 10/19] Fix race condition in keysChanged for collection subscribers --- lib/OnyxUtils.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 920e0c90b..75dcc548f 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -691,6 +691,7 @@ function keysChanged( // send the whole cached collection. if (isSubscribedToCollectionKey) { if (subscriber.waitForCollectionCallback) { + lastConnectionCallbackData.set(subscriber.subscriptionID, cachedCollection); subscriber.callback(cachedCollection, subscriber.key, partialCollection); continue; } @@ -705,6 +706,7 @@ function keysChanged( subscriber.callback(cachedCollection[dataKey], dataKey); } + lastConnectionCallbackData.set(subscriber.subscriptionID, cachedCollection); continue; } From 66140d31593b48feb4180428ff338b26990e252c Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 17 Feb 2026 18:02:43 +0100 Subject: [PATCH 11/19] Remove unnecessary OnyxUtils prefix --- lib/OnyxUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 75dcc548f..c37bf6c3d 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -1399,7 +1399,7 @@ function multiSetWithRetry(data: OnyxMultiSetInput, retryAttempt?: number): Prom // Update cache and optimistically inform subscribers on the next tick cache.set(key, value); - return OnyxUtils.keyChanged(key, value); + return keyChanged(key, value); }); const keyValuePairsToStore = keyValuePairsToSet.filter((keyValuePair) => { @@ -1475,7 +1475,7 @@ function setCollectionWithRetry({collectionKey, for (const [key, value] of keyValuePairs) cache.set(key, value); - const updatePromise = OnyxUtils.keysChanged(collectionKey, mutableCollection, previousCollection); + const updatePromise = keysChanged(collectionKey, mutableCollection, previousCollection); // RAM-only keys are not supposed to be saved to storage if (isRamOnlyKey(collectionKey)) { From 6318f5ef6568f836af31c569c147acf471ee24f3 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Thu, 19 Feb 2026 17:26:38 +0100 Subject: [PATCH 12/19] Re-run checks From 1b6ea571a9df700b990e35a81c4f959b98af66df Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 24 Feb 2026 11:10:52 +0100 Subject: [PATCH 13/19] Remove unused promise variables for void-returning keyChanged/keysChanged calls --- lib/OnyxUtils.ts | 161 +++++++++++++++++++++++------------------------ 1 file changed, 77 insertions(+), 84 deletions(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index c37bf6c3d..b9f46e13e 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -1390,7 +1390,7 @@ function multiSetWithRetry(data: OnyxMultiSetInput, retryAttempt?: number): Prom const keyValuePairsToSet = OnyxUtils.prepareKeyValuePairsForStorage(newData, true); - const updatePromises = keyValuePairsToSet.map(([key, value]) => { + for (const [key, value] of keyValuePairsToSet) { // When we use multiSet to set a key we want to clear the current delta changes from Onyx.merge that were queued // before the value was set. If Onyx.merge is currently reading the old value from storage, it will then not apply the changes. if (OnyxUtils.hasPendingMergeForKey(key)) { @@ -1399,8 +1399,8 @@ function multiSetWithRetry(data: OnyxMultiSetInput, retryAttempt?: number): Prom // Update cache and optimistically inform subscribers on the next tick cache.set(key, value); - return keyChanged(key, value); - }); + keyChanged(key, value); + } const keyValuePairsToStore = keyValuePairsToSet.filter((keyValuePair) => { const [key] = keyValuePair; @@ -1412,9 +1412,7 @@ function multiSetWithRetry(data: OnyxMultiSetInput, retryAttempt?: number): Prom .catch((error) => OnyxUtils.retryOperation(error, multiSetWithRetry, newData, retryAttempt)) .then(() => { OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.MULTI_SET, undefined, newData); - return Promise.all(updatePromises); - }) - .then(() => undefined); + }); } /** @@ -1475,19 +1473,18 @@ function setCollectionWithRetry({collectionKey, for (const [key, value] of keyValuePairs) cache.set(key, value); - const updatePromise = keysChanged(collectionKey, mutableCollection, previousCollection); + keysChanged(collectionKey, mutableCollection, previousCollection); // RAM-only keys are not supposed to be saved to storage if (isRamOnlyKey(collectionKey)) { OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET_COLLECTION, undefined, mutableCollection); - return updatePromise; + return; } return Storage.multiSet(keyValuePairs) .catch((error) => OnyxUtils.retryOperation(error, setCollectionWithRetry, {collectionKey, collection}, retryAttempt)) .then(() => { OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET_COLLECTION, undefined, mutableCollection); - return updatePromise; }); }); } @@ -1540,94 +1537,91 @@ function mergeCollectionWithPatches( } resultCollectionKeys = Object.keys(resultCollection); - return getAllKeys() - .then((persistedKeys) => { - // Split to keys that exist in storage and keys that don't - const keys = resultCollectionKeys.filter((key) => { - if (resultCollection[key] === null) { - remove(key, isProcessingCollectionUpdate); - return false; - } - return true; - }); - - const existingKeys = keys.filter((key) => persistedKeys.has(key)); + return getAllKeys().then((persistedKeys) => { + // Split to keys that exist in storage and keys that don't + const keys = resultCollectionKeys.filter((key) => { + if (resultCollection[key] === null) { + remove(key, isProcessingCollectionUpdate); + return false; + } + return true; + }); - const cachedCollectionForExistingKeys = getCachedCollection(collectionKey, existingKeys); + const existingKeys = keys.filter((key) => persistedKeys.has(key)); - const existingKeyCollection = existingKeys.reduce((obj: OnyxInputKeyValueMapping, key) => { - const {isCompatible, existingValueType, newValueType} = utils.checkCompatibilityWithExistingValue(resultCollection[key], cachedCollectionForExistingKeys[key]); + const cachedCollectionForExistingKeys = getCachedCollection(collectionKey, existingKeys); - if (!isCompatible) { - Logger.logAlert(logMessages.incompatibleUpdateAlert(key, 'mergeCollection', existingValueType, newValueType)); - return obj; - } + const existingKeyCollection = existingKeys.reduce((obj: OnyxInputKeyValueMapping, key) => { + const {isCompatible, existingValueType, newValueType} = utils.checkCompatibilityWithExistingValue(resultCollection[key], cachedCollectionForExistingKeys[key]); - // eslint-disable-next-line no-param-reassign - obj[key] = resultCollection[key]; + if (!isCompatible) { + Logger.logAlert(logMessages.incompatibleUpdateAlert(key, 'mergeCollection', existingValueType, newValueType)); return obj; - }, {}) as Record>; + } - const newCollection: Record> = {}; - for (const key of keys) { - if (persistedKeys.has(key)) { - continue; - } - newCollection[key] = resultCollection[key]; + // eslint-disable-next-line no-param-reassign + obj[key] = resultCollection[key]; + return obj; + }, {}) as Record>; + + const newCollection: Record> = {}; + for (const key of keys) { + if (persistedKeys.has(key)) { + continue; } + newCollection[key] = resultCollection[key]; + } - // When (multi-)merging the values with the existing values in storage, - // we don't want to remove nested null values from the data that we pass to the storage layer, - // because the storage layer uses them to remove nested keys from storage natively. - const keyValuePairsForExistingCollection = prepareKeyValuePairsForStorage(existingKeyCollection, false, mergeReplaceNullPatches); + // When (multi-)merging the values with the existing values in storage, + // we don't want to remove nested null values from the data that we pass to the storage layer, + // because the storage layer uses them to remove nested keys from storage natively. + const keyValuePairsForExistingCollection = prepareKeyValuePairsForStorage(existingKeyCollection, false, mergeReplaceNullPatches); - // We can safely remove nested null values when using (multi-)set, - // because we will simply overwrite the existing values in storage. - const keyValuePairsForNewCollection = prepareKeyValuePairsForStorage(newCollection, true); + // We can safely remove nested null values when using (multi-)set, + // because we will simply overwrite the existing values in storage. + const keyValuePairsForNewCollection = prepareKeyValuePairsForStorage(newCollection, true); - const promises = []; + const promises = []; - // We need to get the previously existing values so we can compare the new ones - // against them, to avoid unnecessary subscriber updates. - const previousCollectionPromise = Promise.all(existingKeys.map((key) => get(key).then((value) => [key, value]))).then(Object.fromEntries); + // We need to get the previously existing values so we can compare the new ones + // against them, to avoid unnecessary subscriber updates. + const previousCollectionPromise = Promise.all(existingKeys.map((key) => get(key).then((value) => [key, value]))).then(Object.fromEntries); - // New keys will be added via multiSet while existing keys will be updated using multiMerge - // This is because setting a key that doesn't exist yet with multiMerge will throw errors - // We can skip this step for RAM-only keys as they should never be saved to storage - if (!isRamOnlyKey(collectionKey) && keyValuePairsForExistingCollection.length > 0) { - promises.push(Storage.multiMerge(keyValuePairsForExistingCollection)); - } + // New keys will be added via multiSet while existing keys will be updated using multiMerge + // This is because setting a key that doesn't exist yet with multiMerge will throw errors + // We can skip this step for RAM-only keys as they should never be saved to storage + if (!isRamOnlyKey(collectionKey) && keyValuePairsForExistingCollection.length > 0) { + promises.push(Storage.multiMerge(keyValuePairsForExistingCollection)); + } - // We can skip this step for RAM-only keys as they should never be saved to storage - if (!isRamOnlyKey(collectionKey) && keyValuePairsForNewCollection.length > 0) { - promises.push(Storage.multiSet(keyValuePairsForNewCollection)); - } + // We can skip this step for RAM-only keys as they should never be saved to storage + if (!isRamOnlyKey(collectionKey) && keyValuePairsForNewCollection.length > 0) { + promises.push(Storage.multiSet(keyValuePairsForNewCollection)); + } - // finalMergedCollection contains all the keys that were merged, without the keys of incompatible updates - const finalMergedCollection = {...existingKeyCollection, ...newCollection}; + // finalMergedCollection contains all the keys that were merged, without the keys of incompatible updates + const finalMergedCollection = {...existingKeyCollection, ...newCollection}; - // Prefill cache if necessary by calling get() on any existing keys and then merge original data to cache - // and update all subscribers - const promiseUpdate = previousCollectionPromise.then((previousCollection) => { - cache.merge(finalMergedCollection); - return keysChanged(collectionKey, finalMergedCollection, previousCollection); - }); + // Prefill cache if necessary by calling get() on any existing keys and then merge original data to cache + // and update all subscribers + previousCollectionPromise.then((previousCollection) => { + cache.merge(finalMergedCollection); + return keysChanged(collectionKey, finalMergedCollection, previousCollection); + }); - return Promise.all(promises) - .catch((error) => - retryOperation( - error, - mergeCollectionWithPatches, - {collectionKey, collection: resultCollection as OnyxMergeCollectionInput, mergeReplaceNullPatches, isProcessingCollectionUpdate}, - retryAttempt, - ), - ) - .then(() => { - sendActionToDevTools(METHOD.MERGE_COLLECTION, undefined, resultCollection); - return promiseUpdate; - }); - }) - .then(() => undefined); + return Promise.all(promises) + .catch((error) => + retryOperation( + error, + mergeCollectionWithPatches, + {collectionKey, collection: resultCollection as OnyxMergeCollectionInput, mergeReplaceNullPatches, isProcessingCollectionUpdate}, + retryAttempt, + ), + ) + .then(() => { + sendActionToDevTools(METHOD.MERGE_COLLECTION, undefined, resultCollection); + }); + }); } /** @@ -1676,18 +1670,17 @@ function partialSetCollection({collectionKey, co for (const [key, value] of keyValuePairs) cache.set(key, value); - const updatePromise = keysChanged(collectionKey, mutableCollection, previousCollection); + keysChanged(collectionKey, mutableCollection, previousCollection); if (isRamOnlyKey(collectionKey)) { sendActionToDevTools(METHOD.SET_COLLECTION, undefined, mutableCollection); - return updatePromise; + return; } return Storage.multiSet(keyValuePairs) .catch((error) => retryOperation(error, partialSetCollection, {collectionKey, collection}, retryAttempt)) .then(() => { sendActionToDevTools(METHOD.SET_COLLECTION, undefined, mutableCollection); - return updatePromise; }); }); } From 7d4e71b8df7244130da27c15b20ad9d3845a5d52 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 24 Feb 2026 14:10:19 +0100 Subject: [PATCH 14/19] Restore promise chaining for previousCollectionPromise --- lib/OnyxUtils.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 1985799f3..9818d2034 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -1594,9 +1594,9 @@ function mergeCollectionWithPatches( // Prefill cache if necessary by calling get() on any existing keys and then merge original data to cache // and update all subscribers - previousCollectionPromise.then((previousCollection) => { + const promiseUpdate = previousCollectionPromise.then((previousCollection) => { cache.merge(finalMergedCollection); - return keysChanged(collectionKey, finalMergedCollection, previousCollection); + keysChanged(collectionKey, finalMergedCollection, previousCollection); }); return Promise.all(promises) @@ -1610,6 +1610,7 @@ function mergeCollectionWithPatches( ) .then(() => { sendActionToDevTools(METHOD.MERGE_COLLECTION, undefined, resultCollection); + return promiseUpdate; }); }); } From f34b69563bcdc7f0cdf5b088d5ab61a5b085244f Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 24 Feb 2026 14:13:03 +0100 Subject: [PATCH 15/19] Align the code with how it was --- lib/OnyxUtils.ts | 142 ++++++++++++++++++++++++----------------------- 1 file changed, 72 insertions(+), 70 deletions(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 9818d2034..dd08e6be7 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -1527,92 +1527,94 @@ function mergeCollectionWithPatches( } resultCollectionKeys = Object.keys(resultCollection); - return getAllKeys().then((persistedKeys) => { - // Split to keys that exist in storage and keys that don't - const keys = resultCollectionKeys.filter((key) => { - if (resultCollection[key] === null) { - remove(key, isProcessingCollectionUpdate); - return false; - } - return true; - }); + return getAllKeys() + .then((persistedKeys) => { + // Split to keys that exist in storage and keys that don't + const keys = resultCollectionKeys.filter((key) => { + if (resultCollection[key] === null) { + remove(key, isProcessingCollectionUpdate); + return false; + } + return true; + }); - const existingKeys = keys.filter((key) => persistedKeys.has(key)); + const existingKeys = keys.filter((key) => persistedKeys.has(key)); - const cachedCollectionForExistingKeys = getCachedCollection(collectionKey, existingKeys); + const cachedCollectionForExistingKeys = getCachedCollection(collectionKey, existingKeys); - const existingKeyCollection = existingKeys.reduce((obj: OnyxInputKeyValueMapping, key) => { - const {isCompatible, existingValueType, newValueType} = utils.checkCompatibilityWithExistingValue(resultCollection[key], cachedCollectionForExistingKeys[key]); + const existingKeyCollection = existingKeys.reduce((obj: OnyxInputKeyValueMapping, key) => { + const {isCompatible, existingValueType, newValueType} = utils.checkCompatibilityWithExistingValue(resultCollection[key], cachedCollectionForExistingKeys[key]); - if (!isCompatible) { - Logger.logAlert(logMessages.incompatibleUpdateAlert(key, 'mergeCollection', existingValueType, newValueType)); - return obj; - } + if (!isCompatible) { + Logger.logAlert(logMessages.incompatibleUpdateAlert(key, 'mergeCollection', existingValueType, newValueType)); + return obj; + } - // eslint-disable-next-line no-param-reassign - obj[key] = resultCollection[key]; - return obj; - }, {}) as Record>; + // eslint-disable-next-line no-param-reassign + obj[key] = resultCollection[key]; + return obj; + }, {}) as Record>; - const newCollection: Record> = {}; - for (const key of keys) { - if (persistedKeys.has(key)) { - continue; + const newCollection: Record> = {}; + for (const key of keys) { + if (persistedKeys.has(key)) { + continue; + } + newCollection[key] = resultCollection[key]; } - newCollection[key] = resultCollection[key]; - } - - // When (multi-)merging the values with the existing values in storage, - // we don't want to remove nested null values from the data that we pass to the storage layer, - // because the storage layer uses them to remove nested keys from storage natively. - const keyValuePairsForExistingCollection = prepareKeyValuePairsForStorage(existingKeyCollection, false, mergeReplaceNullPatches); - // We can safely remove nested null values when using (multi-)set, - // because we will simply overwrite the existing values in storage. - const keyValuePairsForNewCollection = prepareKeyValuePairsForStorage(newCollection, true); + // When (multi-)merging the values with the existing values in storage, + // we don't want to remove nested null values from the data that we pass to the storage layer, + // because the storage layer uses them to remove nested keys from storage natively. + const keyValuePairsForExistingCollection = prepareKeyValuePairsForStorage(existingKeyCollection, false, mergeReplaceNullPatches); - const promises = []; + // We can safely remove nested null values when using (multi-)set, + // because we will simply overwrite the existing values in storage. + const keyValuePairsForNewCollection = prepareKeyValuePairsForStorage(newCollection, true); - // We need to get the previously existing values so we can compare the new ones - // against them, to avoid unnecessary subscriber updates. - const previousCollectionPromise = Promise.all(existingKeys.map((key) => get(key).then((value) => [key, value]))).then(Object.fromEntries); + const promises = []; - // New keys will be added via multiSet while existing keys will be updated using multiMerge - // This is because setting a key that doesn't exist yet with multiMerge will throw errors - // We can skip this step for RAM-only keys as they should never be saved to storage - if (!isRamOnlyKey(collectionKey) && keyValuePairsForExistingCollection.length > 0) { - promises.push(Storage.multiMerge(keyValuePairsForExistingCollection)); - } + // We need to get the previously existing values so we can compare the new ones + // against them, to avoid unnecessary subscriber updates. + const previousCollectionPromise = Promise.all(existingKeys.map((key) => get(key).then((value) => [key, value]))).then(Object.fromEntries); - // We can skip this step for RAM-only keys as they should never be saved to storage - if (!isRamOnlyKey(collectionKey) && keyValuePairsForNewCollection.length > 0) { - promises.push(Storage.multiSet(keyValuePairsForNewCollection)); - } + // New keys will be added via multiSet while existing keys will be updated using multiMerge + // This is because setting a key that doesn't exist yet with multiMerge will throw errors + // We can skip this step for RAM-only keys as they should never be saved to storage + if (!isRamOnlyKey(collectionKey) && keyValuePairsForExistingCollection.length > 0) { + promises.push(Storage.multiMerge(keyValuePairsForExistingCollection)); + } - // finalMergedCollection contains all the keys that were merged, without the keys of incompatible updates - const finalMergedCollection = {...existingKeyCollection, ...newCollection}; + // We can skip this step for RAM-only keys as they should never be saved to storage + if (!isRamOnlyKey(collectionKey) && keyValuePairsForNewCollection.length > 0) { + promises.push(Storage.multiSet(keyValuePairsForNewCollection)); + } - // Prefill cache if necessary by calling get() on any existing keys and then merge original data to cache - // and update all subscribers - const promiseUpdate = previousCollectionPromise.then((previousCollection) => { - cache.merge(finalMergedCollection); - keysChanged(collectionKey, finalMergedCollection, previousCollection); - }); + // finalMergedCollection contains all the keys that were merged, without the keys of incompatible updates + const finalMergedCollection = {...existingKeyCollection, ...newCollection}; - return Promise.all(promises) - .catch((error) => - retryOperation( - error, - mergeCollectionWithPatches, - {collectionKey, collection: resultCollection as OnyxMergeCollectionInput, mergeReplaceNullPatches, isProcessingCollectionUpdate}, - retryAttempt, - ), - ) - .then(() => { - sendActionToDevTools(METHOD.MERGE_COLLECTION, undefined, resultCollection); - return promiseUpdate; + // Prefill cache if necessary by calling get() on any existing keys and then merge original data to cache + // and update all subscribers + const promiseUpdate = previousCollectionPromise.then((previousCollection) => { + cache.merge(finalMergedCollection); + keysChanged(collectionKey, finalMergedCollection, previousCollection); }); - }); + + return Promise.all(promises) + .catch((error) => + retryOperation( + error, + mergeCollectionWithPatches, + {collectionKey, collection: resultCollection as OnyxMergeCollectionInput, mergeReplaceNullPatches, isProcessingCollectionUpdate}, + retryAttempt, + ), + ) + .then(() => { + sendActionToDevTools(METHOD.MERGE_COLLECTION, undefined, resultCollection); + return promiseUpdate; + }); + }) + .then(() => undefined); } /** From 74d444be2673e4da6090819646a7c364a68913bb Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 25 Feb 2026 09:18:20 +0100 Subject: [PATCH 16/19] Move up lastConnectionCallbackData.set --- lib/OnyxUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index dd08e6be7..06d17c136 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -686,8 +686,9 @@ function keysChanged( // If they are subscribed to the collection key and using waitForCollectionCallback then we'll // send the whole cached collection. if (isSubscribedToCollectionKey) { + lastConnectionCallbackData.set(subscriber.subscriptionID, cachedCollection); + if (subscriber.waitForCollectionCallback) { - lastConnectionCallbackData.set(subscriber.subscriptionID, cachedCollection); subscriber.callback(cachedCollection, subscriber.key, partialCollection); continue; } @@ -702,7 +703,6 @@ function keysChanged( subscriber.callback(cachedCollection[dataKey], dataKey); } - lastConnectionCallbackData.set(subscriber.subscriptionID, cachedCollection); continue; } From 3d96fe94a4c6703266fa6c06a82a71ed2f56e9d5 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 25 Feb 2026 09:36:33 +0100 Subject: [PATCH 17/19] Re-run perf test From 5d161c8823fb7171a94226c6f5738a9e9da4c9f0 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 25 Feb 2026 09:46:17 +0100 Subject: [PATCH 18/19] Revert "Move up lastConnectionCallbackData.set" This reverts commit 74d444be2673e4da6090819646a7c364a68913bb. --- lib/OnyxUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 06d17c136..dd08e6be7 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -686,9 +686,8 @@ function keysChanged( // If they are subscribed to the collection key and using waitForCollectionCallback then we'll // send the whole cached collection. if (isSubscribedToCollectionKey) { - lastConnectionCallbackData.set(subscriber.subscriptionID, cachedCollection); - if (subscriber.waitForCollectionCallback) { + lastConnectionCallbackData.set(subscriber.subscriptionID, cachedCollection); subscriber.callback(cachedCollection, subscriber.key, partialCollection); continue; } @@ -703,6 +702,7 @@ function keysChanged( subscriber.callback(cachedCollection[dataKey], dataKey); } + lastConnectionCallbackData.set(subscriber.subscriptionID, cachedCollection); continue; } From ea9f16e5526cb88c308d8eb7d7862a43c87cae80 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 25 Feb 2026 17:28:27 +0100 Subject: [PATCH 19/19] Reapply "Move up lastConnectionCallbackData.set" This reverts commit 5d161c8823fb7171a94226c6f5738a9e9da4c9f0. --- lib/OnyxUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index dd08e6be7..06d17c136 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -686,8 +686,9 @@ function keysChanged( // If they are subscribed to the collection key and using waitForCollectionCallback then we'll // send the whole cached collection. if (isSubscribedToCollectionKey) { + lastConnectionCallbackData.set(subscriber.subscriptionID, cachedCollection); + if (subscriber.waitForCollectionCallback) { - lastConnectionCallbackData.set(subscriber.subscriptionID, cachedCollection); subscriber.callback(cachedCollection, subscriber.key, partialCollection); continue; } @@ -702,7 +703,6 @@ function keysChanged( subscriber.callback(cachedCollection[dataKey], dataKey); } - lastConnectionCallbackData.set(subscriber.subscriptionID, cachedCollection); continue; }