From 99287caf7f2bc2eaabffa5f2c82c27c1b8e591ed Mon Sep 17 00:00:00 2001 From: Shubham Agrawal Date: Fri, 15 Aug 2025 21:27:31 +0530 Subject: [PATCH 1/6] Implemented partialSetCollection --- API-INTERNAL.md | 22 ++++++++++++++++--- lib/Onyx.ts | 2 +- lib/OnyxUtils.ts | 55 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/API-INTERNAL.md b/API-INTERNAL.md index 8da520402..3f63741aa 100644 --- a/API-INTERNAL.md +++ b/API-INTERNAL.md @@ -23,7 +23,7 @@
setSkippableCollectionMemberIDs()

Setter - sets the skippable collection member IDs.

-
initStoreValues(keys, initialKeyStates, evictableKeys, fullyMergedSnapshotKeys)
+
initStoreValues(keys, initialKeyStates, evictableKeys)

Sets the initial values for the Onyx store

maybeFlushBatchUpdates()
@@ -154,6 +154,10 @@ It will also mark deep nested objects that need to be entirely replaced during t Serves as core implementation for Onyx.mergeCollection() public function, the difference being that this internal function allows passing an additional mergeReplaceNullPatches parameter.

+
partialSetCollection(collectionKey, collection)
+

Sets keys in a collection by replacing all targeted collection members with new values. +Any existing collection members not included in the new data will not be removed.

+
clearOnyxUtilsInternals()

Clear internal variables used in this file, useful in test environments.

