react-native-onyx 1.0.53 → 1.0.55

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
@@ -18,6 +18,9 @@ const METHOD = {
18
18
  CLEAR: 'clear',
19
19
  };
20
20
 
21
+ // Key/value store of Onyx key and arrays of values to merge
22
+ const mergeQueue = {};
23
+
21
24
  // Keeps track of the last connectionID that was used so we can keep incrementing it
22
25
  let lastConnectionID = 0;
23
26
 
@@ -840,6 +843,38 @@ function evictStorageAndRetry(error, onyxMethod, ...args) {
840
843
  .then(() => onyxMethod(...args));
841
844
  }
842
845
 
846
+ /**
847
+ * Notifys subscribers and writes current value to cache
848
+ *
849
+ * @param {String} key
850
+ * @param {*} value
851
+ * @param {Boolean} hasChanged
852
+ * @param {String} method
853
+ */
854
+ function broadcastUpdate(key, value, hasChanged, method) {
855
+ // Logging properties only since values could be sensitive things we don't want to log
856
+ Logger.logInfo(`${method}() called for key: ${key}${_.isObject(value) ? ` properties: ${_.keys(value).join(',')}` : ''}`);
857
+
858
+ // Update subscribers if the cached value has changed, or when the subscriber specifically requires
859
+ // all updates regardless of value changes (indicated by initWithStoredValues set to false).
860
+ if (hasChanged) {
861
+ cache.set(key, value);
862
+ } else {
863
+ cache.addToAccessedKeys(key);
864
+ }
865
+
866
+ notifySubscribersOnNextTick(key, value, subscriber => hasChanged || subscriber.initWithStoredValues === false);
867
+ }
868
+
869
+ /**
870
+ * @private
871
+ * @param {String} key
872
+ * @returns {Boolean}
873
+ */
874
+ function hasPendingMergeForKey(key) {
875
+ return Boolean(mergeQueue[key]);
876
+ }
877
+
843
878
  /**
844
879
  * Write a value to our store with the given key
845
880
  *
@@ -853,24 +888,20 @@ function set(key, value) {
853
888
  return remove(key);
854
889
  }
855
890
 
856
- // eslint-disable-next-line no-use-before-define
857
891
  if (hasPendingMergeForKey(key)) {
858
892
  Logger.logAlert(`Onyx.set() called after Onyx.merge() for key: ${key}. It is recommended to use set() or merge() not both.`);
859
893
  }
860
894
 
861
- // If the value in the cache is the same as what we have then do not update subscribers unless they
862
- // have initWithStoredValues: false then they MUST get all updates even if nothing has changed.
863
- if (!cache.hasValueChanged(key, value)) {
864
- cache.addToAccessedKeys(key);
865
- notifySubscribersOnNextTick(key, value, subscriber => subscriber.initWithStoredValues === false);
895
+ const hasChanged = cache.hasValueChanged(key, value);
896
+
897
+ // This approach prioritizes fast UI changes without waiting for data to be stored in device storage.
898
+ broadcastUpdate(key, value, hasChanged, 'set');
899
+
900
+ // If the value has not changed, calling Storage.setItem() would be redundant and a waste of performance, so return early instead.
901
+ if (!hasChanged) {
866
902
  return Promise.resolve();
867
903
  }
868
904
 
869
- // Adds the key to cache when it's not available
870
- cache.set(key, value);
871
- notifySubscribersOnNextTick(key, value);
872
-
873
- // Write the thing to persistent storage, which will trigger a storage event for any other tabs open on this domain
874
905
  return Storage.setItem(key, value)
875
906
  .catch(error => evictStorageAndRetry(error, set, key, value));
876
907
  }
@@ -908,67 +939,39 @@ function multiSet(data) {
908
939
  .catch(error => evictStorageAndRetry(error, multiSet, data));
909
940
  }
910
941
 
911
- // Key/value store of Onyx key and arrays of values to merge
912
- const mergeQueue = {};
913
-
914
942
  /**
915
- * @private
916
- * @param {String} key
917
- * @returns {Boolean}
918
- */
919
- function hasPendingMergeForKey(key) {
920
- return Boolean(mergeQueue[key]);
921
- }
922
-
923
- /**
924
- * Given an Onyx key and value this method will combine all queued
925
- * value updates and return a single value. Merge attempts are
926
- * batched. They must occur after a single call to get() so we
927
- * can avoid race conditions.
943
+ * Merges an array of changes with an existing value
928
944
  *
929
945
  * @private
930
- * @param {String} key
931
- * @param {*} data
932
- *
946
+ * @param {*} existingValue
947
+ * @param {Array<*>} changes Array of changes that should be applied to the existing value
933
948
  * @returns {*}
934
949
  */
