Skip to content

Commit aa97373

Browse files
committed
Merge branch 'main' into bugfix/reassure-flakiness
2 parents fbc7fba + b58e02f commit aa97373

8 files changed

Lines changed: 68 additions & 35 deletions

File tree

lib/Onyx.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ function init({
4444
debugSetState = false,
4545
enablePerformanceMetrics = false,
4646
skippableCollectionMemberIDs = [],
47+
fullyMergedSnapshotKeys = [],
4748
}: InitOptions): void {
4849
if (enablePerformanceMetrics) {
4950
GlobalSettings.setPerformanceMetricsEnabled(true);
@@ -70,7 +71,7 @@ function init({
7071
cache.setRecentKeysLimit(maxCachedKeysCount);
7172
}
7273

73-
OnyxUtils.initStoreValues(keys, initialKeyStates, evictableKeys);
74+
OnyxUtils.initStoreValues(keys, initialKeyStates, evictableKeys, fullyMergedSnapshotKeys);
7475

7576
// Initialize all of our keys with data provided then give green light to any pending connections........
7677
Promise.all([cache.addEvictableKeysToRecentlyAccessedList(OnyxUtils.isCollectionKey, OnyxUtils.getAllKeys), OnyxUtils.initializeWithDefaultKeyStates()]).then(

lib/OnyxUtils.ts

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import {deepEqual} from 'fast-equals';
33
import lodashClone from 'lodash/clone';
44
import type {ValueOf} from 'type-fest';
5+
import lodashPick from 'lodash/pick';
56
import DevTools from './DevTools';
67
import * as Logger from './Logger';
78
import type Onyx from './Onyx';
@@ -71,6 +72,8 @@ let lastConnectionCallbackData = new Map<number, OnyxValue<OnyxKey>>();
7172

7273
let snapshotKey: OnyxKey | null = null;
7374

75+
let fullyMergedSnapshotKeys: Set<string> | undefined;
76+
7477
// Keeps track of the last subscriptionID that was used so we can keep incrementing it
7578
let lastSubscriptionID = 0;
7679

@@ -132,8 +135,9 @@ function setSkippableCollectionMemberIDs(ids: Set<string>): void {
132135
* @param keys - `ONYXKEYS` constants object from Onyx.init()
133136
* @param initialKeyStates - initial data to set when `init()` and `clear()` are called
134137
* @param evictableKeys - This is an array of keys (individual or collection patterns) that when provided to Onyx are flagged as "safe" for removal.
138+
* @param fullyMergedSnapshotKeys - Array of snapshot collection keys where full merge is supported and data structure can be changed after merge.
135139
*/
136-
function initStoreValues(keys: DeepRecord<string, OnyxKey>, initialKeyStates: Partial<KeyValueMapping>, evictableKeys: OnyxKey[]): void {
140+
function initStoreValues(keys: DeepRecord<string, OnyxKey>, initialKeyStates: Partial<KeyValueMapping>, evictableKeys: OnyxKey[], fullyMergedSnapshotKeysParam?: string[]): void {
137141
// We need the value of the collection keys later for checking if a
138142
// key is a collection. We store it in a map for faster lookup.
139143
const collectionValues = Object.values(keys.COLLECTION ?? {}) as string[];
@@ -152,6 +156,7 @@ function initStoreValues(keys: DeepRecord<string, OnyxKey>, initialKeyStates: Pa
152156

153157
if (typeof keys.COLLECTION === 'object' && typeof keys.COLLECTION.SNAPSHOT === 'string') {
154158
snapshotKey = keys.COLLECTION.SNAPSHOT;
159+
fullyMergedSnapshotKeys = new Set(fullyMergedSnapshotKeysParam ?? []);
155160
}
156161
}
157162

@@ -446,8 +451,8 @@ function isCollectionKey(key: OnyxKey): key is CollectionKeyBase {
446451
return onyxCollectionKeySet.has(key);
447452
}
448453

449-
function isCollectionMemberKey<TCollectionKey extends CollectionKeyBase>(collectionKey: TCollectionKey, key: string, collectionKeyLength: number): key is `${TCollectionKey}${string}` {
450-
return key.startsWith(collectionKey) && key.length > collectionKeyLength;
454+
function isCollectionMemberKey<TCollectionKey extends CollectionKeyBase>(collectionKey: TCollectionKey, key: string): key is `${TCollectionKey}${string}` {
455+
return key.startsWith(collectionKey) && key.length > collectionKey.length;
451456
}
452457

453458
/**
@@ -461,7 +466,7 @@ function splitCollectionMemberKey<TKey extends CollectionKey, CollectionKeyType
461466
key: TKey,
462467
collectionKey?: string,
463468
): [CollectionKeyType, string] {
464-
if (collectionKey && !isCollectionMemberKey(collectionKey, key, collectionKey.length)) {
469+
if (collectionKey && !isCollectionMemberKey(collectionKey, key)) {
465470
throw new Error(`Invalid '${collectionKey}' collection key provided, it isn't compatible with '${key}' key.`);
466471
}
467472

@@ -556,14 +561,12 @@ function getCachedCollection<TKey extends CollectionKeyBase>(collectionKey: TKey
556561
const allKeys = collectionMemberKeys || cache.getAllKeys();
557562
const collection: OnyxCollection<KeyValueMapping[TKey]> = {};
558563

559-
const collectionKeyLength = collectionKey.length;
560-
561564
// forEach exists on both Set and Array
562565
allKeys.forEach((key) => {
563566
// If we don't have collectionMemberKeys array then we have to check whether a key is a collection member key.
564567
// Because in that case the keys will be coming from `cache.getAllKeys()` and we need to filter out the keys that
565568
// are not part of the collection.
566-
if (!collectionMemberKeys && !isCollectionMemberKey(collectionKey, key, collectionKeyLength)) {
569+
if (!collectionMemberKeys && !isCollectionMemberKey(collectionKey, key)) {
567570
return;
568571
}
569572

@@ -599,7 +602,6 @@ function keysChanged<TKey extends CollectionKeyBase>(
599602
// individual collection key member for the collection that is being updated. It is important to note that the collection parameter cane be a PARTIAL collection
600603
// and does not represent all of the combined keys and values for a collection key. It is just the "new" data that was merged in via mergeCollection().
601604
const stateMappingKeys = Object.keys(callbackToStateMapping);
602-
const collectionKeyLength = collectionKey.length;
603605

604606
for (const stateMappingKey of stateMappingKeys) {
605607
const subscriber = callbackToStateMapping[stateMappingKey];
@@ -620,7 +622,7 @@ function keysChanged<TKey extends CollectionKeyBase>(
620622
/**
621623
* e.g. Onyx.connect({key: `${ONYXKEYS.COLLECTION.REPORT}{reportID}`, callback: ...});
622624
*/
623-
const isSubscribedToCollectionMemberKey = isCollectionMemberKey(collectionKey, subscriber.key, collectionKeyLength);
625+
const isSubscribedToCollectionMemberKey = isCollectionMemberKey(collectionKey, subscriber.key);
624626

625627
// Regular Onyx.connect() subscriber found.
626628
if (typeof subscriber.callback === 'function') {
@@ -1293,12 +1295,21 @@ function subscribeToKey<TKey extends OnyxKey>(connectOptions: ConnectOptions<TKe
12931295
// can send data back to the subscriber. Note that multiple keys can match as a subscriber could either be
12941296
// subscribed to a "collection key" or a single key.
12951297
const matchingKeys: string[] = [];
1296-
keys.forEach((key) => {
1297-
if (!isKeyMatch(mapping.key, key)) {
1298-
return;
1298+
1299+
// Performance optimization: For single key subscriptions, avoid O(n) iteration
1300+
if (!isCollectionKey(mapping.key)) {
1301+
if (keys.has(mapping.key)) {
1302+
matchingKeys.push(mapping.key);
12991303
}
1300-
matchingKeys.push(key);
1301-
});
1304+
} else {
1305+
// Collection case - need to iterate through all keys to find matches (O(n))
1306+
keys.forEach((key) => {
1307+
if (!isKeyMatch(mapping.key, key)) {
1308+
return;
1309+
}
1310+
matchingKeys.push(key);
1311+
});
1312+
}
13021313
// If the key being connected to does not exist we initialize the value with null. For subscribers that connected
13031314
// directly via connect() they will simply get a null value sent to them without any information about which key matched
13041315
// since there are none matched. In withOnyx() we wait for all connected keys to return a value before rendering the child
@@ -1380,7 +1391,6 @@ function updateSnapshots(data: OnyxUpdate[], mergeFn: typeof Onyx.merge): Array<
13801391
const promises: Array<() => Promise<void>> = [];
13811392

13821393
const snapshotCollection = OnyxUtils.getCachedCollection(snapshotCollectionKey);
1383-
const snapshotCollectionKeyLength = snapshotCollectionKey.length;
13841394

13851395
Object.entries(snapshotCollection).forEach(([snapshotEntryKey, snapshotEntryValue]) => {
13861396
// Snapshots may not be present in cache. We don't know how to update them so we skip.
@@ -1392,7 +1402,7 @@ function updateSnapshots(data: OnyxUpdate[], mergeFn: typeof Onyx.merge): Array<
13921402

13931403
data.forEach(({key, value}) => {
13941404
// snapshots are normal keys so we want to skip update if they are written to Onyx
1395-
if (OnyxUtils.isCollectionMemberKey(snapshotCollectionKey, key, snapshotCollectionKeyLength)) {
1405+
if (OnyxUtils.isCollectionMemberKey(snapshotCollectionKey, key)) {
13961406
return;
13971407
}
13981408

@@ -1416,8 +1426,17 @@ function updateSnapshots(data: OnyxUpdate[], mergeFn: typeof Onyx.merge): Array<
14161426
}
14171427

14181428
const oldValue = updatedData[key] || {};
1429+
let collectionKey: string | undefined;
1430+
try {
1431+
collectionKey = getCollectionKey(key);
1432+
} catch (e) {
1433+
// If getCollectionKey() throws an error it means the key is not a collection key.
1434+
collectionKey = undefined;
1435+
}
1436+
const shouldFullyMerge = fullyMergedSnapshotKeys?.has(collectionKey || key);
1437+
const newValue = shouldFullyMerge ? value : lodashPick(value, Object.keys(snapshotData[key]));
14191438

1420-
updatedData = {...updatedData, [key]: Object.assign(oldValue, value)};
1439+
updatedData = {...updatedData, [key]: Object.assign(oldValue, newValue)};
14211440
});
14221441

14231442
// Skip the update if there's no data to be merged

lib/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,14 @@ type InitOptions = {
480480
* Additionally, any subscribers from these keys to won't receive any data from Onyx.
481481
*/
482482
skippableCollectionMemberIDs?: string[];
483+
484+
/**
485+
* Array of snapshot collection keys where full merge is supported and data structure can be changed after merge.
486+
* For e.g. if oldSnapshotData is {report_1: {name 'Fitsum'}} and BE update is {report_1: {name:'Fitsum2', nickName:'Fitse'}}
487+
* if it is fullyMergedSnapshotkey the `nickName` prop that didn't exist in the previous data will be merged
488+
* otherwise only existing prop will be picked from the BE update and merged (in this case only name).
489+
*/
490+
fullyMergedSnapshotKeys?: string[];
483491
};
484492

485493
// eslint-disable-next-line @typescript-eslint/no-explicit-any

lib/useOnyx.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -143,11 +143,7 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(
143143
const previousCollectionKey = OnyxUtils.splitCollectionMemberKey(previousKey)[0];
144144
const collectionKey = OnyxUtils.splitCollectionMemberKey(key)[0];
145145

146-
if (
147-
OnyxUtils.isCollectionMemberKey(previousCollectionKey, previousKey, previousCollectionKey.length) &&
148-
OnyxUtils.isCollectionMemberKey(collectionKey, key, collectionKey.length) &&
149-
previousCollectionKey === collectionKey
150-
) {
146+
if (OnyxUtils.isCollectionMemberKey(previousCollectionKey, previousKey) && OnyxUtils.isCollectionMemberKey(collectionKey, key) && previousCollectionKey === collectionKey) {
151147
return;
152148
}
153149
} catch (e) {

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "react-native-onyx",
3-
"version": "2.0.111",
3+
"version": "2.0.113",
44
"author": "Expensify, Inc.",
55
"homepage": "https://expensify.com",
66
"description": "State management for React Native",

tests/perf-test/OnyxUtils.perf-test.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,14 +154,12 @@ describe('OnyxUtils', () => {
154154
});
155155

156156
describe('isCollectionMemberKey', () => {
157-
const collectionKeyLength = collectionKey.length;
158-
159157
test('one call with correct key', async () => {
160-
await measureFunction(() => OnyxUtils.isCollectionMemberKey(collectionKey, `${collectionKey}entry1`, collectionKeyLength));
158+
await measureFunction(() => OnyxUtils.isCollectionMemberKey(collectionKey, `${collectionKey}entry1`));
161159
});
162160

163161
test('one call with wrong key', async () => {
164-
await measureFunction(() => OnyxUtils.isCollectionMemberKey(collectionKey, `${ONYXKEYS.COLLECTION.TEST_KEY_2}entry1`, collectionKeyLength));
162+
await measureFunction(() => OnyxUtils.isCollectionMemberKey(collectionKey, `${ONYXKEYS.COLLECTION.TEST_KEY_2}entry1`));
165163
});
166164
});
167165

tests/unit/onyxTest.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Onyx.init({
3131
[ONYX_KEYS.KEY_WITH_UNDERSCORE]: 'default',
3232
},
3333
skippableCollectionMemberIDs: ['skippable-id'],
34+
fullyMergedSnapshotKeys: [ONYX_KEYS.COLLECTION.ANIMALS, ONYX_KEYS.OTHER_TEST],
3435
});
3536

3637
describe('Onyx', () => {
@@ -1445,13 +1446,19 @@ describe('Onyx', () => {
14451446

14461447
it('should update Snapshot when its data changed', async () => {
14471448
const cat = `${ONYX_KEYS.COLLECTION.ANIMALS}cat`;
1449+
const people = `${ONYX_KEYS.COLLECTION.PEOPLE}1`;
14481450
const snapshot1 = `${ONYX_KEYS.COLLECTION.SNAPSHOT}1`;
14491451

14501452
const initialValue = {name: 'Fluffy'};
1451-
const finalValue = {name: 'Kitty', nickName: 'Fitse'};
1453+
const initialValueOtherTest = {1: {name: 'Kitty'}};
1454+
const finalValuePeople = {name: 'Kitty'};
1455+
const finalValueOtherTest = {1: {name: 'First person'}, 2: {name: 'Second person'}};
1456+
const finalValueCat = {name: 'Kitty', nickName: 'Fitse'};
1457+
const onyxUpdate = {name: 'Kitty', nickName: 'Fitse'};
14521458

14531459
await Onyx.set(cat, initialValue);
1454-
await Onyx.set(snapshot1, {data: {[cat]: initialValue}});
1460+
await Onyx.set(people, initialValue);
1461+
await Onyx.set(snapshot1, {data: {[ONYX_KEYS.OTHER_TEST]: initialValueOtherTest, [cat]: initialValue, [people]: initialValue}});
14551462

14561463
const callback = jest.fn();
14571464

@@ -1462,11 +1469,15 @@ describe('Onyx', () => {
14621469

14631470
await waitForPromisesToResolve();
14641471

1465-
await Onyx.update([{key: cat, value: finalValue, onyxMethod: Onyx.METHOD.MERGE}]);
1472+
await Onyx.update([
1473+
{key: cat, value: onyxUpdate, onyxMethod: Onyx.METHOD.MERGE},
1474+
{key: people, value: onyxUpdate, onyxMethod: Onyx.METHOD.MERGE},
1475+
{key: ONYX_KEYS.OTHER_TEST, value: finalValueOtherTest, onyxMethod: Onyx.METHOD.MERGE},
1476+
]);
14661477

14671478
expect(callback).toBeCalledTimes(2);
1468-
expect(callback).toHaveBeenNthCalledWith(1, {data: {[cat]: initialValue}}, snapshot1);
1469-
expect(callback).toHaveBeenNthCalledWith(2, {data: {[cat]: finalValue}}, snapshot1);
1479+
expect(callback).toHaveBeenNthCalledWith(1, {data: {[cat]: initialValue, [ONYX_KEYS.OTHER_TEST]: initialValueOtherTest, [people]: initialValue}}, snapshot1);
1480+
expect(callback).toHaveBeenNthCalledWith(2, {data: {[cat]: finalValueCat, [ONYX_KEYS.OTHER_TEST]: finalValueOtherTest, [people]: finalValuePeople}}, snapshot1);
14701481
});
14711482

14721483
describe('update', () => {

0 commit comments

Comments
 (0)