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 a288b1a19..b6e1bf8db 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -1616,6 +1616,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}`); } @@ -1686,6 +1740,7 @@ const OnyxUtils = { reduceCollectionWithSelector, updateSnapshots, mergeCollectionWithPatches, + partialSetCollection, logKeyChanged, logKeyRemoved, }; 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(); 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();