react-native-onyx 1.0.54 → 1.0.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/Onyx.js CHANGED
@@ -184,6 +184,46 @@ function isSafeEvictionKey(testKey) {
184
184
  return _.some(evictionAllowList, key => isKeyMatch(key, testKey));
185
185
  }
186
186
 
187
+ /**
188
+ * Tries to get a value from the cache. If the value is not present in cache it will return the default value or undefined.
189
+ * If the requested key is a collection, it will return an object with all the collection members.
190
+ *
191
+ * @param {String} key
192
+ * @param {Object} mapping
193
+ * @returns {Mixed}
194
+ */
195
+ function tryGetCachedValue(key, mapping = {}) {
196
+ let val = cache.getValue(key);
197
+
198
+ if (isCollectionKey(key)) {
199
+ const allKeys = cache.getAllKeys();
200
+ const matchingKeys = _.filter(allKeys, k => k.startsWith(key));
201
+ const values = _.reduce(matchingKeys, (finalObject, matchedKey) => {
202
+ const cachedValue = cache.getValue(matchedKey);
203
+ if (cachedValue) {
204
+ // This is permissible because we're in the process of constructing the final object in a reduce function.
205
+ // eslint-disable-next-line no-param-reassign
206
+ finalObject[matchedKey] = cachedValue;
207
+ }
208
+ return finalObject;
209
+ }, {});
210
+ if (_.isEmpty(values)) {
211
+ return;
212
+ }
213
+ val = values;
214
+ }
215
+
216
+ if (mapping.selector) {
217
+ const state = mapping.withOnyxInstance ? mapping.withOnyxInstance.state : undefined;
218
+ if (isCollectionKey(key)) {
219
+ return reduceCollectionWithSelector(val, mapping.selector, state);
220
+ }
221
+ return getSubsetOfData(val, mapping.selector, state);
222
+ }
223
+
224
+ return val;
225
+ }
226
+
187
227
  /**
188
228
  * Remove a key from the recently accessed key list.
189
229
  *
@@ -848,19 +888,33 @@ function evictStorageAndRetry(error, onyxMethod, ...args) {
848
888
  *
849
889
  * @param {String} key
850
890
  * @param {*} value
891
+ * @param {Boolean} hasChanged
851
892
  * @param {String} method
852
893
  */