@@ -197,7 +201,7 @@ Setter - sets the skippable collection member IDs. **Kind**: global function -## initStoreValues(keys, initialKeyStates, evictableKeys, fullyMergedSnapshotKeys) +## initStoreValues(keys, initialKeyStates, evictableKeys) Sets the initial values for the Onyx store **Kind**: global function @@ -207,7 +211,6 @@ Sets the initial values for the Onyx store | keys | `ONYXKEYS` constants object from Onyx.init() | | 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. | -| fullyMergedSnapshotKeys | Array of snapshot collection keys where full merge is supported and data structure can be changed after merge. | @@ -523,6 +526,19 @@ that this internal function allows passing an additional `mergeReplaceNullPatche | collection | Object collection keyed by individual collection member keys and values | | mergeReplaceNullPatches | Record where the key is a collection member key and the value is a list of tuples that we'll use to replace the nested objects of that collection member record with something else. | + + +## partialSetCollection(collectionKey, collection) +Sets keys in a collection by replacing all targeted collection members with new values. +Any existing collection members not included in the new data will not be removed. + +**Kind**: global function + +| Param | Description | +| --- | --- | +| collectionKey | e.g. `ONYXKEYS.COLLECTION.REPORT` | +| collection | Object collection keyed by individual collection member keys and values | + ## clearOnyxUtilsInternals() diff --git a/lib/Onyx.ts b/lib/Onyx.ts index 5b9065c7e..be2878f6a 100644 --- a/lib/Onyx.ts +++ b/lib/Onyx.ts @@ -629,7 +629,7 @@ function update(data: OnyxUpdate[]): Promise { ); } if (!utils.isEmptyObject(batchedCollectionUpdates.set)) { - promises.push(() => multiSet(batchedCollectionUpdates.set)); + promises.push(() => OnyxUtils.partialSetCollection(collectionKey, batchedCollectionUpdates.set as Collection)); } }); diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index c777d30ac..1b5cb01b6 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -1627,6 +1627,60 @@ function mergeCollectionWithPatches( .then(() => undefined); } +/** + * Sets keys in a collection by replacing all targeted collection members with new values. + * Any existing collection members not included in the new data will not be removed. + * + * @param collectionKey e.g. `ONYXKEYS.COLLECTION.REPORT` + * @param collection Object collection keyed by individual collection member keys and values + */ +function partialSetCollection(collectionKey: TKey, collection: OnyxMergeCollectionInput): Promise { + let resultCollection: OnyxInputKeyValueMapping = collection; + let resultCollectionKeys = Object.keys(resultCollection); + + // Confirm all the collection keys belong to the same parent + if (!doAllCollectionItemsBelongToSameParent(collectionKey, resultCollectionKeys)) { + Logger.logAlert(`setCollection called with keys that do not belong to the same parent ${collectionKey}. Skipping this update.`); + return Promise.resolve(); + } + + if (skippableCollectionMemberIDs.size) { + resultCollection = resultCollectionKeys.reduce((result: OnyxInputKeyValueMapping, key) => { + try { + const [, collectionMemberID] = splitCollectionMemberKey(key, collectionKey); + // If the collection member key is a skippable one we set its value to null. + // eslint-disable-next-line no-param-reassign + result[key] = !skippableCollectionMemberIDs.has(collectionMemberID) ? resultCollection[key] : null; + } catch { + // Something went wrong during split, so we assign the data to result anyway. + // eslint-disable-next-line no-param-reassign + result[key] = resultCollection[key]; + } + + return result; + }, {}); + } + resultCollectionKeys = Object.keys(resultCollection); + + return getAllKeys().then((persistedKeys) => { + const mutableCollection: OnyxInputKeyValueMapping = {...resultCollection}; + const existingKeys = resultCollectionKeys.filter((key) => persistedKeys.has(key)); + const previousCollection = getCachedCollection(collectionKey, existingKeys); + const keyValuePairs = prepareKeyValuePairsForStorage(mutableCollection, true); + + keyValuePairs.forEach(([key, value]) => cache.set(key, value)); + + const updatePromise = scheduleNotifyCollectionSubscribers(collectionKey, mutableCollection, previousCollection); + + return Storage.multiSet(keyValuePairs) + .catch((error) => evictStorageAndRetry(error, partialSetCollection, collectionKey, collection)) + .then(() => { + sendActionToDevTools(METHOD.SET_COLLECTION, undefined, mutableCollection); + return updatePromise; + }); + }); +} + function logKeyChanged(onyxMethod: Extract, key: OnyxKey, value: unknown, hasChanged: boolean) { Logger.logInfo(`${onyxMethod} called for key: ${key}${_.isObject(value) ? ` properties: ${_.keys(value).join(',')}` : ''} hasChanged: ${hasChanged}`); } @@ -1697,6 +1751,7 @@ const OnyxUtils = { reduceCollectionWithSelector, updateSnapshots, mergeCollectionWithPatches, + partialSetCollection, logKeyChanged, logKeyRemoved, }; From a86719fd15ba2a4b1bccd476c81b86fcdbc0fc05 Mon Sep 17 00:00:00 2001 From: Shubham Agrawal Date: Tue, 9 Sep 2025 20:52:48 +0530 Subject: [PATCH 2/6] Fix batch after merge main --- lib/OnyxUtils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index c5408b402..98c01564b 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -1630,7 +1630,7 @@ function mergeCollectionWithPatches( * @param collectionKey e.g. `ONYXKEYS.COLLECTION.REPORT` * @param collection Object collection keyed by individual collection member keys and values */ -function partialSetCollection(collectionKey: TKey, collection: OnyxMergeCollectionInput): Promise { +function partialSetCollection(collectionKey: TKey, collection: OnyxMergeCollectionInput, isFromUpdate = false): Promise { let resultCollection: OnyxInputKeyValueMapping = collection; let resultCollectionKeys = Object.keys(resultCollection); @@ -1666,10 +1666,10 @@ function partialSetCollection(collectionKe keyValuePairs.forEach(([key, value]) => cache.set(key, value)); - const updatePromise = scheduleNotifyCollectionSubscribers(collectionKey, mutableCollection, previousCollection); + const updatePromise = scheduleNotifyCollectionSubscribers(collectionKey, mutableCollection, previousCollection, isFromUpdate); return Storage.multiSet(keyValuePairs) - .catch((error) => evictStorageAndRetry(error, partialSetCollection, collectionKey, collection)) + .catch((error) => evictStorageAndRetry(error, partialSetCollection, collectionKey, collection, isFromUpdate)) .then(() => { sendActionToDevTools(METHOD.SET_COLLECTION, undefined, mutableCollection); return updatePromise; From b31ab1f8b9fe7ea14b298412dd41330ad7c777db Mon Sep 17 00:00:00 2001 From: Shubham Agrawal Date: Tue, 9 Sep 2025 20:56:23 +0530 Subject: [PATCH 3/6] Fix batch after merge main --- lib/Onyx.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Onyx.ts b/lib/Onyx.ts index bafdbafb9..f043d22f0 100644 --- a/lib/Onyx.ts +++ b/lib/Onyx.ts @@ -658,7 +658,7 @@ function update(data: OnyxUpdate[]): Promise { ); } if (!utils.isEmptyObject(batchedCollectionUpdates.set)) { - promises.push(() => OnyxUtils.partialSetCollection(collectionKey, batchedCollectionUpdates.set as Collection)); + promises.push(() => OnyxUtils.partialSetCollection(collectionKey, batchedCollectionUpdates.set as Collection, true)); } }); From 7039e8163d72d37a4548c2b7209d9ae7896b5743 Mon Sep 17 00:00:00 2001 From: Shubham Agrawal Date: Tue, 9 Sep 2025 22:31:35 +0530 Subject: [PATCH 4/6] Add unit tests --- API.md | 55 ++++++++++++++++++++ tests/unit/onyxUtilsTest.ts | 100 +++++++++++++++++++++++++++++++++++- 2 files changed, 154 insertions(+), 1 deletion(-) diff --git a/API.md b/API.md index d3de3101a..2a8fbb2ce 100644 --- a/API.md +++ b/API.md @@ -21,9 +21,13 @@ This method will be deprecated soon. Please use Onyx.connectWithoutView()<
set(key, value, options)

