From 2f404e649c96264b849e8ffeb5cd20bf496083ed Mon Sep 17 00:00:00 2001 From: VickyStash Date: Thu, 16 Oct 2025 16:03:47 +0200 Subject: [PATCH 01/15] Remove the current batching mechanism --- jestSetup.js | 3 - lib/OnyxUtils.ts | 81 +++++--------------------- lib/batch.native.ts | 3 - lib/batch.ts | 3 - package-lock.json | 27 --------- package.json | 3 - tests/perf-test/OnyxUtils.perf-test.ts | 7 --- tests/unit/onyxUtilsTest.ts | 1 - 8 files changed, 13 insertions(+), 115 deletions(-) delete mode 100644 lib/batch.native.ts delete mode 100644 lib/batch.ts diff --git a/jestSetup.js b/jestSetup.js index 82f8f4d5d..156828a67 100644 --- a/jestSetup.js +++ b/jestSetup.js @@ -10,6 +10,3 @@ jest.mock('react-native-nitro-sqlite', () => ({ })); jest.useRealTimers(); - -const unstable_batchedUpdates_jest = require('react-test-renderer').unstable_batchedUpdates; -require('./lib/batch.native').default = unstable_batchedUpdates_jest; diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 39e8afd06..f87638e6e 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -8,7 +8,6 @@ import * as Logger from './Logger'; import type Onyx from './Onyx'; import cache, {TASK} from './OnyxCache'; import * as Str from './Str'; -import unstable_batchedUpdates from './batch'; import Storage from './storage'; import type { CollectionKey, @@ -67,9 +66,6 @@ let onyxKeyToSubscriptionIDs = new Map(); // Optional user-provided key value states set when Onyx initializes or clears let defaultKeyStates: Record> = {}; -let batchUpdatesPromise: Promise | null = null; -let batchUpdatesQueue: Array<() => void> = []; - // Used for comparison with a new update to avoid invoking the Onyx.connect callback with the same data. let lastConnectionCallbackData = new Map>(); @@ -191,43 +187,6 @@ function sendActionToDevTools( DevTools.registerAction(utils.formatActionName(method, key), value, key ? {[key]: mergedValue || value} : (value as OnyxCollection)); } -/** - * We are batching together onyx updates. This helps with use cases where we schedule onyx updates after each other. - * This happens for example in the Onyx.update function, where we process API responses that might contain a lot of - * update operations. Instead of calling the subscribers for each update operation, we batch them together which will - * cause react to schedule the updates at once instead of after each other. This is mainly a performance optimization. - */ -function maybeFlushBatchUpdates(): Promise { - if (batchUpdatesPromise) { - return batchUpdatesPromise; - } - - batchUpdatesPromise = new Promise((resolve) => { - /* We use (setTimeout, 0) here which should be called once native module calls are flushed (usually at the end of the frame) - * We may investigate if (setTimeout, 1) (which in React Native is equal to requestAnimationFrame) works even better - * then the batch will be flushed on next frame. - */ - setTimeout(() => { - const updatesCopy = batchUpdatesQueue; - batchUpdatesQueue = []; - batchUpdatesPromise = null; - unstable_batchedUpdates(() => { - updatesCopy.forEach((applyUpdates) => { - applyUpdates(); - }); - }); - - resolve(); - }, 0); - }); - return batchUpdatesPromise; -} - -function batchUpdates(updates: () => void): Promise { - batchUpdatesQueue.push(updates); - return maybeFlushBatchUpdates(); -} - /** * Takes a collection of items (eg. {testKey_1:{a:'a'}, testKey_2:{b:'b'}}) * and runs it through a reducer function to return a subset of the data according to a selector. @@ -597,7 +556,6 @@ function keysChanged( collectionKey: TKey, partialCollection: OnyxCollection, partialPreviousCollection: OnyxCollection | undefined, - notifyConnectSubscribers = true, ): void { // We prepare the "cached collection" which is the entire collection + the new partial data that // was merged in via mergeCollection(). @@ -633,10 +591,6 @@ function keysChanged( // Regular Onyx.connect() subscriber found. if (typeof subscriber.callback === 'function') { - if (!notifyConnectSubscribers) { - continue; - } - // If they are subscribed to the collection key and using waitForCollectionCallback then we'll // send the whole cached collection. if (isSubscribedToCollectionKey) { @@ -682,12 +636,7 @@ function keysChanged( * @example * keyChanged(key, value, subscriber => subscriber.initWithStoredValues === false) */ -function keyChanged( - key: TKey, - value: OnyxValue, - canUpdateSubscriber: (subscriber?: CallbackToStateMapping) => boolean = () => true, - notifyConnectSubscribers = true, -): void { +function keyChanged(key: TKey, value: OnyxValue, canUpdateSubscriber: (subscriber?: CallbackToStateMapping) => boolean = () => true): void { // Add or remove this key from the recentlyAccessedKeys lists if (value !== null) { cache.addLastAccessedKey(key, isCollectionKey(key)); @@ -727,9 +676,6 @@ function keyChanged( // Subscriber is a regular call to connect() and provided a callback if (typeof subscriber.callback === 'function') { - if (!notifyConnectSubscribers) { - continue; - } if (lastConnectionCallbackData.has(subscriber.subscriptionID) && lastConnectionCallbackData.get(subscriber.subscriptionID) === value) { continue; } @@ -818,9 +764,11 @@ function scheduleSubscriberUpdate( value: OnyxValue, canUpdateSubscriber: (subscriber?: CallbackToStateMapping) => boolean = () => true, ): Promise { - const promise = Promise.resolve().then(() => keyChanged(key, value, canUpdateSubscriber, true)); - batchUpdates(() => keyChanged(key, value, canUpdateSubscriber, false)); - return Promise.all([maybeFlushBatchUpdates(), promise]).then(() => undefined); + const promise0 = new Promise((resolve) => { + setTimeout(resolve, 0); + }); + const promise = Promise.resolve().then(() => keyChanged(key, value, canUpdateSubscriber)); + return Promise.all([promise0, promise]).then(() => undefined); } /** @@ -833,9 +781,13 @@ function scheduleNotifyCollectionSubscribers( value: OnyxCollection, previousValue?: OnyxCollection, ): Promise { - const promise = Promise.resolve().then(() => keysChanged(key, value, previousValue, true)); - batchUpdates(() => keysChanged(key, value, previousValue, false)); - return Promise.all([maybeFlushBatchUpdates(), promise]).then(() => undefined); + const promise0 = new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, 0); + }); + const promise = Promise.resolve().then(() => keysChanged(key, value, previousValue)); + return Promise.all([promise0, promise]).then(() => undefined); } /** @@ -1420,7 +1372,6 @@ function clearOnyxUtilsInternals() { mergeQueuePromise = {}; callbackToStateMapping = {}; onyxKeyToSubscriptionIDs = new Map(); - batchUpdatesQueue = []; lastConnectionCallbackData = new Map(); } @@ -1432,8 +1383,6 @@ const OnyxUtils = { getDeferredInitTask, initStoreValues, sendActionToDevTools, - maybeFlushBatchUpdates, - batchUpdates, get, getAllKeys, getCollectionKeys, @@ -1487,10 +1436,6 @@ GlobalSettings.addGlobalSettingsChangeListener(({enablePerformanceMetrics}) => { // @ts-expect-error Reassign initStoreValues = decorateWithMetrics(initStoreValues, 'OnyxUtils.initStoreValues'); - // @ts-expect-error Reassign - maybeFlushBatchUpdates = decorateWithMetrics(maybeFlushBatchUpdates, 'OnyxUtils.maybeFlushBatchUpdates'); - // @ts-expect-error Reassign - batchUpdates = decorateWithMetrics(batchUpdates, 'OnyxUtils.batchUpdates'); // @ts-expect-error Complex type signature get = decorateWithMetrics(get, 'OnyxUtils.get'); // @ts-expect-error Reassign diff --git a/lib/batch.native.ts b/lib/batch.native.ts deleted file mode 100644 index fb7ef4ee5..000000000 --- a/lib/batch.native.ts +++ /dev/null @@ -1,3 +0,0 @@ -import {unstable_batchedUpdates} from 'react-native'; - -export default unstable_batchedUpdates; diff --git a/lib/batch.ts b/lib/batch.ts deleted file mode 100644 index 3ff0368fe..000000000 --- a/lib/batch.ts +++ /dev/null @@ -1,3 +0,0 @@ -import {unstable_batchedUpdates} from 'react-dom'; - -export default unstable_batchedUpdates; diff --git a/package-lock.json b/package-lock.json index 16b6c2bc5..3e2240108 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,6 @@ "@types/lodash": "^4.14.202", "@types/node": "^20.11.5", "@types/react": "^18.2.14", - "@types/react-dom": "^18.2.18", "@types/react-native": "^0.70.0", "@types/underscore": "^1.11.15", "@typescript-eslint/eslint-plugin": "^6.19.0", @@ -53,7 +52,6 @@ "prettier": "^2.8.8", "prop-types": "^15.7.2", "react": "18.2.0", - "react-dom": "18.2.0", "react-native": "0.76.3", "react-native-device-info": "^10.3.0", "react-native-nitro-modules": "^0.26.2", @@ -72,7 +70,6 @@ "peerDependencies": { "idb-keyval": "^6.2.1", "react": ">=18.1.0", - "react-dom": ">=18.1.0", "react-native": ">=0.75.0", "react-native-device-info": "^10.3.0", "react-native-nitro-modules": ">=0.26.2", @@ -4111,16 +4108,6 @@ "csstype": "^3.0.2" } }, - "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, "node_modules/@types/react-native": { "version": "0.70.19", "resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.70.19.tgz", @@ -12745,20 +12732,6 @@ } } }, - "node_modules/react-dom": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", - "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.0" - }, - "peerDependencies": { - "react": "^18.2.0" - } - }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", diff --git a/package.json b/package.json index 6200c179a..94e25eaea 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,6 @@ "@types/lodash": "^4.14.202", "@types/node": "^20.11.5", "@types/react": "^18.2.14", - "@types/react-dom": "^18.2.18", "@types/react-native": "^0.70.0", "@types/underscore": "^1.11.15", "@typescript-eslint/eslint-plugin": "^6.19.0", @@ -86,7 +85,6 @@ "prettier": "^2.8.8", "prop-types": "^15.7.2", "react": "18.2.0", - "react-dom": "18.2.0", "react-native": "0.76.3", "react-native-device-info": "^10.3.0", "react-native-nitro-modules": "^0.26.2", @@ -101,7 +99,6 @@ "peerDependencies": { "idb-keyval": "^6.2.1", "react": ">=18.1.0", - "react-dom": ">=18.1.0", "react-native": ">=0.75.0", "react-native-device-info": "^10.3.0", "react-native-nitro-modules": ">=0.26.2", diff --git a/tests/perf-test/OnyxUtils.perf-test.ts b/tests/perf-test/OnyxUtils.perf-test.ts index 5a817b859..f04deb931 100644 --- a/tests/perf-test/OnyxUtils.perf-test.ts +++ b/tests/perf-test/OnyxUtils.perf-test.ts @@ -112,13 +112,6 @@ describe('OnyxUtils', () => { }); }); - describe('batchUpdates / maybeFlushBatchUpdates', () => { - test('one call with 1k updates', async () => { - const updates: Array<() => void> = Array.from({length: 1000}, () => jest.fn); - await measureAsyncFunction(() => Promise.all(updates.map((update) => OnyxUtils.batchUpdates(update)))); - }); - }); - describe('get', () => { test('10k calls with heavy objects', async () => { await measureAsyncFunction(() => Promise.all(mockedReportActionsKeys.map((key) => OnyxUtils.get(key))), { diff --git a/tests/unit/onyxUtilsTest.ts b/tests/unit/onyxUtilsTest.ts index 9b120a580..88ce3e8dd 100644 --- a/tests/unit/onyxUtilsTest.ts +++ b/tests/unit/onyxUtilsTest.ts @@ -283,7 +283,6 @@ describe('OnyxUtils', () => { ONYXKEYS.COLLECTION.TEST_KEY, {[entryKey]: updatedEntryData}, // new collection initialCollection, // previous collection - true, // notify connect subscribers ); // Should be called again because data changed From fec6d6bcebc086f1ad14170b4d91706df03fe1d1 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Thu, 16 Oct 2025 16:18:09 +0200 Subject: [PATCH 02/15] Make code more readable --- lib/OnyxUtils.ts | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index f87638e6e..c7421dcfe 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -753,6 +753,13 @@ function getCollectionDataAndSendAsObject(matchingKeys: Co }); } +// !!!DO NOT MERGE THIS CODE, METHODS FOR READABILITY ONLY +const nextMicrotask = () => Promise.resolve(); +const nextMacrotask = () => + new Promise((resolve) => { + setTimeout(resolve, 0); + }); + /** * Schedules an update that will be appended to the macro task queue (so it doesn't update the subscribers immediately). * @@ -764,11 +771,7 @@ function scheduleSubscriberUpdate( value: OnyxValue, canUpdateSubscriber: (subscriber?: CallbackToStateMapping) => boolean = () => true, ): Promise { - const promise0 = new Promise((resolve) => { - setTimeout(resolve, 0); - }); - const promise = Promise.resolve().then(() => keyChanged(key, value, canUpdateSubscriber)); - return Promise.all([promise0, promise]).then(() => undefined); + return Promise.all([nextMacrotask(), nextMicrotask().then(() => keyChanged(key, value, canUpdateSubscriber))]).then(() => undefined); } /** @@ -781,13 +784,7 @@ function scheduleNotifyCollectionSubscribers( value: OnyxCollection, previousValue?: OnyxCollection, ): Promise { - const promise0 = new Promise((resolve) => { - setTimeout(() => { - resolve(); - }, 0); - }); - const promise = Promise.resolve().then(() => keysChanged(key, value, previousValue)); - return Promise.all([promise0, promise]).then(() => undefined); + return Promise.all([nextMacrotask(), nextMicrotask().then(() => keysChanged(key, value, previousValue))]).then(() => undefined); } /** From 6308802a1f0a522d0a181be42afb57bd68e5d70e Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 21 Oct 2025 16:19:27 +0200 Subject: [PATCH 03/15] Have one next macrotask instead of multiple --- lib/OnyxUtils.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index c7421dcfe..a0e9c2842 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -755,9 +755,13 @@ function getCollectionDataAndSendAsObject(matchingKeys: Co // !!!DO NOT MERGE THIS CODE, METHODS FOR READABILITY ONLY const nextMicrotask = () => Promise.resolve(); +let nextMacrotaskPromise: Promise | null = null; const nextMacrotask = () => new Promise((resolve) => { - setTimeout(resolve, 0); + setTimeout(() => { + nextMacrotaskPromise = null; + resolve(); + }, 0); }); /** @@ -771,7 +775,10 @@ function scheduleSubscriberUpdate( value: OnyxValue, canUpdateSubscriber: (subscriber?: CallbackToStateMapping) => boolean = () => true, ): Promise { - return Promise.all([nextMacrotask(), nextMicrotask().then(() => keyChanged(key, value, canUpdateSubscriber))]).then(() => undefined); + if (!nextMacrotaskPromise) { + nextMacrotaskPromise = nextMacrotask(); + } + return Promise.all([nextMacrotaskPromise, nextMicrotask().then(() => keyChanged(key, value, canUpdateSubscriber))]).then(() => undefined); } /** @@ -784,7 +791,10 @@ function scheduleNotifyCollectionSubscribers( value: OnyxCollection, previousValue?: OnyxCollection, ): Promise { - return Promise.all([nextMacrotask(), nextMicrotask().then(() => keysChanged(key, value, previousValue))]).then(() => undefined); + if (!nextMacrotaskPromise) { + nextMacrotaskPromise = nextMacrotask(); + } + return Promise.all([nextMacrotaskPromise, nextMicrotask().then(() => keysChanged(key, value, previousValue))]).then(() => undefined); } /** From e93419ee903b7bfa881909bc6a2a003e2922b5ea Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 7 Jan 2026 10:15:13 +0100 Subject: [PATCH 04/15] Fix TS error --- lib/Onyx.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Onyx.ts b/lib/Onyx.ts index 27751ae60..bebe3cb9a 100644 --- a/lib/Onyx.ts +++ b/lib/Onyx.ts @@ -62,7 +62,7 @@ function init({ // Setting isProcessingCollectionUpdate=true prevents triggering collection callbacks for each individual update const isKeyCollectionMember = OnyxUtils.isCollectionMember(key); - OnyxUtils.keyChanged(key, value as OnyxValue, undefined, true, isKeyCollectionMember); + OnyxUtils.keyChanged(key, value as OnyxValue, undefined, isKeyCollectionMember); }); } From b0bad9815a73e84c7b5c6a7da333b083458ecc80 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 7 Jan 2026 16:24:58 +0100 Subject: [PATCH 05/15] Improve the logic to schedule the macrotask only when needed --- lib/OnyxUtils.ts | 68 +++++++++++++++++++++++++++++------------------- 1 file changed, 41 insertions(+), 27 deletions(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index b73c7185b..d9ed15d61 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -85,6 +85,9 @@ let onyxCollectionKeySet = new Set(); // Holds a mapping of the connected key to the subscriptionID for faster lookups let onyxKeyToSubscriptionIDs = new Map(); +// Keys with subscriptions currently being established +const pendingSubscriptionKeys = new Set(); + // Optional user-provided key value states set when Onyx initializes or clears let defaultKeyStates: Record> = {}; @@ -801,16 +804,31 @@ function getCollectionDataAndSendAsObject(matchingKeys: Co }); } -// !!!DO NOT MERGE THIS CODE, METHODS FOR READABILITY ONLY -const nextMicrotask = () => Promise.resolve(); -let nextMacrotaskPromise: Promise | null = null; -const nextMacrotask = () => - new Promise((resolve) => { - setTimeout(() => { - nextMacrotaskPromise = null; - resolve(); - }, 0); - }); +/** Helps to schedule subscriber update. Schedule the macrotask if the key subscription is in progress to avoid race condition. + * + * @param key Onyx key + * @param callback The keyChanged/keysChanged callback + * */ +function prepareSubscriberUpdate(key: TKey, callback: () => void): Promise { + let collectionKey: string | undefined; + try { + collectionKey = getCollectionKey(key); + } catch (e) { + // If getCollectionKey() throws an error it means the key is not a collection key. + collectionKey = undefined; + } + + callback(); + + // If subscription is in progress, schedule a macrotask to prevent race condition with data from subscribeToKey deferred logic. + if (pendingSubscriptionKeys.has(key) || (collectionKey && pendingSubscriptionKeys.has(collectionKey))) { + return new Promise((resolve) => { + setTimeout(() => resolve()); + }); + } + + return Promise.resolve(); +} /** * Schedules an update that will be appended to the macro task queue (so it doesn't update the subscribers immediately). @@ -824,14 +842,11 @@ function scheduleSubscriberUpdate( canUpdateSubscriber: (subscriber?: CallbackToStateMapping) => boolean = () => true, isProcessingCollectionUpdate = false, ): Promise { - if (!nextMacrotaskPromise) { - nextMacrotaskPromise = nextMacrotask(); - } - return Promise.all([nextMacrotaskPromise, nextMicrotask().then(() => keyChanged(key, value, canUpdateSubscriber, isProcessingCollectionUpdate))]).then(() => undefined); + return prepareSubscriberUpdate(key, () => keyChanged(key, value, canUpdateSubscriber, isProcessingCollectionUpdate)); } /** - * This method is similar to notifySubscribersOnNextTick but it is built for working specifically with collections + * 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. */ @@ -840,10 +855,7 @@ function scheduleNotifyCollectionSubscribers( value: OnyxCollection, previousValue?: OnyxCollection, ): Promise { - if (!nextMacrotaskPromise) { - nextMacrotaskPromise = nextMacrotask(); - } - return Promise.all([nextMacrotaskPromise, nextMicrotask().then(() => keysChanged(key, value, previousValue))]).then(() => undefined); + return prepareSubscriberUpdate(key, () => keysChanged(key, value, previousValue)); } /** @@ -1092,7 +1104,10 @@ function subscribeToKey(connectOptions: ConnectOptions(connectOptions: ConnectOptions(connectOptions: ConnectOptions { + return multiGet(matchingKeys).then((values) => { values.forEach((val, key) => { sendDataToConnection(mapping, val as OnyxValue, key as TKey); }); }); - return; } // If we are not subscribed to a collection key then there's only a single key to send an update for. - get(mapping.key).then((val) => sendDataToConnection(mapping, val as OnyxValue, mapping.key)); - return; + return get(mapping.key).then((val) => sendDataToConnection(mapping, val as OnyxValue, mapping.key)); } console.error('Warning: Onyx.connect() was found without a callback'); + }) + .then(() => { + pendingSubscriptionKeys.delete(mapping.key); }); // The subscriptionID is returned back to the caller so that it can be used to clean up the connection when it's no longer needed From 605bc69a1324f9cad057530bd805b2e9504cff35 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 7 Jan 2026 16:53:04 +0100 Subject: [PATCH 06/15] Re-run checks From 4e3e9568e1ea06f2739edee768838f2d413ccc71 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 7 Jan 2026 17:12:55 +0100 Subject: [PATCH 07/15] Re-run reassure check From e423194520b5e9cf66d9a00d6255d14c0ed5e14e Mon Sep 17 00:00:00 2001 From: VickyStash Date: Thu, 8 Jan 2026 12:39:42 +0100 Subject: [PATCH 08/15] Fix E/App tests --- lib/OnyxUtils.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index d9ed15d61..29e407a5f 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -818,16 +818,15 @@ function prepareSubscriberUpdate(key: TKey, callback: () = collectionKey = undefined; } - callback(); - // If subscription is in progress, schedule a macrotask to prevent race condition with data from subscribeToKey deferred logic. if (pendingSubscriptionKeys.has(key) || (collectionKey && pendingSubscriptionKeys.has(collectionKey))) { - return new Promise((resolve) => { - setTimeout(() => resolve()); + const macrotaskPromise = new Promise((resolve) => { + setTimeout(() => resolve(), 0); }); + return Promise.all([macrotaskPromise, Promise.resolve().then(callback)]).then(); } - return Promise.resolve(); + return Promise.resolve().then(callback); } /** From fd10feadb68bd4f0547850053344eb12b539a333 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Thu, 8 Jan 2026 16:12:57 +0100 Subject: [PATCH 09/15] Fix the test of E/App tests --- lib/OnyxUtils.ts | 56 ++++++++++++++++++++---------------------------- 1 file changed, 23 insertions(+), 33 deletions(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 29e407a5f..05deec4b2 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -76,6 +76,9 @@ 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> = {}; @@ -85,9 +88,6 @@ let onyxCollectionKeySet = new Set(); // Holds a mapping of the connected key to the subscriptionID for faster lookups let onyxKeyToSubscriptionIDs = new Map(); -// Keys with subscriptions currently being established -const pendingSubscriptionKeys = new Set(); - // Optional user-provided key value states set when Onyx initializes or clears let defaultKeyStates: Record> = {}; @@ -804,29 +804,21 @@ function getCollectionDataAndSendAsObject(matchingKeys: Co }); } -/** Helps to schedule subscriber update. Schedule the macrotask if the key subscription is in progress to avoid race condition. +/** + * Delays promise resolution until the next macrotask to prevent race condition if the key subscription is in progress. * - * @param key Onyx key * @param callback The keyChanged/keysChanged callback * */ -function prepareSubscriberUpdate(key: TKey, callback: () => void): Promise { - let collectionKey: string | undefined; - try { - collectionKey = getCollectionKey(key); - } catch (e) { - // If getCollectionKey() throws an error it means the key is not a collection key. - collectionKey = undefined; - } - - // If subscription is in progress, schedule a macrotask to prevent race condition with data from subscribeToKey deferred logic. - if (pendingSubscriptionKeys.has(key) || (collectionKey && pendingSubscriptionKeys.has(collectionKey))) { - const macrotaskPromise = new Promise((resolve) => { - setTimeout(() => resolve(), 0); +function prepareSubscriberUpdate(callback: () => void): Promise { + if (!nextMacrotaskPromise) { + nextMacrotaskPromise = new Promise((resolve) => { + setTimeout(() => { + nextMacrotaskPromise = null; + resolve(); + }, 0); }); - return Promise.all([macrotaskPromise, Promise.resolve().then(callback)]).then(); } - - return Promise.resolve().then(callback); + return Promise.all([nextMacrotaskPromise, Promise.resolve().then(callback)]).then(); } /** @@ -841,7 +833,7 @@ function scheduleSubscriberUpdate( canUpdateSubscriber: (subscriber?: CallbackToStateMapping) => boolean = () => true, isProcessingCollectionUpdate = false, ): Promise { - return prepareSubscriberUpdate(key, () => keyChanged(key, value, canUpdateSubscriber, isProcessingCollectionUpdate)); + return prepareSubscriberUpdate(() => keyChanged(key, value, canUpdateSubscriber, isProcessingCollectionUpdate)); } /** @@ -854,7 +846,7 @@ function scheduleNotifyCollectionSubscribers( value: OnyxCollection, previousValue?: OnyxCollection, ): Promise { - return prepareSubscriberUpdate(key, () => keysChanged(key, value, previousValue)); + return prepareSubscriberUpdate(() => keysChanged(key, value, previousValue)); } /** @@ -1103,10 +1095,7 @@ function subscribeToKey(connectOptions: ConnectOptions(connectOptions: ConnectOptions(connectOptions: ConnectOptions { + multiGet(matchingKeys).then((values) => { values.forEach((val, key) => { sendDataToConnection(mapping, val as OnyxValue, key as TKey); }); }); + return; } // If we are not subscribed to a collection key then there's only a single key to send an update for. - return get(mapping.key).then((val) => sendDataToConnection(mapping, val as OnyxValue, mapping.key)); + get(mapping.key).then((val) => sendDataToConnection(mapping, val as OnyxValue, mapping.key)); + return; } console.error('Warning: Onyx.connect() was found without a callback'); - }) - .then(() => { - pendingSubscriptionKeys.delete(mapping.key); }); // The subscriptionID is returned back to the caller so that it can be used to clean up the connection when it's no longer needed From 798ac42821426544a99da590b9c2fe6984c43bf9 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Mon, 12 Jan 2026 12:16:15 +0100 Subject: [PATCH 10/15] Re-run reassure test From 19fa64583ba9fd7bbaef70af8b744efbd706b2b0 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Mon, 12 Jan 2026 18:06:30 +0100 Subject: [PATCH 11/15] Update API docs --- API-INTERNAL.md | 48 +++++++++++++++++++++++++++++++----------------- API.md | 2 +- 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/API-INTERNAL.md b/API-INTERNAL.md index 38a5d76c8..4e29291a4 100644 --- a/API-INTERNAL.md +++ b/API-INTERNAL.md @@ -26,12 +26,6 @@
initStoreValues(keys, initialKeyStates, evictableKeys)

Sets the initial values for the Onyx store

-
maybeFlushBatchUpdates()
-

We are batching together onyx updates. This helps with use cases where we schedule onyx updates after each other. -This happens for example in the Onyx.update function, where we process API responses that might contain a lot of -update operations. Instead of calling the subscribers for each update operation, we batch them together which will -cause react to schedule the updates at once instead of after each other. This is mainly a performance optimization.

-
reduceCollectionWithSelector()

Takes a collection of items (eg. {testKey_1:{a:'a'}, testKey_2:{b:'b'}}) and runs it through a reducer function to return a subset of the data according to a selector. @@ -61,6 +55,9 @@ to the values for those keys (correctly typed) such as [OnyxCollection<

Checks to see if the subscriber's supplied key is associated with a collection of keys.

+
isCollectionMember(key)
+

Checks if a given key is a collection member key (not just a collection key).

+
splitCollectionMemberKey(key, collectionKey)

Splits a collection member key into the collection key part and the ID part.

@@ -98,11 +95,14 @@ run out of storage the least recently accessed key can be removed.

getCollectionDataAndSendAsObject()

Gets the data for a given an array of matching keys, combines them into an object, and sends the result back to the subscriber.

+
prepareSubscriberUpdate(callback)
+

Delays promise resolution until the next macrotask to prevent race condition if the key subscription is in progress.

+
scheduleSubscriberUpdate()

Schedules an update that will be appended to the macro task queue (so it doesn't update the subscribers immediately).

scheduleNotifyCollectionSubscribers()
-

This method is similar to notifySubscribersOnNextTick but it is built for working specifically with collections +

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.

@@ -230,15 +230,6 @@ Sets the initial values for the Onyx store | initialKeyStates | initial data to set when `init()` and `clear()` are called | | evictableKeys | This is an array of keys (individual or collection patterns) that when provided to Onyx are flagged as "safe" for removal. | - - -## maybeFlushBatchUpdates() -We are batching together onyx updates. This helps with use cases where we schedule onyx updates after each other. -This happens for example in the Onyx.update function, where we process API responses that might contain a lot of -update operations. Instead of calling the subscribers for each update operation, we batch them together which will -cause react to schedule the updates at once instead of after each other. This is mainly a performance optimization. - -**Kind**: global function ## reduceCollectionWithSelector() @@ -304,6 +295,18 @@ Checks to see if the subscriber's supplied key is associated with a collection of keys. **Kind**: global function + + +## isCollectionMember(key) ⇒ +Checks if a given key is a collection member key (not just a collection key). + +**Kind**: global function +**Returns**: true if the key is a collection member, false otherwise + +| Param | Description | +| --- | --- | +| key | The key to check | + ## splitCollectionMemberKey(key, collectionKey) ⇒ @@ -402,6 +405,17 @@ run out of storage the least recently accessed key can be removed. Gets the data for a given an array of matching keys, combines them into an object, and sends the result back to the subscriber. **Kind**: global function + + +## prepareSubscriberUpdate(callback) +Delays promise resolution until the next macrotask to prevent race condition if the key subscription is in progress. + +**Kind**: global function + +| Param | Description | +| --- | --- | +| callback | The keyChanged/keysChanged callback | + ## scheduleSubscriberUpdate() @@ -415,7 +429,7 @@ scheduleSubscriberUpdate(key, value, subscriber => subscriber.initWithStoredValu ## scheduleNotifyCollectionSubscribers() -This method is similar to notifySubscribersOnNextTick but it is built for working specifically with collections +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. diff --git a/API.md b/API.md index 8870f7885..545adbfab 100644 --- a/API.md +++ b/API.md @@ -177,7 +177,7 @@ applied in the order they were called. Note: `Onyx.set()` calls do not work this **Example** ```js Onyx.merge(ONYXKEYS.EMPLOYEE_LIST, ['Joe']); // -> ['Joe'] -Onyx.merge(ONYXKEYS.EMPLOYEE_LIST, ['Jack']); // -> ['Jack'] +Onyx.merge(ONYXKEYS.EMPLOYEE_LIST, ['Jack']); // -> ['Joe', 'Jack'] Onyx.merge(ONYXKEYS.POLICY, {id: 1}); // -> {id: 1} Onyx.merge(ONYXKEYS.POLICY, {name: 'My Workspace'}); // -> {id: 1, name: 'My Workspace'} ``` From eb000c61645ca4e5b4547c3b36ce284b99083854 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 14 Jan 2026 16:06:21 +0100 Subject: [PATCH 12/15] Update test --- tests/perf-test/OnyxUtils.perf-test.ts | 42 +++++++++++++------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/tests/perf-test/OnyxUtils.perf-test.ts b/tests/perf-test/OnyxUtils.perf-test.ts index 28e30e798..695858c9e 100644 --- a/tests/perf-test/OnyxUtils.perf-test.ts +++ b/tests/perf-test/OnyxUtils.perf-test.ts @@ -312,29 +312,29 @@ describe('OnyxUtils', () => { }); describe('keyChanged', () => { - test('one call with one heavy object to update 10k subscribers', async () => { - const subscriptionIDs = new Set(); + const subscriptionIDs = new Set(); + const key = `${collectionKey}0`; + const previousReportAction = mockedReportActionsMap[`${collectionKey}0`]; - const key = `${collectionKey}0`; - const previousReportAction = mockedReportActionsMap[`${collectionKey}0`]; - const changedReportAction = createRandomReportAction(Number(previousReportAction.reportActionID)); + beforeEach(async () => { + await Onyx.set(key, previousReportAction); + for (let i = 0; i < 10000; i++) { + const id = OnyxUtils.subscribeToKey({key, callback: jest.fn(), initWithStoredValues: false}); + subscriptionIDs.add(id); + } + }); - await measureFunction(() => OnyxUtils.keyChanged(key, changedReportAction), { - beforeEach: async () => { - await Onyx.set(key, previousReportAction); - for (let i = 0; i < 10000; i++) { - const id = OnyxUtils.subscribeToKey({key, callback: jest.fn(), initWithStoredValues: false}); - subscriptionIDs.add(id); - } - }, - afterEach: async () => { - for (const id of subscriptionIDs) { - OnyxUtils.unsubscribeFromKey(id); - } - subscriptionIDs.clear(); - await clearOnyxAfterEachMeasure(); - }, - }); + afterEach(async () => { + for (const id of subscriptionIDs) { + OnyxUtils.unsubscribeFromKey(id); + } + subscriptionIDs.clear(); + await clearOnyxAfterEachMeasure(); + }); + + test('one call with one heavy object to update 10k subscribers', async () => { + const changedReportAction = createRandomReportAction(Number(previousReportAction.reportActionID)); + await measureFunction(() => OnyxUtils.keyChanged(key, changedReportAction)); }); }); From e89773d785ef66415f85a668aad3e0785bbbf438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Tue, 20 Jan 2026 08:52:35 +0000 Subject: [PATCH 13/15] Revert Reassure test change --- tests/perf-test/OnyxUtils.perf-test.ts | 42 +++++++++++++------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/tests/perf-test/OnyxUtils.perf-test.ts b/tests/perf-test/OnyxUtils.perf-test.ts index 695858c9e..28e30e798 100644 --- a/tests/perf-test/OnyxUtils.perf-test.ts +++ b/tests/perf-test/OnyxUtils.perf-test.ts @@ -312,29 +312,29 @@ describe('OnyxUtils', () => { }); describe('keyChanged', () => { - const subscriptionIDs = new Set(); - const key = `${collectionKey}0`; - const previousReportAction = mockedReportActionsMap[`${collectionKey}0`]; - - beforeEach(async () => { - await Onyx.set(key, previousReportAction); - for (let i = 0; i < 10000; i++) { - const id = OnyxUtils.subscribeToKey({key, callback: jest.fn(), initWithStoredValues: false}); - subscriptionIDs.add(id); - } - }); - - afterEach(async () => { - for (const id of subscriptionIDs) { - OnyxUtils.unsubscribeFromKey(id); - } - subscriptionIDs.clear(); - await clearOnyxAfterEachMeasure(); - }); - test('one call with one heavy object to update 10k subscribers', async () => { + const subscriptionIDs = new Set(); + + const key = `${collectionKey}0`; + const previousReportAction = mockedReportActionsMap[`${collectionKey}0`]; const changedReportAction = createRandomReportAction(Number(previousReportAction.reportActionID)); - await measureFunction(() => OnyxUtils.keyChanged(key, changedReportAction)); + + await measureFunction(() => OnyxUtils.keyChanged(key, changedReportAction), { + beforeEach: async () => { + await Onyx.set(key, previousReportAction); + for (let i = 0; i < 10000; i++) { + const id = OnyxUtils.subscribeToKey({key, callback: jest.fn(), initWithStoredValues: false}); + subscriptionIDs.add(id); + } + }, + afterEach: async () => { + for (const id of subscriptionIDs) { + OnyxUtils.unsubscribeFromKey(id); + } + subscriptionIDs.clear(); + await clearOnyxAfterEachMeasure(); + }, + }); }); }); From 8686120c52554aa73c6067ed994205d7b88ce7f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Tue, 20 Jan 2026 09:06:04 +0000 Subject: [PATCH 14/15] Use ubuntu-latest runner --- .github/workflows/reassurePerfTests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/reassurePerfTests.yml b/.github/workflows/reassurePerfTests.yml index 70262a9a6..e6c8b6c2d 100644 --- a/.github/workflows/reassurePerfTests.yml +++ b/.github/workflows/reassurePerfTests.yml @@ -10,7 +10,7 @@ jobs: # Note: We run baseline and delta performance checks in the same runner to reduce hardware variance across machines perf-tests: if: ${{ github.actor != 'OSBotify' }} - runs-on: ubuntu-24.04-v4 + runs-on: ubuntu-latest steps: # v4 - name: Checkout From 78408f1f643df342bdc2547d71957dc6e81a0506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Tue, 20 Jan 2026 09:59:59 +0000 Subject: [PATCH 15/15] Revert "Use ubuntu-latest runner" This reverts commit 8686120c52554aa73c6067ed994205d7b88ce7f5. --- .github/workflows/reassurePerfTests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/reassurePerfTests.yml b/.github/workflows/reassurePerfTests.yml index e6c8b6c2d..70262a9a6 100644 --- a/.github/workflows/reassurePerfTests.yml +++ b/.github/workflows/reassurePerfTests.yml @@ -10,7 +10,7 @@ jobs: # Note: We run baseline and delta performance checks in the same runner to reduce hardware variance across machines perf-tests: if: ${{ github.actor != 'OSBotify' }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04-v4 steps: # v4 - name: Checkout