853
- function broadcastUpdate(key, value, method) {
894
+ function broadcastUpdate(key, value, hasChanged, method) {
854
895
  // Logging properties only since values could be sensitive things we don't want to log
855
896
  Logger.logInfo(`${method}() called for key: ${key}${_.isObject(value) ? ` properties: ${_.keys(value).join(',')}` : ''}`);
856
897
 
857
898
  // Update subscribers if the cached value has changed, or when the subscriber specifically requires
858
899
  // all updates regardless of value changes (indicated by initWithStoredValues set to false).
859
- const hasChanged = cache.hasValueChanged(key, value);
860
- cache.set(key, value);
900
+ if (hasChanged) {
901
+ cache.set(key, value);
902
+ } else {
903
+ cache.addToAccessedKeys(key);
904
+ }
905
+
861
906
  notifySubscribersOnNextTick(key, value, subscriber => hasChanged || subscriber.initWithStoredValues === false);
862
907
  }
863
908
 
909
+ /**
910
+ * @private
911
+ * @param {String} key
912
+ * @returns {Boolean}
913
+ */
914
+ function hasPendingMergeForKey(key) {
915
+ return Boolean(mergeQueue[key]);
916
+ }
917
+
864
918
  /**
865
919
  * Write a value to our store with the given key
866
920
  *
@@ -874,8 +928,19 @@ function set(key, value) {
874
928
  return remove(key);
875
929
  }
876
930
 
931
+ if (hasPendingMergeForKey(key)) {
932
+ Logger.logAlert(`Onyx.set() called after Onyx.merge() for key: ${key}. It is recommended to use set() or merge() not both.`);
933
+ }
934
+
935
+ const hasChanged = cache.hasValueChanged(key, value);
936
+
877
937
  // This approach prioritizes fast UI changes without waiting for data to be stored in device storage.
878
- broadcastUpdate(key, value, 'set');
938
+ broadcastUpdate(key, value, hasChanged, 'set');
939
+
940
+ // If the value has not changed, calling Storage.setItem() would be redundant and a waste of performance, so return early instead.
941
+ if (!hasChanged) {
942
+ return Promise.resolve();
943
+ }
879
944
 
880
945
  return Storage.setItem(key, value)
881
946
  .catch(error => evictStorageAndRetry(error, set, key, value));
@@ -918,11 +983,11 @@ function multiSet(data) {
918
983
  * Merges an array of changes with an existing value
919
984
  *
920
985
  * @private
921
- * @param {Array<*>} changes Array of changes that should be applied to the existing value
922
986
  * @param {*} existingValue
987
+ * @param {Array<*>} changes Array of changes that should be applied to the existing value
923
988
  * @returns {*}
924
989
  */
925
- function applyMerge(changes, existingValue) {
990
+ function applyMerge(existingValue, changes) {
926
991
  const lastChange = _.last(changes);
927
992
 
928
993
  if (_.isArray(existingValue) || _.isArray(lastChange)) {
@@ -931,14 +996,10 @@ function applyMerge(changes, existingValue) {
931
996
 
932
997
  if (_.isObject(existingValue) || _.every(changes, _.isObject)) {
933
998
  // Object values are merged one after the other
934
- return _.reduce(changes, (modifiedData, change) => {
935
- // lodash adds a small overhead so we don't use it here
936
- // eslint-disable-next-line prefer-object-spread, rulesdir/prefer-underscore-method
937
- const newData = Object.assign({}, fastMerge(modifiedData, change));
938
-
939
- // Remove all first level keys that are explicitly set to null.
940
- return _.omit(newData, value => _.isNull(value));
941
- }, existingValue || {});
999
+ // lodash adds a small overhead so we don't use it here
1000
+ // eslint-disable-next-line prefer-object-spread, rulesdir/prefer-underscore-method
1001
+ return _.reduce(changes, (modifiedData, change) => fastMerge(modifiedData, change),
1002
+ existingValue || {});
942
1003
  }
943
1004
 
944
1005
  // If we have anything else we can't merge it so we'll
@@ -979,17 +1040,30 @@ function merge(key, changes) {
979
1040
  .then((existingValue) => {
980
1041
  try {
981
1042
  // We first only merge the changes, so we can provide these to the native implementation (SQLite uses only delta changes in "JSON_PATCH" to merge)
982
- const batchedChanges = applyMerge(mergeQueue[key]);
1043
+ const batchedChanges = applyMerge(undefined, mergeQueue[key]);
983
1044
 
984
1045
  // Clean up the write queue so we
985
1046
  // don't apply these changes again
986
1047
  delete mergeQueue[key];
987
1048
 
988
1049
  // After that we merge the batched changes with the existing value
989
- const modifiedData = applyMerge([batchedChanges], existingValue);
1050
+ let modifiedData = applyMerge(existingValue, [batchedChanges]);
1051
+
1052
+ // For objects, the key for null values needs to be removed from the object to ensure the value will get removed from storage completely.
1053
+ // On native, SQLite will remove top-level keys that are null. To be consistent, we remove them on web too.
1054
+ if (!_.isArray(modifiedData) && _.isObject(modifiedData)) {
1055
+ modifiedData = _.omit(modifiedData, value => _.isNull(value));
1056
+ }
1057
+
1058
+ const hasChanged = cache.hasValueChanged(key, modifiedData);
990
1059
 
991
1060
  // This approach prioritizes fast UI changes without waiting for data to be stored in device storage.
992
- broadcastUpdate(key, modifiedData, 'merge');
1061
+ broadcastUpdate(key, modifiedData, hasChanged, 'merge');
1062
+
1063
+ // If the value has not changed, calling Storage.setItem() would be redundant and a waste of performance, so return early instead.
1064
+ if (!hasChanged) {
1065
+ return Promise.resolve();
1066
+ }
993
1067
 
994
1068
  return Storage.mergeItem(key, batchedChanges, modifiedData);
995
1069
  } catch (error) {
@@ -1000,15 +1074,6 @@ function merge(key, changes) {
1000
1074
  });
1001
1075
  }
1002
1076
 
1003
- /**
1004
- * @private
1005
- * @param {String} key
1006
- * @returns {Boolean}
1007
- */
1008
- function hasPendingMergeForKey(key) {
1009
- return Boolean(mergeQueue[key]);
1010
- }
1011
-
1012
1077
  /**
1013
1078
  * Merge user provided default key value pairs.
1014
1079
  * @private
@@ -1328,6 +1393,7 @@ const Onyx = {
1328
1393
  isSafeEvictionKey,
1329
1394
  METHOD,
1330
1395
  setMemoryOnlyKeys,
1396
+ tryGetCachedValue,
1331
1397
  };
1332
1398
 
1333
1399
  /**
@@ -4,9 +4,8 @@ import fastMerge from '../../fastMerge';
4
4
  let storageMapInternal = {};
5
5
 
6
6
  const set = jest.fn((key, value) => {
7
- // eslint-disable-next-line no-param-reassign
8
7
  storageMapInternal[key] = value;
9
- return Promise.resolve(storageMapInternal[key]);
8
+ return Promise.resolve(value);
10
9
  });
11
10
 
12
11
  const localForageMock = {
package/lib/withOnyx.js CHANGED
@@ -37,13 +37,25 @@ export default function (mapOnyxToState) {
37
37
  // disconnected. It is a key value store with the format {[mapping.key]: connectionID}.
38
38
  this.activeConnectionIDs = {};
39
39
 
40
+ const cachedState = _.reduce(mapOnyxToState, (resultObj, mapping, propertyName) => {
41
+ const key = Str.result(mapping.key, props);
42
+ const value = Onyx.tryGetCachedValue(key, mapping);
43
+
44
+ if (value !== undefined) {
45
+ // eslint-disable-next-line no-param-reassign
46
+ resultObj[propertyName] = value;
47
+ }
48
+
49
+ return resultObj;
50
+ }, {});
51
+
52
+ // If we have all the data we need, then we can render the component immediately
53
+ cachedState.loading = _.size(cachedState) < requiredKeysForInit.length;
54
+
40
55
  // Object holding the temporary initial state for the component while we load the various Onyx keys
41
- this.tempState = {};
56
+ this.tempState = cachedState;
42
57
 
43
- this.state = {
44
- // If there are no required keys for init then we can render the wrapped component immediately
45
- loading: requiredKeysForInit.length > 0,
46
- };
58
+ this.state = cachedState;
47
59
  }
48
60
 
49
61
  componentDidMount() {
@@ -60,7 +72,6 @@ export default function (mapOnyxToState) {
60
72
  _.each(mapOnyxToState, (mapping, propertyName) => {
61
73
  const previousKey = Str.result(mapping.key, prevProps);
62
74
  const newKey = Str.result(mapping.key, this.props);
63
-
64
75
  if (previousKey !== newKey) {
65
76
  Onyx.disconnect(this.activeConnectionIDs[previousKey], previousKey);
66
77
  delete this.activeConnectionIDs[previousKey];
@@ -88,6 +99,16 @@ export default function (mapOnyxToState) {
88
99
  * @param {*} val
89
100
  */
90
101
  setWithOnyxState(statePropertyName, val) {
102
+ // We might have loaded the values for the onyx keys/mappings already from the cache.
103
+ // In case we were able to load all the values upfront, the loading state will be false.
104
+ // However, Onyx.js will always call setWithOnyxState, as it doesn't know that this implementation
105
+ // already loaded the values from cache. Thus we have to check whether the value has changed
106
+ // before we set the state to prevent unnecessary renders.
107
+ const prevValue = this.state[statePropertyName];
108
+ if (!this.state.loading && prevValue === val) {
109
+ return;
110
+ }
111
+
91
112
  if (!this.state.loading) {
92
113
  this.setState({[statePropertyName]: val});
93
114
  return;
@@ -100,7 +121,14 @@ export default function (mapOnyxToState) {
100
121
  return;
101
122
  }
102
123
 
103
- this.setState({...this.tempState, loading: false});
124
+ const stateUpdate = {...this.tempState, loading: false};
125
+
126
+ // The state is set here manually, instead of using setState because setState is an async operation, meaning it might execute on the next tick,
127
+ // or at a later point in the microtask queue. That can lead to a race condition where
128
+ // setWithOnyxState is called before the state is actually set. This results in unreliable behavior when checking the loading state and has been mainly observed on fabric.
129
+ this.state = stateUpdate;
130
+
131
+ this.setState(stateUpdate); // Trigger a render
104
132
  delete this.tempState;
105
133
  }
106
134
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-onyx",
3
- "version": "1.0.54",
3
+ "version": "1.0.56",
4
4
  "author": "Expensify, Inc.",
5
5
  "homepage": "https://expensify.com",
6
6
  "description": "State management for React Native",