Write a value to our store with the given key

+
setInternal(isFromUpdate)
+
multiSet(data)

Sets multiple keys and values

+
multiSetInternal(isFromUpdate)
+
merge()

Merge a new value into an existing value at a key.

The types of values that can be merged are Object and Array. To set another type of value use Onyx.set(). @@ -32,9 +36,13 @@ Values of type Object get merged with the old value, whilst for Onyx.set() calls do not work this way so use caution when mixing Onyx.merge() and Onyx.set().

+
mergeInternal(isFromUpdate)
+
mergeCollection(collectionKey, collection)

Merges a collection based on their keys.

+
mergeCollectionInternal(isFromUpdate)
+
clear(keysToPreserve)

Clear out all the data in the store

Note that calling Onyx.clear() and then Onyx.set() on a key with a default @@ -58,6 +66,8 @@ value will be saved to storage after the default value.

Sets a collection by replacing all existing collection members with new values. Any existing collection members not included in the new data will be removed.

+
setCollectionInternal(isFromUpdate)
+
@@ -152,6 +162,15 @@ Write a value to our store with the given key | value | value to store | | options | optional configuration object | + + +## setInternal(isFromUpdate) +**Kind**: global function + +| Param | Default | Description | +| --- | --- | --- | +| isFromUpdate | false | Whether this call originates from Onyx.update() When isFromUpdate = true (called from Onyx.update()), useOnyx hook subscribers are batched to escape excessive re-renders in components | + ## multiSet(data) @@ -167,6 +186,15 @@ Sets multiple keys and values ```js Onyx.multiSet({'key1': 'a', 'key2': 'b'}); ``` + + +## multiSetInternal(isFromUpdate) +**Kind**: global function + +| Param | Default | Description | +| --- | --- | --- | +| isFromUpdate | false | Whether this call originates from Onyx.update() When isFromUpdate = true (called from Onyx.update()), useOnyx hook subscribers are batched to escape excessive re-renders in components | + ## merge() @@ -187,6 +215,15 @@ 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'} ``` + + +## mergeInternal(isFromUpdate) +**Kind**: global function + +| Param | Default | Description | +| --- | --- | --- | +| isFromUpdate | false | Whether this call originates from Onyx.update() When isFromUpdate = true (called from Onyx.update()), useOnyx hook subscribers are batched to escape excessive re-renders in components | + ## mergeCollection(collectionKey, collection) @@ -206,6 +243,15 @@ Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, { [`${ONYXKEYS.COLLECTION.REPORT}2`]: report2, }); ``` + + +## mergeCollectionInternal(isFromUpdate) +**Kind**: global function + +| Param | Default | Description | +| --- | --- | --- | +| isFromUpdate | false | Whether this call originates from Onyx.update() When isFromUpdate = true (called from Onyx.update()), useOnyx hook subscribers are batched to escape excessive re-renders in components | + ## clear(keysToPreserve) @@ -265,3 +311,12 @@ Onyx.setCollection(ONYXKEYS.COLLECTION.REPORT, { [`${ONYXKEYS.COLLECTION.REPORT}2`]: report2, }); ``` + + +## setCollectionInternal(isFromUpdate) +**Kind**: global function + +| Param | Default | Description | +| --- | --- | --- | +| isFromUpdate | false | Whether this call originates from Onyx.update() When isFromUpdate = true (called from Onyx.update()), useOnyx hook subscribers are batched to escape excessive re-renders in components | + diff --git a/tests/unit/onyxUtilsTest.ts b/tests/unit/onyxUtilsTest.ts index 2024238aa..7d2679392 100644 --- a/tests/unit/onyxUtilsTest.ts +++ b/tests/unit/onyxUtilsTest.ts @@ -2,7 +2,8 @@ import Onyx from '../../lib'; import OnyxUtils from '../../lib/OnyxUtils'; import type {GenericDeepRecord} from '../types'; import utils from '../../lib/utils'; -import type {Collection} from '../../lib/types'; +import type {Collection, OnyxCollection} from '../../lib/types'; +import type GenericCollection from '../utils/GenericCollection'; const testObject: GenericDeepRecord = { a: 'a', @@ -71,6 +72,7 @@ const ONYXKEYS = { TEST_KEY: 'test_', TEST_LEVEL_KEY: 'test_level_', TEST_LEVEL_LAST_KEY: 'test_level_last_', + ROUTES: 'routes_', }, }; @@ -123,6 +125,102 @@ describe('OnyxUtils', () => { }); }); + describe('partialSetCollection', () => { + beforeEach(() => { + Onyx.clear(); + }); + + afterEach(() => { + Onyx.clear(); + }); + it('should replace all existing collection members with new values and keep old ones intact', async () => { + let result: OnyxCollection; + const routeA = `${ONYXKEYS.COLLECTION.ROUTES}A`; + const routeB = `${ONYXKEYS.COLLECTION.ROUTES}B`; + const routeB1 = `${ONYXKEYS.COLLECTION.ROUTES}B1`; + const routeC = `${ONYXKEYS.COLLECTION.ROUTES}C`; + + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.ROUTES, + initWithStoredValues: false, + callback: (value) => (result = value), + waitForCollectionCallback: true, + }); + + // Set initial collection state + await Onyx.setCollection(ONYXKEYS.COLLECTION.ROUTES, { + [routeA]: {name: 'Route A'}, + [routeB1]: {name: 'Route B1'}, + [routeC]: {name: 'Route C'}, + } as GenericCollection); + + // Replace with new collection data + await OnyxUtils.partialSetCollection(ONYXKEYS.COLLECTION.ROUTES, { + [routeA]: {name: 'New Route A'}, + [routeB]: {name: 'New Route B'}, + [routeC]: {name: 'New Route C'}, + } as GenericCollection); + + expect(result).toEqual({ + [routeA]: {name: 'New Route A'}, + [routeB]: {name: 'New Route B'}, + [routeB1]: {name: 'Route B1'}, + [routeC]: {name: 'New Route C'}, + }); + await Onyx.disconnect(connection); + }); + + it('should not replace anything in the collection with empty values', async () => { + let result: OnyxCollection; + const routeA = `${ONYXKEYS.COLLECTION.ROUTES}A`; + + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.ROUTES, + initWithStoredValues: false, + callback: (value) => (result = value), + waitForCollectionCallback: true, + }); + + await Onyx.mergeCollection(ONYXKEYS.COLLECTION.ROUTES, { + [routeA]: {name: 'Route A'}, + } as GenericCollection); + + await OnyxUtils.partialSetCollection(ONYXKEYS.COLLECTION.ROUTES, {} as GenericCollection); + + expect(result).toEqual({ + [routeA]: {name: 'Route A'}, + }); + await Onyx.disconnect(connection); + }); + + it('should reject collection items with invalid keys', async () => { + let result: OnyxCollection; + const routeA = `${ONYXKEYS.COLLECTION.ROUTES}A`; + const invalidRoute = 'invalid_route'; + + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.ROUTES, + initWithStoredValues: false, + callback: (value) => (result = value), + waitForCollectionCallback: true, + }); + + await Onyx.mergeCollection(ONYXKEYS.COLLECTION.ROUTES, { + [routeA]: {name: 'Route A'}, + } as GenericCollection); + + await OnyxUtils.partialSetCollection(ONYXKEYS.COLLECTION.ROUTES, { + [invalidRoute]: {name: 'Invalid Route'}, + } as GenericCollection); + + expect(result).toEqual({ + [routeA]: {name: 'Route A'}, + }); + + await Onyx.disconnect(connection); + }); + }); + describe('keysChanged', () => { beforeEach(() => { Onyx.clear(); From c8e41fd454ce8878981e0cd7ea96297505aa0dbc Mon Sep 17 00:00:00 2001 From: Shubham Agrawal Date: Tue, 9 Sep 2025 22:37:34 +0530 Subject: [PATCH 5/6] Add perf tests --- tests/perf-test/OnyxUtils.perf-test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/perf-test/OnyxUtils.perf-test.ts b/tests/perf-test/OnyxUtils.perf-test.ts index 9b6f0bcaf..399b53ba3 100644 --- a/tests/perf-test/OnyxUtils.perf-test.ts +++ b/tests/perf-test/OnyxUtils.perf-test.ts @@ -1,4 +1,5 @@ import {measureAsyncFunction, measureFunction} from 'reassure'; +import {randBoolean} from '@ngneat/falso'; import createRandomReportAction, {getRandomReportActions} from '../utils/collections/reportActions'; import type {OnyxKey, Selector} from '../../lib'; import Onyx from '../../lib'; @@ -306,6 +307,20 @@ describe('OnyxUtils', () => { }); }); + describe('partialSetCollection', () => { + test('one call with 10k heavy objects', async () => { + const changedReportActions = Object.fromEntries( + Object.entries(mockedReportActionsMap).map(([k, v]) => [k, randBoolean() ? v : createRandomReportAction(Number(v.reportActionID))] as const), + ) as GenericCollection; + await measureAsyncFunction(() => OnyxUtils.partialSetCollection(collectionKey, changedReportActions), { + beforeEach: async () => { + await Onyx.setCollection(collectionKey, mockedReportActionsMap as GenericCollection); + }, + afterEach: clearOnyxAfterEachMeasure, + }); + }); + }); + describe('keysChanged', () => { test('one call with 10k heavy objects to update 10k subscribers', async () => { const subscriptionMap = new Map(); From d2300669b753758acfb9464377adb476fff09a6d Mon Sep 17 00:00:00 2001 From: Shubham Agrawal Date: Thu, 11 Sep 2025 21:22:09 +0530 Subject: [PATCH 6/6] Update doc --- API.md | 55 ------------------------------------------------------- 1 file changed, 55 deletions(-) diff --git a/API.md b/API.md index 2a8fbb2ce..d3de3101a 100644 --- a/API.md +++ b/API.md @@ -21,13 +21,9 @@ This method will be deprecated soon. Please use Onyx.connectWithoutView()<
set(key, value, options)

