Skip to content

Commit e532b79

Browse files
committed
Merge branch 'main' into feature/useOnyx-type-improvements
2 parents 894ba69 + 2704488 commit e532b79

File tree

6 files changed

+127
-42
lines changed

6 files changed

+127
-42
lines changed

jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
module.exports = {
22
preset: 'react-native',
3+
roots: ['<rootDir>/lib', '<rootDir>/tests'],
34
transform: {
45
'\\.[jt]sx?$': 'babel-jest',
56
},

lib/Onyx.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,10 @@ function mergeCollection<TKey extends CollectionKeyBase, TMap>(collectionKey: TK
460460

461461
const promises = [];
462462

463+
// We need to get the previously existing values so we can compare the new ones
464+
// against them, to avoid unnecessary subscriber updates.
465+
const previousCollectionPromise = Promise.all(existingKeys.map((key) => OnyxUtils.get(key).then((value) => [key, value]))).then(Object.fromEntries);
466+
463467
// New keys will be added via multiSet while existing keys will be updated using multiMerge
464468
// This is because setting a key that doesn't exist yet with multiMerge will throw errors
465469
if (keyValuePairsForExistingCollection.length > 0) {
@@ -475,9 +479,9 @@ function mergeCollection<TKey extends CollectionKeyBase, TMap>(collectionKey: TK
475479

476480
// Prefill cache if necessary by calling get() on any existing keys and then merge original data to cache
477481
// and update all subscribers
478-
const promiseUpdate = Promise.all(existingKeys.map(OnyxUtils.get)).then(() => {
482+
const promiseUpdate = previousCollectionPromise.then((previousCollection) => {
479483
cache.merge(finalMergedCollection);
480-
return OnyxUtils.scheduleNotifyCollectionSubscribers(collectionKey, finalMergedCollection);
484+
return OnyxUtils.scheduleNotifyCollectionSubscribers(collectionKey, finalMergedCollection, previousCollection);
481485
});
482486

483487
return Promise.all(promises)

lib/OnyxUtils.ts

Lines changed: 52 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -422,9 +422,22 @@ function getCachedCollection<TKey extends CollectionKeyBase>(collectionKey: TKey
422422
function keysChanged<TKey extends CollectionKeyBase>(
423423
collectionKey: TKey,
424424
partialCollection: OnyxCollection<KeyValueMapping[TKey]>,
425+
previousPartialCollection: OnyxCollection<KeyValueMapping[TKey]> | undefined,
425426
notifyRegularSubscibers = true,
426427
notifyWithOnyxSubscibers = true,
427428
): void {
429+
const previousCollectionWithoutNestedNulls = previousPartialCollection === undefined ? {} : (utils.removeNestedNullValues(previousPartialCollection) as Record<string, unknown>);
430+
431+
// We prepare the "cached collection" which is the entire collection + the new partial data that
432+
// was merged in via mergeCollection().
433+
const cachedCollection = getCachedCollection(collectionKey);
434+
const cachedCollectionWithoutNestedNulls = utils.removeNestedNullValues(cachedCollection) as Record<string, unknown>;
435+
436+
// If the previous collection equals the new collection then we do not need to notify any subscribers.
437+
if (previousPartialCollection !== undefined && deepEqual(cachedCollectionWithoutNestedNulls, previousCollectionWithoutNestedNulls)) {
438+
return;
439+
}
440+
428441
// We are iterating over all subscribers similar to keyChanged(). However, we are looking for subscribers who are subscribing to either a collection key or
429442
// 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
430443
// 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().
@@ -450,11 +463,6 @@ function keysChanged<TKey extends CollectionKeyBase>(
450463
*/
451464
const isSubscribedToCollectionMemberKey = isCollectionMemberKey(collectionKey, subscriber.key);
452465

453-
// We prepare the "cached collection" which is the entire collection + the new partial data that
454-
// was merged in via mergeCollection().
455-
const cachedCollection = getCachedCollection(collectionKey);
456-
const cachedCollectionWithoutNestedNulls = utils.removeNestedNullValues(cachedCollection) as Record<string, unknown>;
457-
458466
// Regular Onyx.connect() subscriber found.
459467
if (typeof subscriber.callback === 'function') {
460468
if (!notifyRegularSubscibers) {
@@ -474,6 +482,11 @@ function keysChanged<TKey extends CollectionKeyBase>(
474482
const dataKeys = Object.keys(partialCollection ?? {});
475483
for (let j = 0; j < dataKeys.length; j++) {
476484
const dataKey = dataKeys[j];
485+
486+
if (deepEqual(cachedCollectionWithoutNestedNulls[dataKey], previousCollectionWithoutNestedNulls[dataKey])) {
487+
continue;
488+
}
489+
477490
subscriber.callback(cachedCollectionWithoutNestedNulls[dataKey], dataKey);
478491
}
479492
continue;
@@ -482,6 +495,10 @@ function keysChanged<TKey extends CollectionKeyBase>(
482495
// And if the subscriber is specifically only tracking a particular collection member key then we will
483496
// notify them with the cached data for that key only.
484497
if (isSubscribedToCollectionMemberKey) {
498+
if (deepEqual(cachedCollectionWithoutNestedNulls[subscriber.key], previousCollectionWithoutNestedNulls[subscriber.key])) {
499+
continue;
500+
}
501+
485502
const subscriberCallback = subscriber.callback as DefaultConnectCallback<TKey>;
486503
subscriberCallback(cachedCollectionWithoutNestedNulls[subscriber.key], subscriber.key as TKey);
487504
continue;
@@ -535,6 +552,10 @@ function keysChanged<TKey extends CollectionKeyBase>(
535552

536553
// If a React component is only interested in a single key then we can set the cached value directly to the state name.
537554
if (isSubscribedToCollectionMemberKey) {
555+
if (deepEqual(cachedCollectionWithoutNestedNulls[subscriber.key], previousCollectionWithoutNestedNulls[subscriber.key])) {
556+
continue;
557+
}
558+
538559
// However, we only want to update this subscriber if the partial data contains a change.
539560
// Otherwise, we would update them with a value they already have and trigger an unnecessary re-render.
540561
const dataFromCollection = partialCollection?.[subscriber.key];
@@ -592,14 +613,14 @@ function keysChanged<TKey extends CollectionKeyBase>(
592613
*/
593614
function keyChanged<TKey extends OnyxKey>(
594615
key: TKey,
595-
data: OnyxValue<TKey>,
596-
prevData: OnyxValue<TKey>,
616+
value: OnyxValue<TKey>,
617+
previousValue: OnyxValue<TKey>,
597618
canUpdateSubscriber: (subscriber?: Mapping<OnyxKey>) => boolean = () => true,
598619
notifyRegularSubscibers = true,
599620
notifyWithOnyxSubscibers = true,
600621
): void {
601622
// Add or remove this key from the recentlyAccessedKeys lists
602-
if (data !== null) {
623+
if (value !== null) {
603624
addLastAccessedKey(key);
604625
} else {
605626
removeLastAccessedKey(key);
@@ -624,14 +645,14 @@ function keyChanged<TKey extends OnyxKey>(
624645
const cachedCollection = getCachedCollection(subscriber.key);
625646
const cachedCollectionWithoutNestedNulls = utils.removeNestedNullValues(cachedCollection) as Record<string, unknown>;
626647

627-
cachedCollectionWithoutNestedNulls[key] = data;
648+
cachedCollectionWithoutNestedNulls[key] = value;
628649
subscriber.callback(cachedCollectionWithoutNestedNulls);
629650
continue;
630651
}
631652

632-
const dataWithoutNestedNulls = utils.removeNestedNullValues(data);
653+
const valueWithoutNestedNulls = utils.removeNestedNullValues(value);
633654
const subscriberCallback = subscriber.callback as DefaultConnectCallback<TKey>;
634-
subscriberCallback(dataWithoutNestedNulls, key);
655+
subscriberCallback(valueWithoutNestedNulls, key);
635656
continue;
636657
}
637658

@@ -650,7 +671,7 @@ function keyChanged<TKey extends OnyxKey>(
650671
subscriber.withOnyxInstance.setStateProxy((prevState) => {
651672
const prevWithOnyxData = prevState[subscriber.statePropertyName];
652673
const newWithOnyxData = {
653-
[key]: selector(data, subscriber.withOnyxInstance.state),
674+
[key]: selector(value, subscriber.withOnyxInstance.state),
654675
};
655676
const prevDataWithNewData = {
656677
...prevWithOnyxData,
@@ -671,7 +692,7 @@ function keyChanged<TKey extends OnyxKey>(
671692
const collection = prevState[subscriber.statePropertyName] || {};
672693
const newCollection = {
673694
...collection,
674-
[key]: data,
695+
[key]: value,
675696
};
676697
PerformanceUtils.logSetStateCall(subscriber, collection, newCollection, 'keyChanged', key);
677698
return {
@@ -685,10 +706,10 @@ function keyChanged<TKey extends OnyxKey>(
685706
// returned by the selector and only if the selected data has changed.
686707
if (selector) {
687708
subscriber.withOnyxInstance.setStateProxy(() => {
688-
const previousValue = selector(prevData, subscriber.withOnyxInstance.state);
689-
const newValue = selector(data, subscriber.withOnyxInstance.state);
709+
const prevValue = selector(previousValue, subscriber.withOnyxInstance.state);
710+
const newValue = selector(value, subscriber.withOnyxInstance.state);
690711

691-
if (!deepEqual(previousValue, newValue)) {
712+
if (!deepEqual(prevValue, newValue)) {
692713
return {
693714
[subscriber.statePropertyName]: newValue,
694715
};
@@ -700,19 +721,19 @@ function keyChanged<TKey extends OnyxKey>(
700721

701722
// If we did not match on a collection key then we just set the new data to the state property
702723
subscriber.withOnyxInstance.setStateProxy((prevState) => {
703-
const prevWithOnyxData = prevState[subscriber.statePropertyName];
724+
const prevWithOnyxValue = prevState[subscriber.statePropertyName];
704725

705726
// Avoids triggering unnecessary re-renders when feeding empty objects
706-
if (utils.isEmptyObject(data) && utils.isEmptyObject(prevWithOnyxData)) {
727+
if (utils.isEmptyObject(value) && utils.isEmptyObject(prevWithOnyxValue)) {
707728
return null;
708729
}
709-
if (prevWithOnyxData === data) {
730+
if (prevWithOnyxValue === value) {
710731
return null;
711732
}
712733

713-
PerformanceUtils.logSetStateCall(subscriber, prevData, data, 'keyChanged', key);
734+
PerformanceUtils.logSetStateCall(subscriber, previousValue, value, 'keyChanged', key);
714735
return {
715-
[subscriber.statePropertyName]: data,
736+
[subscriber.statePropertyName]: value,
716737
};
717738
});
718739
continue;
@@ -866,11 +887,11 @@ function getCollectionDataAndSendAsObject<TKey extends OnyxKey>(matchingKeys: Co
866887
function scheduleSubscriberUpdate<TKey extends OnyxKey>(
867888
key: TKey,
868889
value: OnyxValue<TKey>,
869-
prevValue: OnyxValue<TKey>,
890+
previousValue: OnyxValue<TKey>,
870891
canUpdateSubscriber: (subscriber?: Mapping<OnyxKey>) => boolean = () => true,
871892
): Promise<void> {
872-
const promise = Promise.resolve().then(() => keyChanged(key, value, prevValue, canUpdateSubscriber, true, false));
873-
batchUpdates(() => keyChanged(key, value, prevValue, canUpdateSubscriber, false, true));
893+
const promise = Promise.resolve().then(() => keyChanged(key, value, previousValue, canUpdateSubscriber, true, false));
894+
batchUpdates(() => keyChanged(key, value, previousValue, canUpdateSubscriber, false, true));
874895
return Promise.all([maybeFlushBatchUpdates(), promise]).then(() => undefined);
875896
}
876897

@@ -879,9 +900,13 @@ function scheduleSubscriberUpdate<TKey extends OnyxKey>(
879900
* so that keysChanged() is triggered for the collection and not keyChanged(). If this was not done, then the
880901
* subscriber callbacks receive the data in a different format than they normally expect and it breaks code.
881902
*/
882-
function scheduleNotifyCollectionSubscribers<TKey extends OnyxKey>(key: TKey, value: OnyxCollection<KeyValueMapping[TKey]>): Promise<void> {
883-
const promise = Promise.resolve().then(() => keysChanged(key, value, true, false));
884-
batchUpdates(() => keysChanged(key, value, false, true));
903+
function scheduleNotifyCollectionSubscribers<TKey extends OnyxKey>(
904+
key: TKey,
905+
value: OnyxCollection<KeyValueMapping[TKey]>,
906+
previousValue?: OnyxCollection<KeyValueMapping[TKey]>,
907+
): Promise<void> {
908+
const promise = Promise.resolve().then(() => keysChanged(key, value, previousValue, true, false));
909+
batchUpdates(() => keysChanged(key, value, previousValue, false, true));
885910
return Promise.all([maybeFlushBatchUpdates(), promise]).then(() => undefined);
886911
}
887912

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.35",
3+
"version": "2.0.36",
44
"author": "Expensify, Inc.",
55
"homepage": "https://expensify.com",
66
"description": "State management for React Native",

tests/unit/onyxTest.js

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const ONYX_KEYS = {
1111
TEST_CONNECT_COLLECTION: 'testConnectCollection_',
1212
TEST_POLICY: 'testPolicy_',
1313
TEST_UPDATE: 'testUpdate_',
14+
ANIMALS: 'animals_',
1415
},
1516
};
1617

@@ -960,16 +961,27 @@ describe('Onyx', () => {
960961
connectionIDs.push(Onyx.connect({key: ONYX_KEYS.TEST_KEY, callback: testCallback}));
961962
connectionIDs.push(Onyx.connect({key: ONYX_KEYS.OTHER_TEST, callback: otherTestCallback}));
962963
connectionIDs.push(Onyx.connect({key: ONYX_KEYS.COLLECTION.TEST_UPDATE, callback: collectionCallback, waitForCollectionCallback: true}));
963-
return Onyx.update([
964-
{onyxMethod: Onyx.METHOD.SET, key: ONYX_KEYS.TEST_KEY, value: 'taco'},
965-
{onyxMethod: Onyx.METHOD.MERGE, key: ONYX_KEYS.OTHER_TEST, value: 'pizza'},
966-
{onyxMethod: Onyx.METHOD.MERGE_COLLECTION, key: ONYX_KEYS.COLLECTION.TEST_UPDATE, value: {[itemKey]: {a: 'a'}}},
967-
]).then(() => {
968-
expect(collectionCallback).toHaveBeenNthCalledWith(1, {[itemKey]: {a: 'a'}});
969-
expect(testCallback).toHaveBeenNthCalledWith(1, 'taco', ONYX_KEYS.TEST_KEY);
970-
expect(otherTestCallback).toHaveBeenNthCalledWith(1, 'pizza', ONYX_KEYS.OTHER_TEST);
971-
Onyx.disconnect(connectionIDs);
972-
});
964+
return waitForPromisesToResolve().then(() =>
965+
Onyx.update([
966+
{onyxMethod: Onyx.METHOD.SET, key: ONYX_KEYS.TEST_KEY, value: 'taco'},
967+
{onyxMethod: Onyx.METHOD.MERGE, key: ONYX_KEYS.OTHER_TEST, value: 'pizza'},
968+
{onyxMethod: Onyx.METHOD.MERGE_COLLECTION, key: ONYX_KEYS.COLLECTION.TEST_UPDATE, value: {[itemKey]: {a: 'a'}}},
969+
]).then(() => {
970+
expect(collectionCallback).toHaveBeenCalledTimes(2);
971+
expect(collectionCallback).toHaveBeenNthCalledWith(1, null, undefined);
972+
expect(collectionCallback).toHaveBeenNthCalledWith(2, {[itemKey]: {a: 'a'}});
973+
974+
expect(testCallback).toHaveBeenCalledTimes(2);
975+
expect(testCallback).toHaveBeenNthCalledWith(1, null, undefined);
976+
expect(testCallback).toHaveBeenNthCalledWith(2, 'taco', ONYX_KEYS.TEST_KEY);
977+
978+
expect(otherTestCallback).toHaveBeenCalledTimes(2);
979+
// We set an initial value of 42 for ONYX_KEYS.OTHER_TEST in Onyx.init()
980+
expect(otherTestCallback).toHaveBeenNthCalledWith(1, 42, ONYX_KEYS.OTHER_TEST);
981+
expect(otherTestCallback).toHaveBeenNthCalledWith(2, 'pizza', ONYX_KEYS.OTHER_TEST);
982+
Onyx.disconnect(connectionIDs);
983+
}),
984+
);
973985
});
974986

975987
it('should merge an object with a batch of objects and undefined', () => {
@@ -1188,4 +1200,47 @@ describe('Onyx', () => {
11881200
});
11891201
});
11901202
});
1203+
1204+
it('should not call a collection item subscriber if the value did not change', () => {
1205+
const connectionIDs = [];
1206+
1207+
const cat = `${ONYX_KEYS.COLLECTION.ANIMALS}cat`;
1208+
const dog = `${ONYX_KEYS.COLLECTION.ANIMALS}dog`;
1209+
1210+
const collectionCallback = jest.fn();
1211+
const catCallback = jest.fn();
1212+
const dogCallback = jest.fn();
1213+
1214+
connectionIDs.push(
1215+
Onyx.connect({
1216+
key: ONYX_KEYS.COLLECTION.ANIMALS,
1217+
callback: collectionCallback,
1218+
waitForCollectionCallback: true,
1219+
}),
1220+
);
1221+
connectionIDs.push(Onyx.connect({key: cat, callback: catCallback}));
1222+
connectionIDs.push(Onyx.connect({key: dog, callback: dogCallback}));
1223+
1224+
const initialValue = {name: 'Fluffy'};
1225+
1226+
const collectionDiff = {
1227+
[cat]: initialValue,
1228+
[dog]: {name: 'Rex'},
1229+
};
1230+
1231+
return Onyx.set(cat, initialValue)
1232+
.then(() => {
1233+
Onyx.mergeCollection(ONYX_KEYS.COLLECTION.ANIMALS, collectionDiff);
1234+
return waitForPromisesToResolve();
1235+
})
1236+
.then(() => {
1237+
expect(collectionCallback).toHaveBeenCalledTimes(3);
1238+
expect(collectionCallback).toHaveBeenCalledWith(collectionDiff);
1239+
1240+
expect(catCallback).toHaveBeenCalledTimes(2);
1241+
expect(dogCallback).toHaveBeenCalledTimes(2);
1242+
1243+
_.map(connectionIDs, Onyx.disconnect);
1244+
});
1245+
});
11911246
});

0 commit comments

Comments
 (0)