react-native-onyx 1.0.52 → 1.0.54

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,24 @@ 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 {String} method
852
+ */
853
+ function broadcastUpdate(key, value, method) {
854
+ // Logging properties only since values could be sensitive things we don't want to log
855
+ Logger.logInfo(`${method}() called for key: ${key}${_.isObject(value) ? ` properties: ${_.keys(value).join(',')}` : ''}`);
856
+
857
+ // Update subscribers if the cached value has changed, or when the subscriber specifically requires
858
+ // all updates regardless of value changes (indicated by initWithStoredValues set to false).
859
+ const hasChanged = cache.hasValueChanged(key, value);
860
+ cache.set(key, value);
861
+ notifySubscribersOnNextTick(key, value, subscriber => hasChanged || subscriber.initWithStoredValues === false);
862
+ }
863
+
843
864
  /**
844
865
  * Write a value to our store with the given key
845
866
  *
@@ -853,24 +874,9 @@ function set(key, value) {
853
874
  return remove(key);
854
875
  }
855
876
 
856
- // eslint-disable-next-line no-use-before-define
857
- if (hasPendingMergeForKey(key)) {
858
- Logger.logAlert(`Onyx.set() called after Onyx.merge() for key: ${key}. It is recommended to use set() or merge() not both.`);
859
- }
860
-
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);
866
- return Promise.resolve();
867
- }
868
-
869
- // Adds the key to cache when it's not available
870
- cache.set(key, value);
871
- notifySubscribersOnNextTick(key, value);
877
+ // This approach prioritizes fast UI changes without waiting for data to be stored in device storage.
878
+ broadcastUpdate(key, value, 'set');
872
879
 
873
- // Write the thing to persistent storage, which will trigger a storage event for any other tabs open on this domain
874
880
  return Storage.setItem(key, value)
875
881
  .catch(error => evictStorageAndRetry(error, set, key, value));
876
882
  }
@@ -908,67 +914,43 @@ function multiSet(data) {
908
914
  .catch(error => evictStorageAndRetry(error, multiSet, data));
909
915
  }
910
916
 
911
- // Key/value store of Onyx key and arrays of values to merge
912
- const mergeQueue = {};
913
-
914
- /**
915
- * @private
916
- * @param {String} key
917
- * @returns {Boolean}
918
- */
919
- function hasPendingMergeForKey(key) {
920
- return Boolean(mergeQueue[key]);
921
- }
922
-
923
917
  /**
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.
918
+ * Merges an array of changes with an existing value
928
919
  *
929
920
  * @private
930
- * @param {String} key
931
- * @param {*} data
932
- *
921
+ * @param {Array<*>} changes Array of changes that should be applied to the existing value
922
+ * @param {*} existingValue
933
923
  * @returns {*}
934
924
  */
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 || []);
925
+ function applyMerge(changes, existingValue) {
926
+ const lastChange = _.last(changes);
927
+
928
+ if (_.isArray(existingValue) || _.isArray(lastChange)) {
929
+ return lastChange;
944
930
  }
945
931
 
946
- if (_.isObject(data) || _.every(mergeValues, _.isObject)) {
932
+ if (_.isObject(existingValue) || _.every(changes, _.isObject)) {
947
933
  // Object values are merged one after the other
948
- return _.reduce(mergeValues, (modifiedData, mergeValue) => {
934
+ return _.reduce(changes, (modifiedData, change) => {
949
935
  // lodash adds a small overhead so we don't use it here
950
936
  // eslint-disable-next-line prefer-object-spread, rulesdir/prefer-underscore-method
951
- const newData = Object.assign({}, fastMerge(modifiedData, mergeValue));
937
+ const newData = Object.assign({}, fastMerge(modifiedData, change));
952
938
 
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
939
  // Remove all first level keys that are explicitly set to null.
956
- return _.omit(newData, (value, finalObjectKey) => _.isNull(mergeValue[finalObjectKey]));
957
- }, data || {});
940
+ return _.omit(newData, value => _.isNull(value));
941
+ }, existingValue || {});
958
942
  }
959
943
 
960
944
  // If we have anything else we can't merge it so we'll
961
945
  // simply return the last value that was queued
962
- return _.last(mergeValues);
946
+ return lastChange;
963
947
  }
964
948
 
965
949
  /**
966
950
  * Merge a new value into an existing value at a key.
967
951
  *
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
952
+ * The types of values that can be merged are `Object` and `Array`. To set another type of value use `Onyx.set()`.
953
+ * 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
954
  *
973
955
  * Calls to `Onyx.merge()` are batched so that any calls performed in a single tick will stack in a queue and get
974
956
  * applied in the order they were called. Note: `Onyx.set()` calls do not work this way so use caution when mixing
@@ -981,26 +963,35 @@ function applyMerge(key, data) {
981
963
  * Onyx.merge(ONYXKEYS.POLICY, {name: 'My Workspace'}); // -> {id: 1, name: 'My Workspace'}
982
964
  *
983
965
  * @param {String} key ONYXKEYS key
984
- * @param {(Object|Array)} value Object or Array value to merge
966
+ * @param {(Object|Array)} changes Object or Array value to merge
985
967
  * @returns {Promise}
986
968
  */
987
- function merge(key, value) {
969
+ function merge(key, changes) {
970
+ // Merge attempts are batched together. The delta should be applied after a single call to get() to prevent a race condition.
971
+ // Using the initial value from storage in subsequent merge attempts will lead to an incorrect final merged value.
988
972
  if (mergeQueue[key]) {
989
- mergeQueue[key].push(value);
973
+ mergeQueue[key].push(changes);
990
974
  return Promise.resolve();
991
975
  }
976
+ mergeQueue[key] = [changes];
992
977
 
993
- mergeQueue[key] = [value];
994
978
  return get(key)
995
- .then((data) => {
979
+ .then((existingValue) => {
996
980
  try {
997
- const modifiedData = applyMerge(key, data);
981
+ // 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]);
998
983
 
999
984
  // Clean up the write queue so we
1000
985
  // don't apply these changes again
1001
986
  delete mergeQueue[key];
1002
987
 
1003
- return set(key, modifiedData);
988
+ // After that we merge the batched changes with the existing value
989
+ const modifiedData = applyMerge([batchedChanges], existingValue);
990
+
991
+ // This approach prioritizes fast UI changes without waiting for data to be stored in device storage.
992
+ broadcastUpdate(key, modifiedData, 'merge');
993
+
994
+ return Storage.mergeItem(key, batchedChanges, modifiedData);
1004
995
  } catch (error) {
1005
996
  Logger.logAlert(`An error occurred while applying merge for key: ${key}, Error: ${error}`);
1006
997
  }
@@ -1009,6 +1000,15 @@ function merge(key, value) {
1009
1000
  });
1010
1001
  }
1011
1002
 
1003
+ /**
1004
+ * @private
1005
+ * @param {String} key
1006
+ * @returns {Boolean}
1007
+ */
1008
+ function hasPendingMergeForKey(key) {
1009
+ return Boolean(mergeQueue[key]);
1010
+ }
1011
+
1012
1012
  /**
1013
1013
  * Merge user provided default key value pairs.
1014
1014
  * @private
@@ -1134,6 +1134,11 @@ function mergeCollection(collectionKey, collection) {
1134
1134
  if (isKeyMatch(collectionKey, dataKey)) {
1135
1135
  return;
1136
1136
  }
1137
+
1138
+ if (process.env.NODE_ENV === 'development') {
1139
+ throw new Error(`Provided collection doesn't have all its data belonging to the same parent. CollectionKey: ${collectionKey}, DataKey: ${dataKey}`);
1140
+ }
1141
+
1137
1142
  hasCollectionKeyCheckFailed = true;
1138
1143
  Logger.logAlert(`Provided collection doesn't have all its data belonging to the same parent. CollectionKey: ${collectionKey}, DataKey: ${dataKey}`);
1139
1144
  });
@@ -1312,6 +1317,7 @@ const Onyx = {
1312
1317
  multiSet,
1313
1318
  merge,
1314
1319
  mergeCollection,
1320
+ hasPendingMergeForKey,
1315
1321
  update,
1316
1322
  clear,
1317
1323
  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
  /*
@@ -11,8 +11,7 @@ const set = jest.fn((key, value) => {
11
11
 
12
12
  const localForageMock = {
13
13
  setItem(key, value) {
14
- set(key, value);
15
- return Promise.resolve();
14
+ return set(key, value);
16
15
  },
17
16
  multiSet(pairs) {
18
17
  const setPromises = _.map(pairs, ([key, value]) => this.setItem(key, value));
@@ -36,6 +35,9 @@ const localForageMock = {
36
35
 
37
36
  return Promise.resolve(storageMapInternal);
38
37
  },
38
+ mergeItem(key, _changes, modifiedData) {
39
+ return this.setItem(key, modifiedData);
40
+ },
39
41
  removeItem(key) {
40
42
  delete storageMapInternal[key];
41
43
  return Promise.resolve();
@@ -68,7 +70,7 @@ const localForageMockSpy = {
68
70
  multiGet: jest.fn(localForageMock.multiGet),
69
71
  multiSet: jest.fn(localForageMock.multiSet),
70
72
  multiMerge: jest.fn(localForageMock.multiMerge),
71
-
73
+ mergeItem: jest.fn(localForageMock.mergeItem),
72
74
  getStorageMap: jest.fn(() => storageMapInternal),
73
75
  setInitialMockData: jest.fn((data) => {
74
76
  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.52",
3
+ "version": "1.0.54",
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",