Write a value to our store with the given key

-
setInternal(isFromUpdate)
-
multiSet(data)

Sets multiple keys and values

-
multiSetInternal(isFromUpdate)
-
merge()

Merge a new value into an existing value at a key.

The types of values that can be merged are Object and Array. To set another type of value use Onyx.set(). @@ -36,13 +32,9 @@ Values of type Object get merged with the old value, whilst for Onyx.set() calls do not work this way so use caution when mixing Onyx.merge() and Onyx.set().

-
mergeInternal(isFromUpdate)
-
mergeCollection(collectionKey, collection)

Merges a collection based on their keys.

-
mergeCollectionInternal(isFromUpdate)
-
clear(keysToPreserve)

Clear out all the data in the store

Note that calling Onyx.clear() and then Onyx.set() on a key with a default @@ -66,8 +58,6 @@ value will be saved to storage after the default value.

Sets a collection by replacing all existing collection members with new values. Any existing collection members not included in the new data will be removed.

-
setCollectionInternal(isFromUpdate)
-
@@ -162,15 +152,6 @@ Write a value to our store with the given key | value | value to store | | options | optional configuration object | - - -## setInternal(isFromUpdate) -**Kind**: global function - -| Param | Default | Description | -| --- | --- | --- | -| isFromUpdate | false | Whether this call originates from Onyx.update() When isFromUpdate = true (called from Onyx.update()), useOnyx hook subscribers are batched to escape excessive re-renders in components | - ## multiSet(data) @@ -186,15 +167,6 @@ Sets multiple keys and values ```js Onyx.multiSet({'key1': 'a', 'key2': 'b'}); ``` - - -## multiSetInternal(isFromUpdate) -**Kind**: global function - -| Param | Default | Description | -| --- | --- | --- | -| isFromUpdate | false | Whether this call originates from Onyx.update() When isFromUpdate = true (called from Onyx.update()), useOnyx hook subscribers are batched to escape excessive re-renders in components | - ## merge() @@ -215,15 +187,6 @@ 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'} ``` - - -## mergeInternal(isFromUpdate) -**Kind**: global function - -| Param | Default | Description | -| --- | --- | --- | -| isFromUpdate | false | Whether this call originates from Onyx.update() When isFromUpdate = true (called from Onyx.update()), useOnyx hook subscribers are batched to escape excessive re-renders in components | - ## mergeCollection(collectionKey, collection) @@ -243,15 +206,6 @@ Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, { [`${ONYXKEYS.COLLECTION.REPORT}2`]: report2, }); ``` - - -## mergeCollectionInternal(isFromUpdate) -**Kind**: global function - -| Param | Default | Description | -| --- | --- | --- | -| isFromUpdate | false | Whether this call originates from Onyx.update() When isFromUpdate = true (called from Onyx.update()), useOnyx hook subscribers are batched to escape excessive re-renders in components | - ## clear(keysToPreserve) @@ -311,12 +265,3 @@ Onyx.setCollection(ONYXKEYS.COLLECTION.REPORT, { [`${ONYXKEYS.COLLECTION.REPORT}2`]: report2, }); ``` - - -## setCollectionInternal(isFromUpdate) -**Kind**: global function - -| Param | Default | Description | -| --- | --- | --- | -| isFromUpdate | false | Whether this call originates from Onyx.update() When isFromUpdate = true (called from Onyx.update()), useOnyx hook subscribers are batched to escape excessive re-renders in components | -