Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions API-INTERNAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
<dt><a href="#setSkippableCollectionMemberIDs">setSkippableCollectionMemberIDs()</a></dt>
<dd><p>Setter - sets the skippable collection member IDs.</p>
</dd>
<dt><a href="#initStoreValues">initStoreValues(keys, initialKeyStates, evictableKeys, fullyMergedSnapshotKeys)</a></dt>
<dt><a href="#initStoreValues">initStoreValues(keys, initialKeyStates, evictableKeys)</a></dt>
<dd><p>Sets the initial values for the Onyx store</p>
</dd>
<dt><a href="#maybeFlushBatchUpdates">maybeFlushBatchUpdates()</a></dt>
Expand Down Expand Up @@ -154,6 +154,10 @@ It will also mark deep nested objects that need to be entirely replaced during t
Serves as core implementation for <code>Onyx.mergeCollection()</code> public function, the difference being
that this internal function allows passing an additional <code>mergeReplaceNullPatches</code> parameter.</p>
</dd>
<dt><a href="#partialSetCollection">partialSetCollection(collectionKey, collection)</a></dt>
<dd><p>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.</p>
</dd>
<dt><a href="#clearOnyxUtilsInternals">clearOnyxUtilsInternals()</a></dt>
<dd><p>Clear internal variables used in this file, useful in test environments.</p>
</dd>
Expand Down Expand Up @@ -197,7 +201,7 @@ Setter - sets the skippable collection member IDs.
**Kind**: global function
<a name="initStoreValues"></a>

## initStoreValues(keys, initialKeyStates, evictableKeys, fullyMergedSnapshotKeys)
## initStoreValues(keys, initialKeyStates, evictableKeys)
Sets the initial values for the Onyx store

**Kind**: global function
Expand All @@ -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. |

<a name="maybeFlushBatchUpdates"></a>

Expand Down Expand Up @@ -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. |

<a name="partialSetCollection"></a>

## 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 |

<a name="clearOnyxUtilsInternals"></a>

## clearOnyxUtilsInternals()
Expand Down
2 changes: 1 addition & 1 deletion lib/Onyx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -629,7 +629,7 @@ function update(data: OnyxUpdate[]): Promise<void> {
);
}
if (!utils.isEmptyObject(batchedCollectionUpdates.set)) {
promises.push(() => multiSet(batchedCollectionUpdates.set));
promises.push(() => OnyxUtils.partialSetCollection(collectionKey, batchedCollectionUpdates.set as Collection<CollectionKey, unknown, unknown>));
}
});

Expand Down
55 changes: 55 additions & 0 deletions lib/OnyxUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1616,6 +1616,60 @@ function mergeCollectionWithPatches<TKey extends CollectionKeyBase, TMap>(
.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<TKey extends CollectionKeyBase, TMap>(collectionKey: TKey, collection: OnyxMergeCollectionInput<TKey, TMap>): Promise<void> {
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();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should reject this and throw an error back. This is a development mistake, so they should not proceed with such code. Silencing this error could give false positive.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the standard implementation across different methods. We are logging an alert. If we throw an error here, it will cause issues (crash) if we get some bad updates from BE.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok. Talking about backend updates where are we handling the backend updates for this new method.

}

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<OnyxMethod, 'set' | 'merge'>, key: OnyxKey, value: unknown, hasChanged: boolean) {
Logger.logInfo(`${onyxMethod} called for key: ${key}${_.isObject(value) ? ` properties: ${_.keys(value).join(',')}` : ''} hasChanged: ${hasChanged}`);
}
Expand Down Expand Up @@ -1686,6 +1740,7 @@ const OnyxUtils = {
reduceCollectionWithSelector,
updateSnapshots,
mergeCollectionWithPatches,
partialSetCollection,
logKeyChanged,
logKeyRemoved,
};
Expand Down
15 changes: 15 additions & 0 deletions tests/perf-test/OnyxUtils.perf-test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string, number>();
Expand Down
100 changes: 99 additions & 1 deletion tests/unit/onyxUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -71,6 +72,7 @@ const ONYXKEYS = {
TEST_KEY: 'test_',
TEST_LEVEL_KEY: 'test_level_',
TEST_LEVEL_LAST_KEY: 'test_level_last_',
ROUTES: 'routes_',
},
};

Expand Down Expand Up @@ -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 () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This tests does not to match with function description.

Any existing collection members not included in the new data will not be removed

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think should happen here?

Copy link
Member

@parasharrajat parasharrajat Sep 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, I just saw that "not be removed".

let result: OnyxCollection<unknown>;
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<unknown>;
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<unknown>;
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();
Expand Down