From 2f404e649c96264b849e8ffeb5cd20bf496083ed Mon Sep 17 00:00:00 2001 From: VickyStash Date: Thu, 16 Oct 2025 16:03:47 +0200 Subject: [PATCH 01/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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 606bd640b33b58820bb752fa31ae9e280596e824 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 13 Jan 2026 12:25:15 +0100 Subject: [PATCH 12/33] Test change --- lib/Onyx.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/Onyx.ts b/lib/Onyx.ts index bfae1c739..15cb2b18b 100644 --- a/lib/Onyx.ts +++ b/lib/Onyx.ts @@ -31,6 +31,8 @@ import * as GlobalSettings from './GlobalSettings'; import decorateWithMetrics from './metrics'; import OnyxMerge from './OnyxMerge'; +/** Test change */ + /** Initialize the store with actions and listening for storage events */ function init({ keys = {}, From e8cc46dda93a818cc7fa5d8c35e866c030d87117 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 13 Jan 2026 14:50:09 +0100 Subject: [PATCH 13/33] Experiment 1 --- lib/OnyxUtils.ts | 45 +++++++++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 889397424..172a0cb79 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -76,7 +76,7 @@ let mergeQueue: Record>> = {}; let mergeQueuePromise: Record> = {}; // Used to schedule subscriber update to the macro task queue -let nextMacrotaskPromise: Promise | null = null; +// 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> = {}; @@ -808,17 +808,28 @@ function getCollectionDataAndSendAsObject(matchingKeys: Co * * @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(); -} +// 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(); +// } + +// !!!DO NOT MERGE THIS CODE, METHODS FOR TESTING ONLY +const nextMicrotask = () => Promise.resolve(); +let nextMacrotaskPromise: Promise | null = null; +const nextMacrotask = () => + new Promise((resolve) => { + setTimeout(() => { + nextMacrotaskPromise = null; + resolve(); + }, 0); + }); /** * Schedules an update that will be appended to the macro task queue (so it doesn't update the subscribers immediately). @@ -832,7 +843,10 @@ function scheduleSubscriberUpdate( canUpdateSubscriber: (subscriber?: CallbackToStateMapping) => boolean = () => true, isProcessingCollectionUpdate = false, ): Promise { - return prepareSubscriberUpdate(() => keyChanged(key, value, canUpdateSubscriber, isProcessingCollectionUpdate)); + if (!nextMacrotaskPromise) { + nextMacrotaskPromise = nextMacrotask(); + } + return Promise.all([nextMacrotaskPromise, nextMicrotask().then(() => keyChanged(key, value, canUpdateSubscriber, isProcessingCollectionUpdate))]).then(() => undefined); } /** @@ -845,7 +859,10 @@ function scheduleNotifyCollectionSubscribers( value: OnyxCollection, previousValue?: OnyxCollection, ): Promise { - return prepareSubscriberUpdate(() => keysChanged(key, value, previousValue)); + if (!nextMacrotaskPromise) { + nextMacrotaskPromise = nextMacrotask(); + } + return Promise.all([nextMacrotaskPromise, nextMicrotask().then(() => keysChanged(key, value, previousValue))]).then(() => undefined); } /** From 5069c1d59357e4feda0bdd1b0e9864f3ed3b4f38 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 13 Jan 2026 15:11:50 +0100 Subject: [PATCH 14/33] Revert "Experiment 1" This reverts commit e8cc46dda93a818cc7fa5d8c35e866c030d87117. --- lib/OnyxUtils.ts | 45 ++++++++++++++------------------------------- 1 file changed, 14 insertions(+), 31 deletions(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 172a0cb79..889397424 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -76,7 +76,7 @@ let mergeQueue: Record>> = {}; let mergeQueuePromise: Record> = {}; // Used to schedule subscriber update to the macro task queue -// let nextMacrotaskPromise: Promise | null = null; +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> = {}; @@ -808,28 +808,17 @@ function getCollectionDataAndSendAsObject(matchingKeys: Co * * @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(); -// } - -// !!!DO NOT MERGE THIS CODE, METHODS FOR TESTING ONLY -const nextMicrotask = () => Promise.resolve(); -let nextMacrotaskPromise: Promise | null = null; -const nextMacrotask = () => - new Promise((resolve) => { - setTimeout(() => { - nextMacrotaskPromise = null; - resolve(); - }, 0); - }); +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). @@ -843,10 +832,7 @@ 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(() => keyChanged(key, value, canUpdateSubscriber, isProcessingCollectionUpdate)); } /** @@ -859,10 +845,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(() => keysChanged(key, value, previousValue)); } /** From 79d7400b95dc5c0adbccd4cd4ac18de4b9744a55 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 13 Jan 2026 15:13:06 +0100 Subject: [PATCH 15/33] Revert "Merge pull request #720 from margelo/@chrispader/fix-collection-key-expected-matched-on-collection-callback" This reverts commit 5342c7815aede3fa45c3688bd82e33f815407bbe, reversing changes made to 2ae6062a373c4067a9a44f7368abc9ac027df746. --- lib/OnyxUtils.ts | 6 ++---- tests/unit/OnyxConnectionManagerTest.ts | 4 ++-- tests/unit/onyxClearWebStorageTest.ts | 2 +- tests/unit/onyxTest.ts | 10 +++++----- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 889397424..fd59ab0d1 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -799,7 +799,7 @@ function addKeyToRecentlyAccessedIfNeeded(key: TKey): void function getCollectionDataAndSendAsObject(matchingKeys: CollectionKeyBase[], mapping: CallbackToStateMapping): void { multiGet(matchingKeys).then((dataMap) => { const data = Object.fromEntries(dataMap.entries()) as OnyxValue; - sendDataToConnection(mapping, data, mapping.key); + sendDataToConnection(mapping, data, undefined); }); } @@ -1138,11 +1138,9 @@ function subscribeToKey(connectOptions: ConnectOptions { expect(callback1).toHaveBeenNthCalledWith(2, obj2, `${ONYXKEYS.COLLECTION.TEST_KEY}entry2`); expect(callback2).toHaveBeenCalledTimes(1); - expect(callback2).toHaveBeenCalledWith(collection, ONYXKEYS.COLLECTION.TEST_KEY, undefined); + expect(callback2).toHaveBeenCalledWith(collection, undefined, undefined); connectionManager.disconnect(connection1); connectionManager.disconnect(connection2); @@ -482,7 +482,7 @@ describe('OnyxConnectionManager', () => { // Initial callback with undefined values expect(callback).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenCalledWith(undefined, ONYXKEYS.COLLECTION.TEST_KEY, undefined); + expect(callback).toHaveBeenCalledWith(undefined, undefined, undefined); // Reset mock to test the next update callback.mockReset(); diff --git a/tests/unit/onyxClearWebStorageTest.ts b/tests/unit/onyxClearWebStorageTest.ts index f98ff510b..63f88e427 100644 --- a/tests/unit/onyxClearWebStorageTest.ts +++ b/tests/unit/onyxClearWebStorageTest.ts @@ -161,7 +161,7 @@ describe('Set data while storage is clearing', () => { expect(collectionCallback).toHaveBeenCalledTimes(3); // And it should be called with the expected parameters each time - expect(collectionCallback).toHaveBeenNthCalledWith(1, undefined, ONYX_KEYS.COLLECTION.TEST, undefined); + expect(collectionCallback).toHaveBeenNthCalledWith(1, undefined, undefined, undefined); expect(collectionCallback).toHaveBeenNthCalledWith( 2, { diff --git a/tests/unit/onyxTest.ts b/tests/unit/onyxTest.ts index b432bf82c..a1cac4df8 100644 --- a/tests/unit/onyxTest.ts +++ b/tests/unit/onyxTest.ts @@ -1070,7 +1070,7 @@ describe('Onyx', () => { .then(() => { // Then we expect the callback to be called only once and the initial stored value to be initialCollectionData expect(mockCallback).toHaveBeenCalledTimes(1); - expect(mockCallback).toHaveBeenCalledWith(initialCollectionData, ONYX_KEYS.COLLECTION.TEST_CONNECT_COLLECTION, undefined); + expect(mockCallback).toHaveBeenCalledWith(initialCollectionData, undefined, undefined); }); }); @@ -1096,7 +1096,7 @@ describe('Onyx', () => { expect(mockCallback).toHaveBeenCalledTimes(2); // AND the value for the first call should be null since the collection was not initialized at that point - expect(mockCallback).toHaveBeenNthCalledWith(1, undefined, ONYX_KEYS.COLLECTION.TEST_POLICY, undefined); + expect(mockCallback).toHaveBeenNthCalledWith(1, undefined, undefined, undefined); // AND the value for the second call should be collectionUpdate since the collection was updated expect(mockCallback).toHaveBeenNthCalledWith(2, collectionUpdate, ONYX_KEYS.COLLECTION.TEST_POLICY, collectionUpdate); @@ -1154,7 +1154,7 @@ describe('Onyx', () => { expect(mockCallback).toHaveBeenCalledTimes(2); // AND the value for the second call should be collectionUpdate - expect(mockCallback).toHaveBeenNthCalledWith(1, undefined, ONYX_KEYS.COLLECTION.TEST_POLICY, undefined); + expect(mockCallback).toHaveBeenNthCalledWith(1, undefined, undefined, undefined); expect(mockCallback).toHaveBeenNthCalledWith(2, collectionUpdate, ONYX_KEYS.COLLECTION.TEST_POLICY, { [`${ONYX_KEYS.COLLECTION.TEST_POLICY}1`]: collectionUpdate.testPolicy_1, }); @@ -1270,7 +1270,7 @@ describe('Onyx', () => { {onyxMethod: Onyx.METHOD.MERGE_COLLECTION, key: ONYX_KEYS.COLLECTION.TEST_UPDATE, value: {[itemKey]: {a: 'a'}} as GenericCollection}, ]).then(() => { expect(collectionCallback).toHaveBeenCalledTimes(2); - expect(collectionCallback).toHaveBeenNthCalledWith(1, undefined, ONYX_KEYS.COLLECTION.TEST_UPDATE, undefined); + expect(collectionCallback).toHaveBeenNthCalledWith(1, undefined, undefined, undefined); expect(collectionCallback).toHaveBeenNthCalledWith(2, {[itemKey]: {a: 'a'}}, ONYX_KEYS.COLLECTION.TEST_UPDATE, {[itemKey]: {a: 'a'}}); expect(testCallback).toHaveBeenCalledTimes(2); @@ -1537,7 +1537,7 @@ describe('Onyx', () => { .then(() => { expect(collectionCallback).toHaveBeenCalledTimes(3); 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(2, {[cat]: initialValue}, undefined, undefined); expect(collectionCallback).toHaveBeenNthCalledWith(3, collectionDiff, ONYX_KEYS.COLLECTION.ANIMALS, {[cat]: initialValue, [dog]: {name: 'Rex'}}); // Cat hasn't changed from its original value, expect only the initial connect callback From 7ed1af8c9aad9fc3512cf993b353692ad4b37074 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 13 Jan 2026 15:29:12 +0100 Subject: [PATCH 16/33] Reapply "Merge pull request #720 from margelo/@chrispader/fix-collection-key-expected-matched-on-collection-callback" This reverts commit 79d7400b95dc5c0adbccd4cd4ac18de4b9744a55. --- lib/OnyxUtils.ts | 6 ++++-- tests/unit/OnyxConnectionManagerTest.ts | 4 ++-- tests/unit/onyxClearWebStorageTest.ts | 2 +- tests/unit/onyxTest.ts | 10 +++++----- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index fd59ab0d1..889397424 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -799,7 +799,7 @@ function addKeyToRecentlyAccessedIfNeeded(key: TKey): void function getCollectionDataAndSendAsObject(matchingKeys: CollectionKeyBase[], mapping: CallbackToStateMapping): void { multiGet(matchingKeys).then((dataMap) => { const data = Object.fromEntries(dataMap.entries()) as OnyxValue; - sendDataToConnection(mapping, data, undefined); + sendDataToConnection(mapping, data, mapping.key); }); } @@ -1138,9 +1138,11 @@ function subscribeToKey(connectOptions: ConnectOptions { expect(callback1).toHaveBeenNthCalledWith(2, obj2, `${ONYXKEYS.COLLECTION.TEST_KEY}entry2`); expect(callback2).toHaveBeenCalledTimes(1); - expect(callback2).toHaveBeenCalledWith(collection, undefined, undefined); + expect(callback2).toHaveBeenCalledWith(collection, ONYXKEYS.COLLECTION.TEST_KEY, undefined); connectionManager.disconnect(connection1); connectionManager.disconnect(connection2); @@ -482,7 +482,7 @@ describe('OnyxConnectionManager', () => { // Initial callback with undefined values expect(callback).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenCalledWith(undefined, undefined, undefined); + expect(callback).toHaveBeenCalledWith(undefined, ONYXKEYS.COLLECTION.TEST_KEY, undefined); // Reset mock to test the next update callback.mockReset(); diff --git a/tests/unit/onyxClearWebStorageTest.ts b/tests/unit/onyxClearWebStorageTest.ts index 63f88e427..f98ff510b 100644 --- a/tests/unit/onyxClearWebStorageTest.ts +++ b/tests/unit/onyxClearWebStorageTest.ts @@ -161,7 +161,7 @@ describe('Set data while storage is clearing', () => { expect(collectionCallback).toHaveBeenCalledTimes(3); // And it should be called with the expected parameters each time - expect(collectionCallback).toHaveBeenNthCalledWith(1, undefined, undefined, undefined); + expect(collectionCallback).toHaveBeenNthCalledWith(1, undefined, ONYX_KEYS.COLLECTION.TEST, undefined); expect(collectionCallback).toHaveBeenNthCalledWith( 2, { diff --git a/tests/unit/onyxTest.ts b/tests/unit/onyxTest.ts index a1cac4df8..b432bf82c 100644 --- a/tests/unit/onyxTest.ts +++ b/tests/unit/onyxTest.ts @@ -1070,7 +1070,7 @@ describe('Onyx', () => { .then(() => { // Then we expect the callback to be called only once and the initial stored value to be initialCollectionData expect(mockCallback).toHaveBeenCalledTimes(1); - expect(mockCallback).toHaveBeenCalledWith(initialCollectionData, undefined, undefined); + expect(mockCallback).toHaveBeenCalledWith(initialCollectionData, ONYX_KEYS.COLLECTION.TEST_CONNECT_COLLECTION, undefined); }); }); @@ -1096,7 +1096,7 @@ describe('Onyx', () => { expect(mockCallback).toHaveBeenCalledTimes(2); // AND the value for the first call should be null since the collection was not initialized at that point - expect(mockCallback).toHaveBeenNthCalledWith(1, undefined, undefined, undefined); + expect(mockCallback).toHaveBeenNthCalledWith(1, undefined, ONYX_KEYS.COLLECTION.TEST_POLICY, undefined); // AND the value for the second call should be collectionUpdate since the collection was updated expect(mockCallback).toHaveBeenNthCalledWith(2, collectionUpdate, ONYX_KEYS.COLLECTION.TEST_POLICY, collectionUpdate); @@ -1154,7 +1154,7 @@ describe('Onyx', () => { expect(mockCallback).toHaveBeenCalledTimes(2); // AND the value for the second call should be collectionUpdate - expect(mockCallback).toHaveBeenNthCalledWith(1, undefined, undefined, undefined); + expect(mockCallback).toHaveBeenNthCalledWith(1, undefined, ONYX_KEYS.COLLECTION.TEST_POLICY, undefined); expect(mockCallback).toHaveBeenNthCalledWith(2, collectionUpdate, ONYX_KEYS.COLLECTION.TEST_POLICY, { [`${ONYX_KEYS.COLLECTION.TEST_POLICY}1`]: collectionUpdate.testPolicy_1, }); @@ -1270,7 +1270,7 @@ describe('Onyx', () => { {onyxMethod: Onyx.METHOD.MERGE_COLLECTION, key: ONYX_KEYS.COLLECTION.TEST_UPDATE, value: {[itemKey]: {a: 'a'}} as GenericCollection}, ]).then(() => { expect(collectionCallback).toHaveBeenCalledTimes(2); - expect(collectionCallback).toHaveBeenNthCalledWith(1, undefined, undefined, undefined); + expect(collectionCallback).toHaveBeenNthCalledWith(1, undefined, ONYX_KEYS.COLLECTION.TEST_UPDATE, undefined); expect(collectionCallback).toHaveBeenNthCalledWith(2, {[itemKey]: {a: 'a'}}, ONYX_KEYS.COLLECTION.TEST_UPDATE, {[itemKey]: {a: 'a'}}); expect(testCallback).toHaveBeenCalledTimes(2); @@ -1537,7 +1537,7 @@ describe('Onyx', () => { .then(() => { expect(collectionCallback).toHaveBeenCalledTimes(3); expect(collectionCallback).toHaveBeenNthCalledWith(1, {[cat]: initialValue}, ONYX_KEYS.COLLECTION.ANIMALS, {[cat]: initialValue}); - expect(collectionCallback).toHaveBeenNthCalledWith(2, {[cat]: initialValue}, undefined, undefined); + 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'}}); // Cat hasn't changed from its original value, expect only the initial connect callback From c9a1c4325767870c3a7dae39d09cd26f874dd6ee Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 13 Jan 2026 15:31:44 +0100 Subject: [PATCH 17/33] Experiment 2 --- package-lock.json | 27 +++++++++++++++++++++++++++ package.json | 3 +++ 2 files changed, 30 insertions(+) diff --git a/package-lock.json b/package-lock.json index ab5c3e19f..bbb883556 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@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": "^8.51.0", @@ -54,6 +55,7 @@ "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.27.2", @@ -72,6 +74,7 @@ "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.27.2", @@ -4301,6 +4304,16 @@ "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", @@ -13458,6 +13471,20 @@ } } }, + "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 a8efbd5ed..5338b074a 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@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": "^8.51.0", @@ -88,6 +89,7 @@ "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.27.2", @@ -102,6 +104,7 @@ "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.27.2", From 8227551d392a5319ddb2a012dcd084d7f2030be2 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 13 Jan 2026 15:50:18 +0100 Subject: [PATCH 18/33] Experiment 3 --- lib/OnyxUtils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 889397424..196659ac9 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -1657,6 +1657,7 @@ function clearOnyxUtilsInternals() { mergeQueue = {}; mergeQueuePromise = {}; callbackToStateMapping = {}; + nextMacrotaskPromise = null; onyxKeyToSubscriptionIDs = new Map(); lastConnectionCallbackData = new Map(); } From acb09d3f406ec5092c83b49488af706290595ae4 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 13 Jan 2026 16:13:51 +0100 Subject: [PATCH 19/33] Experiment 4 --- lib/Onyx.ts | 2 +- lib/OnyxUtils.ts | 81 ++++++++++++++++++++++++++++++++++-------------- 2 files changed, 58 insertions(+), 25 deletions(-) diff --git a/lib/Onyx.ts b/lib/Onyx.ts index 5e113a138..15cb2b18b 100644 --- a/lib/Onyx.ts +++ b/lib/Onyx.ts @@ -64,7 +64,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, isKeyCollectionMember); + OnyxUtils.keyChanged(key, value as OnyxValue, undefined, true, isKeyCollectionMember); }); } diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 196659ac9..d3a7061d1 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> = {}; @@ -90,6 +87,9 @@ 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>(); @@ -211,6 +211,37 @@ 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(() => { + batchUpdatesQueue = []; + batchUpdatesPromise = null; + + 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. @@ -596,6 +627,7 @@ 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(). @@ -631,6 +663,10 @@ 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) { @@ -680,6 +716,7 @@ function keyChanged( key: TKey, value: OnyxValue, canUpdateSubscriber: (subscriber?: CallbackToStateMapping) => boolean = () => true, + notifyConnectSubscribers = true, isProcessingCollectionUpdate = false, ): void { // Add or remove this key from the recentlyAccessedKeys lists @@ -721,6 +758,9 @@ 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; } @@ -803,23 +843,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). * @@ -832,11 +855,13 @@ function scheduleSubscriberUpdate( canUpdateSubscriber: (subscriber?: CallbackToStateMapping) => boolean = () => true, isProcessingCollectionUpdate = false, ): Promise { - return prepareSubscriberUpdate(() => keyChanged(key, value, canUpdateSubscriber, isProcessingCollectionUpdate)); + const promise = Promise.resolve().then(() => keyChanged(key, value, canUpdateSubscriber, true, isProcessingCollectionUpdate)); + batchUpdates(() => keyChanged(key, value, canUpdateSubscriber, false, isProcessingCollectionUpdate)); + return Promise.all([maybeFlushBatchUpdates(), promise]).then(() => undefined); } /** - * This method is similar to scheduleSubscriberUpdate but it is built for working specifically with collections + * This method is similar to notifySubscribersOnNextTick 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. */ @@ -845,7 +870,9 @@ function scheduleNotifyCollectionSubscribers( value: OnyxCollection, previousValue?: OnyxCollection, ): Promise { - return prepareSubscriberUpdate(() => keysChanged(key, value, previousValue)); + const promise = Promise.resolve().then(() => keysChanged(key, value, previousValue, true)); + batchUpdates(() => keysChanged(key, value, previousValue, false)); + return Promise.all([maybeFlushBatchUpdates(), promise]).then(() => undefined); } /** @@ -1657,8 +1684,8 @@ function clearOnyxUtilsInternals() { mergeQueue = {}; mergeQueuePromise = {}; callbackToStateMapping = {}; - nextMacrotaskPromise = null; onyxKeyToSubscriptionIDs = new Map(); + batchUpdatesQueue = []; lastConnectionCallbackData = new Map(); } @@ -1670,6 +1697,8 @@ const OnyxUtils = { getDeferredInitTask, initStoreValues, sendActionToDevTools, + maybeFlushBatchUpdates, + batchUpdates, get, getAllKeys, getCollectionKeys, @@ -1727,6 +1756,10 @@ 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 From cd5e14cdc616c08cfb982bc50f3990963cafe6ec Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 13 Jan 2026 16:28:02 +0100 Subject: [PATCH 20/33] Experiment 5 --- jestSetup.js | 3 +++ lib/OnyxUtils.ts | 7 +++++++ lib/batch.native.ts | 3 +++ lib/batch.ts | 3 +++ 4 files changed, 16 insertions(+) create mode 100644 lib/batch.native.ts create mode 100644 lib/batch.ts diff --git a/jestSetup.js b/jestSetup.js index f51f0d54f..5c09ddf46 100644 --- a/jestSetup.js +++ b/jestSetup.js @@ -9,3 +9,6 @@ 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 d3a7061d1..3376b9b9a 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -7,6 +7,7 @@ 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, @@ -228,8 +229,14 @@ function maybeFlushBatchUpdates(): Promise { * then the batch will be flushed on next frame. */ setTimeout(() => { + const updatesCopy = batchUpdatesQueue; batchUpdatesQueue = []; batchUpdatesPromise = null; + unstable_batchedUpdates(() => { + for (const applyUpdates of updatesCopy) { + applyUpdates(); + } + }); resolve(); }, 0); diff --git a/lib/batch.native.ts b/lib/batch.native.ts new file mode 100644 index 000000000..fb7ef4ee5 --- /dev/null +++ b/lib/batch.native.ts @@ -0,0 +1,3 @@ +import {unstable_batchedUpdates} from 'react-native'; + +export default unstable_batchedUpdates; diff --git a/lib/batch.ts b/lib/batch.ts new file mode 100644 index 000000000..3ff0368fe --- /dev/null +++ b/lib/batch.ts @@ -0,0 +1,3 @@ +import {unstable_batchedUpdates} from 'react-dom'; + +export default unstable_batchedUpdates; From 25eca79749654a18de5b3176ba9d27d98c3db0e6 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 13 Jan 2026 16:53:21 +0100 Subject: [PATCH 21/33] Experiment 6 --- lib/OnyxUtils.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 3376b9b9a..80c0ace7a 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -7,7 +7,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, @@ -232,11 +231,9 @@ function maybeFlushBatchUpdates(): Promise { const updatesCopy = batchUpdatesQueue; batchUpdatesQueue = []; batchUpdatesPromise = null; - unstable_batchedUpdates(() => { - for (const applyUpdates of updatesCopy) { - applyUpdates(); - } - }); + for (const applyUpdates of updatesCopy) { + applyUpdates(); + } resolve(); }, 0); From a7855a6ffd540c06f36cc0973313862a3515140d Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 13 Jan 2026 17:07:35 +0100 Subject: [PATCH 22/33] Experiment 7 --- jestSetup.js | 3 --- lib/batch.native.ts | 3 --- lib/batch.ts | 3 --- package.json | 3 --- 4 files changed, 12 deletions(-) delete mode 100644 lib/batch.native.ts delete mode 100644 lib/batch.ts diff --git a/jestSetup.js b/jestSetup.js index 5c09ddf46..f51f0d54f 100644 --- a/jestSetup.js +++ b/jestSetup.js @@ -9,6 +9,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/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.json b/package.json index 5338b074a..a8efbd5ed 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,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": "^8.51.0", @@ -89,7 +88,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.27.2", @@ -104,7 +102,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.27.2", From 67af56f6e46151707d71de9978bd7d634601d482 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 13 Jan 2026 17:38:05 +0100 Subject: [PATCH 23/33] Experiment 8 --- lib/OnyxUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 80c0ace7a..481364504 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -860,7 +860,7 @@ function scheduleSubscriberUpdate( isProcessingCollectionUpdate = false, ): Promise { const promise = Promise.resolve().then(() => keyChanged(key, value, canUpdateSubscriber, true, isProcessingCollectionUpdate)); - batchUpdates(() => keyChanged(key, value, canUpdateSubscriber, false, isProcessingCollectionUpdate)); + // batchUpdates(() => keyChanged(key, value, canUpdateSubscriber, false, isProcessingCollectionUpdate)); return Promise.all([maybeFlushBatchUpdates(), promise]).then(() => undefined); } @@ -875,7 +875,7 @@ function scheduleNotifyCollectionSubscribers( previousValue?: OnyxCollection, ): Promise { const promise = Promise.resolve().then(() => keysChanged(key, value, previousValue, true)); - batchUpdates(() => keysChanged(key, value, previousValue, false)); + // batchUpdates(() => keysChanged(key, value, previousValue, false)); return Promise.all([maybeFlushBatchUpdates(), promise]).then(() => undefined); } From 1c192665ca8115ad415b0b1c8bd305c389debb76 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 13 Jan 2026 18:10:33 +0100 Subject: [PATCH 24/33] Experiment 9 --- lib/OnyxUtils.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 481364504..d6bc97e67 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -860,7 +860,9 @@ function scheduleSubscriberUpdate( isProcessingCollectionUpdate = false, ): Promise { const promise = Promise.resolve().then(() => keyChanged(key, value, canUpdateSubscriber, true, isProcessingCollectionUpdate)); - // batchUpdates(() => keyChanged(key, value, canUpdateSubscriber, false, isProcessingCollectionUpdate)); + batchUpdates(() => { + /* empty */ + }); return Promise.all([maybeFlushBatchUpdates(), promise]).then(() => undefined); } @@ -875,7 +877,9 @@ function scheduleNotifyCollectionSubscribers( previousValue?: OnyxCollection, ): Promise { const promise = Promise.resolve().then(() => keysChanged(key, value, previousValue, true)); - // batchUpdates(() => keysChanged(key, value, previousValue, false)); + batchUpdates(() => { + /* empty */ + }); return Promise.all([maybeFlushBatchUpdates(), promise]).then(() => undefined); } From 4ce13d7c6878c31e6504ccad29b99f2093d05f48 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 13 Jan 2026 18:28:45 +0100 Subject: [PATCH 25/33] Return back --- lib/OnyxUtils.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index d6bc97e67..80c0ace7a 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -860,9 +860,7 @@ function scheduleSubscriberUpdate( isProcessingCollectionUpdate = false, ): Promise { const promise = Promise.resolve().then(() => keyChanged(key, value, canUpdateSubscriber, true, isProcessingCollectionUpdate)); - batchUpdates(() => { - /* empty */ - }); + batchUpdates(() => keyChanged(key, value, canUpdateSubscriber, false, isProcessingCollectionUpdate)); return Promise.all([maybeFlushBatchUpdates(), promise]).then(() => undefined); } @@ -877,9 +875,7 @@ function scheduleNotifyCollectionSubscribers( previousValue?: OnyxCollection, ): Promise { const promise = Promise.resolve().then(() => keysChanged(key, value, previousValue, true)); - batchUpdates(() => { - /* empty */ - }); + batchUpdates(() => keysChanged(key, value, previousValue, false)); return Promise.all([maybeFlushBatchUpdates(), promise]).then(() => undefined); } From 3f35861d8fa8d95bec1dce78f75ce8fc1727d628 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 14 Jan 2026 10:48:58 +0100 Subject: [PATCH 26/33] Experiment 10 --- lib/OnyxUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 80c0ace7a..b2ae9e34f 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -875,7 +875,7 @@ function scheduleNotifyCollectionSubscribers( previousValue?: OnyxCollection, ): Promise { const promise = Promise.resolve().then(() => keysChanged(key, value, previousValue, true)); - batchUpdates(() => keysChanged(key, value, previousValue, false)); + // batchUpdates(() => keysChanged(key, value, previousValue, false)); return Promise.all([maybeFlushBatchUpdates(), promise]).then(() => undefined); } From 9462313528932dab3336322389bba5dc49c36fd5 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 14 Jan 2026 12:33:00 +0100 Subject: [PATCH 27/33] Experiment 11 --- lib/OnyxUtils.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index b2ae9e34f..060c9adcb 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -860,7 +860,29 @@ function scheduleSubscriberUpdate( isProcessingCollectionUpdate = false, ): Promise { const promise = Promise.resolve().then(() => keyChanged(key, value, canUpdateSubscriber, true, isProcessingCollectionUpdate)); - batchUpdates(() => keyChanged(key, value, canUpdateSubscriber, false, isProcessingCollectionUpdate)); + batchUpdates(() => { + if (value !== null) { + cache.addLastAccessedKey(key, isCollectionKey(key)); + } else { + cache.removeLastAccessedKey(key); + } + + let stateMappingKeys = onyxKeyToSubscriptionIDs.get(key) ?? []; + let collectionKey: string | undefined; + try { + collectionKey = getCollectionKey(key); + } catch (e) { + collectionKey = undefined; + } + + if (collectionKey) { + stateMappingKeys = [...stateMappingKeys, ...(onyxKeyToSubscriptionIDs.get(collectionKey) ?? [])]; + if (stateMappingKeys.length === 0) { + // eslint-disable-next-line no-useless-return + return; + } + } + }); return Promise.all([maybeFlushBatchUpdates(), promise]).then(() => undefined); } From f2b4fece0d062c7fd7307326b477b7501ba7265a Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 14 Jan 2026 12:51:12 +0100 Subject: [PATCH 28/33] Experiment 12 --- lib/OnyxUtils.ts | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 060c9adcb..69e2cc776 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -867,21 +867,21 @@ function scheduleSubscriberUpdate( cache.removeLastAccessedKey(key); } - let stateMappingKeys = onyxKeyToSubscriptionIDs.get(key) ?? []; - let collectionKey: string | undefined; - try { - collectionKey = getCollectionKey(key); - } catch (e) { - collectionKey = undefined; - } - - if (collectionKey) { - stateMappingKeys = [...stateMappingKeys, ...(onyxKeyToSubscriptionIDs.get(collectionKey) ?? [])]; - if (stateMappingKeys.length === 0) { - // eslint-disable-next-line no-useless-return - return; - } - } + // let stateMappingKeys = onyxKeyToSubscriptionIDs.get(key) ?? []; + // let collectionKey: string | undefined; + // try { + // collectionKey = getCollectionKey(key); + // } catch (e) { + // collectionKey = undefined; + // } + // + // if (collectionKey) { + // stateMappingKeys = [...stateMappingKeys, ...(onyxKeyToSubscriptionIDs.get(collectionKey) ?? [])]; + // if (stateMappingKeys.length === 0) { + // // eslint-disable-next-line no-useless-return + // return; + // } + // } }); return Promise.all([maybeFlushBatchUpdates(), promise]).then(() => undefined); } From 1db12e564a32c658c4e1f901f58a58d662ebe4f4 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 14 Jan 2026 13:01:56 +0100 Subject: [PATCH 29/33] Experiment 13 --- lib/OnyxUtils.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 69e2cc776..284dbbe65 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -867,6 +867,11 @@ function scheduleSubscriberUpdate( cache.removeLastAccessedKey(key); } + // Any additional computations + for (let i = 0; i < 10000; i++) { + // do nothing + } + // let stateMappingKeys = onyxKeyToSubscriptionIDs.get(key) ?? []; // let collectionKey: string | undefined; // try { From e4b6b941f5e1808970ce07acfff57d674aabe7c4 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 14 Jan 2026 13:17:42 +0100 Subject: [PATCH 30/33] Experiment 14 --- lib/OnyxUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 284dbbe65..17d490348 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -868,7 +868,7 @@ function scheduleSubscriberUpdate( } // Any additional computations - for (let i = 0; i < 10000; i++) { + for (let i = 0; i < 1000; i++) { // do nothing } From bc3ff240c9f628b11573ef50351f8fb17296be00 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 14 Jan 2026 14:48:29 +0100 Subject: [PATCH 31/33] Update reassure test --- lib/OnyxUtils.ts | 28 ----------------- tests/perf-test/OnyxUtils.perf-test.ts | 42 +++++++++++++------------- 2 files changed, 21 insertions(+), 49 deletions(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 17d490348..6b4dd8530 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -860,34 +860,6 @@ function scheduleSubscriberUpdate( isProcessingCollectionUpdate = false, ): Promise { const promise = Promise.resolve().then(() => keyChanged(key, value, canUpdateSubscriber, true, isProcessingCollectionUpdate)); - batchUpdates(() => { - if (value !== null) { - cache.addLastAccessedKey(key, isCollectionKey(key)); - } else { - cache.removeLastAccessedKey(key); - } - - // Any additional computations - for (let i = 0; i < 1000; i++) { - // do nothing - } - - // let stateMappingKeys = onyxKeyToSubscriptionIDs.get(key) ?? []; - // let collectionKey: string | undefined; - // try { - // collectionKey = getCollectionKey(key); - // } catch (e) { - // collectionKey = undefined; - // } - // - // if (collectionKey) { - // stateMappingKeys = [...stateMappingKeys, ...(onyxKeyToSubscriptionIDs.get(collectionKey) ?? [])]; - // if (stateMappingKeys.length === 0) { - // // eslint-disable-next-line no-useless-return - // return; - // } - // } - }); return Promise.all([maybeFlushBatchUpdates(), promise]).then(() => undefined); } 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 553c4fd49622f57b756615eeea2a6b3f18299cc9 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 14 Jan 2026 15:21:47 +0100 Subject: [PATCH 32/33] Experiment 15 --- lib/OnyxUtils.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 6b4dd8530..7f2899fc3 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -88,6 +88,7 @@ let onyxKeyToSubscriptionIDs = new Map(); let defaultKeyStates: Record> = {}; let batchUpdatesPromise: Promise | null = null; +let batchUpdatesTimeoutID: number | null = null; let batchUpdatesQueue: Array<() => void> = []; // Used for comparison with a new update to avoid invoking the Onyx.connect callback with the same data. @@ -227,7 +228,9 @@ function maybeFlushBatchUpdates(): Promise { * 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(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + batchUpdatesTimeoutID = setTimeout(() => { const updatesCopy = batchUpdatesQueue; batchUpdatesQueue = []; batchUpdatesPromise = null; @@ -860,6 +863,7 @@ function scheduleSubscriberUpdate( isProcessingCollectionUpdate = false, ): Promise { const promise = Promise.resolve().then(() => keyChanged(key, value, canUpdateSubscriber, true, isProcessingCollectionUpdate)); + // batchUpdates(() => keyChanged(key, value, canUpdateSubscriber, false, isProcessingCollectionUpdate)); return Promise.all([maybeFlushBatchUpdates(), promise]).then(() => undefined); } @@ -1689,7 +1693,12 @@ function clearOnyxUtilsInternals() { callbackToStateMapping = {}; onyxKeyToSubscriptionIDs = new Map(); batchUpdatesQueue = []; + batchUpdatesPromise = null; lastConnectionCallbackData = new Map(); + if (batchUpdatesTimeoutID) { + clearTimeout(batchUpdatesTimeoutID); + batchUpdatesTimeoutID = null; + } } const OnyxUtils = { From b8adc1f1c805804c67f5a8aa95bcc6ffb96661cf Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 14 Jan 2026 15:25:23 +0100 Subject: [PATCH 33/33] Revert "Update reassure test" This reverts commit bc3ff240 --- 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(); + }, + }); }); });