935
- function applyMerge(key, data) {
936
- const mergeValues = mergeQueue[key];
937
- if (_.isArray(data) || _.every(mergeValues, _.isArray)) {
938
- // Array values will always just concatenate
939
- // more items onto the end of the array
940
- return _.reduce(mergeValues, (modifiedData, mergeValue) => [
941
- ...modifiedData,
942
- ...mergeValue,
943
- ], data || []);
950
+ function applyMerge(existingValue, changes) {
951
+ const lastChange = _.last(changes);
952
+
953
+ if (_.isArray(existingValue) || _.isArray(lastChange)) {
954
+ return lastChange;
944
955
  }
945
956
 
946
- if (_.isObject(data) || _.every(mergeValues, _.isObject)) {
957
+ if (_.isObject(existingValue) || _.every(changes, _.isObject)) {
947
958
  // Object values are merged one after the other
948
- return _.reduce(mergeValues, (modifiedData, mergeValue) => {
949
- // lodash adds a small overhead so we don't use it here
950
- // eslint-disable-next-line prefer-object-spread, rulesdir/prefer-underscore-method
951
- const newData = Object.assign({}, fastMerge(modifiedData, mergeValue));
952
-
953
- // We will also delete any object keys that are undefined or null.
954
- // Deleting keys is not supported by AsyncStorage so we do it this way.
955
- // Remove all first level keys that are explicitly set to null.
956
- return _.omit(newData, (value, finalObjectKey) => _.isNull(mergeValue[finalObjectKey]));
957
- }, data || {});
959
+ // lodash adds a small overhead so we don't use it here
960
+ // eslint-disable-next-line prefer-object-spread, rulesdir/prefer-underscore-method
961
+ return _.reduce(changes, (modifiedData, change) => fastMerge(modifiedData, change),
962
+ existingValue || {});
958
963
  }
959
964
 
960
965
  // If we have anything else we can't merge it so we'll
961
966
  // simply return the last value that was queued
962
- return _.last(mergeValues);
967
+ return lastChange;
963
968
  }
964
969
 
965
970
  /**
966
971
  * Merge a new value into an existing value at a key.
967
972
  *
968
- * The types of values that can be merged are `Object` and `Array`. To set another type of value use `Onyx.set()`. Merge
969
- * behavior uses lodash/merge under the hood for `Object` and simple concatenation for `Array`. However, it's important
970
- * to note that if you have an array value property on an `Object` that the default behavior of lodash/merge is not to
971
- * concatenate. See here: https://github.com/lodash/lodash/issues/2872
973
+ * The types of values that can be merged are `Object` and `Array`. To set another type of value use `Onyx.set()`.
974
+ * Values of type `Object` get merged with the old value, whilst for `Array`'s we simply replace the current value with the new one.
972
975
  *
973
976
  * Calls to `Onyx.merge()` are batched so that any calls performed in a single tick will stack in a queue and get
974
977
  * applied in the order they were called. Note: `Onyx.set()` calls do not work this way so use caution when mixing
@@ -981,26 +984,48 @@ function applyMerge(key, data) {
981
984
  * Onyx.merge(ONYXKEYS.POLICY, {name: 'My Workspace'}); // -> {id: 1, name: 'My Workspace'}
982
985
  *
983
986
  * @param {String} key ONYXKEYS key
984
- * @param {(Object|Array)} value Object or Array value to merge
987
+ * @param {(Object|Array)} changes Object or Array value to merge
985
988
  * @returns {Promise}
986
989
  */
987
- function merge(key, value) {
990
+ function merge(key, changes) {
991
+ // Merge attempts are batched together. The delta should be applied after a single call to get() to prevent a race condition.
992
+ // Using the initial value from storage in subsequent merge attempts will lead to an incorrect final merged value.
988
993
  if (mergeQueue[key]) {
989
- mergeQueue[key].push(value);
994
+ mergeQueue[key].push(changes);
990
995
  return Promise.resolve();
991
996
  }
997
+ mergeQueue[key] = [changes];
992
998
 
993
- mergeQueue[key] = [value];
994
999
  return get(key)
995
- .then((data) => {
1000
+ .then((existingValue) => {
996
1001
  try {
997
- const modifiedData = applyMerge(key, data);
1002
+ // 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)
1003
+ const batchedChanges = applyMerge(undefined, mergeQueue[key]);
998
1004
 
999
1005
  // Clean up the write queue so we
1000
1006
  // don't apply these changes again
1001
1007
  delete mergeQueue[key];
1002
1008
 
1003
- return set(key, modifiedData);
1009
+ // After that we merge the batched changes with the existing value
1010
+ let modifiedData = applyMerge(existingValue, [batchedChanges]);
1011
+
1012
+ // For objects, the key for null values needs to be removed from the object to ensure the value will get removed from storage completely.
1013
+ // On native, SQLite will remove top-level keys that are null. To be consistent, we remove them on web too.
1014
+ if (!_.isArray(modifiedData) && _.isObject(modifiedData)) {
1015
+ modifiedData = _.omit(modifiedData, value => _.isNull(value));
1016
+ }
1017
+
1018
+ const hasChanged = cache.hasValueChanged(key, modifiedData);
1019
+
1020
+ // This approach prioritizes fast UI changes without waiting for data to be stored in device storage.
1021
+ broadcastUpdate(key, modifiedData, hasChanged, 'merge');
1022
+
1023
+ // If the value has not changed, calling Storage.setItem() would be redundant and a waste of performance, so return early instead.
1024
+ if (!hasChanged) {
1025
+ return Promise.resolve();
1026
+ }
1027
+
1028
+ return Storage.mergeItem(key, batchedChanges, modifiedData);
1004
1029
  } catch (error) {
1005
1030
  Logger.logAlert(`An error occurred while applying merge for key: ${key}, Error: ${error}`);
1006
1031
  }
@@ -1317,6 +1342,7 @@ const Onyx = {
1317
1342
  multiSet,
1318
1343
  merge,
1319
1344
  mergeCollection,
1345
+ hasPendingMergeForKey,
1320
1346
  update,
1321
1347
  clear,
1322
1348
  getAllKeys,
@@ -43,6 +43,7 @@ function decorateWithMetrics(func, alias = func.name) {
43
43
  function decorated(...args) {
44
44
  const mark = addMark(alias, args);
45
45
 
46
+ // eslint-disable-next-line no-invalid-this
46
47
  const originalPromise = func.apply(this, args);
47
48
 
48
49
  /*
@@ -4,15 +4,13 @@ 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 = {
13
12
  setItem(key, value) {
14
- set(key, value);
15
- return Promise.resolve();
13
+ return set(key, value);
16
14
  },
17
15
  multiSet(pairs) {
18
16
  const setPromises = _.map(pairs, ([key, value]) => this.setItem(key, value));
@@ -36,6 +34,9 @@ const localForageMock = {
36
34
 
37
35
  return Promise.resolve(storageMapInternal);
38
36
  },
37
+ mergeItem(key, _changes, modifiedData) {
38
+ return this.setItem(key, modifiedData);
39
+ },
39
40
  removeItem(key) {
40
41
  delete storageMapInternal[key];
41
42
  return Promise.resolve();
@@ -68,7 +69,7 @@ const localForageMockSpy = {
68
69
  multiGet: jest.fn(localForageMock.multiGet),
69
70
  multiSet: jest.fn(localForageMock.multiSet),
70
71
  multiMerge: jest.fn(localForageMock.multiMerge),
71
-
72
+ mergeItem: jest.fn(localForageMock.mergeItem),
72
73
  getStorageMap: jest.fn(() => storageMapInternal),
73
74
  setInitialMockData: jest.fn((data) => {
74
75
  storageMapInternal = data;
@@ -49,6 +49,16 @@ const provider = {
49
49
  return localforage.setItem(key, value);
50
50
  }),
51
51
 
52
+ /**
53
+ * Sets the value for a given key. The only requirement is that the value should be serializable to JSON string
54
+ * @param {String} key
55
+ * @param {*} value
56
+ * @return {Promise<void>}
57
+ */
58
+ setItem(key, value) {
59
+ return this.setItemQueue.push({key, value});
60
+ },
61
+
52
62
  /**
53
63
  * Get multiple key-value pairs for the give array of keys in a batch
54
64
  * @param {String[]} keys
@@ -76,6 +86,17 @@ const provider = {
76
86
  return Promise.all(tasks).then(() => Promise.resolve());
77
87
  },
78
88
 
89
+ /**
90
+ * Merging an existing value with a new one
91
+ * @param {String} key
92
+ * @param {any} _changes - not used, as we rely on the pre-merged data from the `modifiedData`
93
+ * @param {any} modifiedData - the pre-merged data from `Onyx.applyMerge`
94
+ * @return {Promise<void>}
95
+ */
96
+ mergeItem(key, _changes, modifiedData) {
97
+ return this.setItem(key, modifiedData);
98
+ },
99
+
79
100
  /**
80
101
  * Stores multiple key-value pairs in a batch
81
102
  * @param {Array<[key, value]>} pairs
@@ -126,16 +147,6 @@ const provider = {
126
147
  return localforage.removeItems(keys);
127
148
  },
128
149
 
129
- /**
130
- * Sets the value for a given key. The only requirement is that the value should be serializable to JSON string
131
- * @param {String} key
132
- * @param {*} value
133
- * @return {Promise<void>}
134
- */
135
- setItem(key, value) {
136
- return this.setItemQueue.push({key, value});
137
- },
138
-
139
150
  /**
140
151
  * @param {string[]} keyList
141
152
  */
@@ -82,10 +82,10 @@ const provider = {
82
82
  multiMerge(pairs) {
83
83
  // Note: We use `ON CONFLICT DO UPDATE` here instead of `INSERT OR REPLACE INTO`
84
84
  // so the new JSON value is merged into the old one if there's an existing value
85
- const query = `INSERT INTO keyvaluepairs (record_key, valueJSON)
86
- VALUES (:key, JSON(:value))
87
- ON CONFLICT DO UPDATE
88
- SET valueJSON = JSON_PATCH(valueJSON, JSON(:value));
85
+ const query = `INSERT INTO keyvaluepairs (record_key, valueJSON)
86
+ VALUES (:key, JSON(:value))
87
+ ON CONFLICT DO UPDATE
88
+ SET valueJSON = JSON_PATCH(valueJSON, JSON(:value));
89
89
  `;
90
90
  const queryArguments = _.map(pairs, (pair) => {
91
91
  const value = JSON.stringify(pair[1]);
@@ -94,6 +94,16 @@ const provider = {
94
94
  return db.executeBatchAsync([[query, queryArguments]]);
95
95
  },
96
96
 
97
+ /**
98
+ * Merges an existing value with a new one by leveraging JSON_PATCH
99
+ * @param {String} key
100
+ * @param {*} changes - the delta for a specific key
101
+ * @return {Promise<void>}
102
+ */
103
+ mergeItem(key, changes) {
104
+ return this.multiMerge([[key, changes]]);
105
+ },
106
+
97
107
  /**
98
108
  * Returns all keys available in storage
99
109
  * @returns {Promise<String[]>}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-onyx",
3
- "version": "1.0.53",
3
+ "version": "1.0.55",
4
4
  "author": "Expensify, Inc.",
5
5
  "homepage": "https://expensify.com",
6
6
  "description": "State management for React Native",
@@ -55,7 +55,7 @@
55
55
  "babel-jest": "^26.2.2",
56
56
  "babel-loader": "^8.2.5",
57
57
  "eslint": "^7.6.0",
58
- "eslint-config-expensify": "^2.0.24",
58
+ "eslint-config-expensify": "^2.0.38",
59
59
  "eslint-plugin-jsx-a11y": "^6.6.1",
60
60
  "eslint-plugin-react": "^7.31.10",
61
61
  "jest": "^26.5.2",