react-native-onyx 3.0.84 → 3.0.86

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.
@@ -122,8 +122,10 @@ declare class OnyxCache {
122
122
  addEvictableKeysToRecentlyAccessedList(isCollectionKeyFn: (key: OnyxKey) => boolean, getAllKeysFn: () => Promise<Set<OnyxKey>>): Promise<void>;
123
123
  /**
124
124
  * Finds the least recently accessed key that can be safely evicted from storage.
125
+ * `excludeKeys` skips keys that must not be evicted (e.g. the in-flight write's own keys,
126
+ * whose cache value is the merge base the retry depends on).
125
127
  */
126
- getKeyForEviction(): OnyxKey | undefined;
128
+ getKeyForEviction(excludeKeys?: Set<OnyxKey>): OnyxKey | undefined;
127
129
  /**
128
130
  * Set the collection keys for optimized storage
129
131
  */
package/dist/OnyxCache.js CHANGED
@@ -265,11 +265,18 @@ class OnyxCache {
265
265
  }
266
266
  /**
267
267
  * Finds the least recently accessed key that can be safely evicted from storage.
268
+ * `excludeKeys` skips keys that must not be evicted (e.g. the in-flight write's own keys,
269
+ * whose cache value is the merge base the retry depends on).
268
270
  */
269
- getKeyForEviction() {
271
+ getKeyForEviction(excludeKeys) {
270
272
  // recentlyAccessedKeys is ordered from least to most recently accessed,
271
- // so the first element is the best candidate for eviction.
272
- return this.recentlyAccessedKeys.values().next().value;
273
+ // so the first non-excluded key is the best candidate for eviction.
274
+ for (const key of this.recentlyAccessedKeys) {
275
+ if (!(excludeKeys === null || excludeKeys === void 0 ? void 0 : excludeKeys.has(key))) {
276
+ return key;
277
+ }
278
+ }
279
+ return undefined;
273
280
  }
274
281
  /**
275
282
  * Set the collection keys for optimized storage
@@ -140,7 +140,7 @@ declare function reportStorageQuota(error?: Error): Promise<void>;
140
140
  * - Non-retriable errors: logs an alert and resolves without retrying
141
141
  * - Other errors: retries the operation
142
142
  */
143
- declare function retryOperation<TMethod extends RetriableOnyxOperation>(error: Error, onyxMethod: TMethod, defaultParams: Parameters<TMethod>[0], retryAttempt: number | undefined): Promise<void>;
143
+ declare function retryOperation<TMethod extends RetriableOnyxOperation>(error: Error, onyxMethod: TMethod, defaultParams: Parameters<TMethod>[0], retryAttempt: number | undefined, inFlightKeys?: Set<OnyxKey>): Promise<void>;
144
144
  /**
145
145
  * Notifies subscribers and writes current value to cache
146
146
  */
package/dist/OnyxUtils.js CHANGED
@@ -679,7 +679,7 @@ function reportStorageQuota(error) {
679
679
  * - Non-retriable errors: logs an alert and resolves without retrying
680
680
  * - Other errors: retries the operation
681
681
  */
682
- function retryOperation(error, onyxMethod, defaultParams, retryAttempt) {
682
+ function retryOperation(error, onyxMethod, defaultParams, retryAttempt, inFlightKeys) {
683
683
  var _a, _b, _c, _d;
684
684
  const currentRetryAttempt = retryAttempt !== null && retryAttempt !== void 0 ? retryAttempt : 0;
685
685
  const nextRetryAttempt = currentRetryAttempt + 1;
@@ -704,8 +704,10 @@ function retryOperation(error, onyxMethod, defaultParams, retryAttempt) {
704
704
  // @ts-expect-error No overload matches this call.
705
705
  return onyxMethod(defaultParams, nextRetryAttempt);
706
706
  }
707
- // Find the least recently accessed evictable key that we can remove
708
- const keyForRemoval = OnyxCache_1.default.getKeyForEviction();
707
+ // Find the least recently accessed evictable key that we can remove. Never evict an in-flight
708
+ // key its cache value is the merge base this retry depends on, so dropping it would truncate
709
+ // the write to just the delta and diverge cache from storage.
710
+ const keyForRemoval = OnyxCache_1.default.getKeyForEviction(inFlightKeys);
709
711
  if (!keyForRemoval) {
710
712
  // If we have no acceptable keys to remove then we are possibly trying to save mission critical data. If this is the case,
711
713
  // then we should stop retrying as there is not much the user can do to fix this. Instead of getting them stuck in an infinite loop we
@@ -1177,21 +1179,29 @@ function multiSetWithRetry(data, retryAttempt) {
1177
1179
  // so re-entrant callbacks (e.g. Onyx.set inside a callback) see consistent cache
1178
1180
  // and subscriber state, matching the original per-key notification semantics.
1179
1181
  OnyxCache_1.default.set(key, value);
1180
- keyChanged(key, value);
1182
+ // Skip subscriber notification on retry — already notified on attempt 0.
1183
+ // waitForCollectionCallback subscribers re-fire on every keyChanged by contract.
1184
+ if (!retryAttempt) {
1185
+ keyChanged(key, value);
1186
+ }
1181
1187
  }
1182
1188
  }
1183
1189
  // One keysChanged() per collection — fires each collection-level subscriber once and lets
1184
1190
  // keysChanged() internally decide which individual member subscribers need notification.
1185
- for (const [collectionKey, batch] of collectionBatches) {
1186
- keysChanged(collectionKey, batch.partial, batch.previous);
1191
+ // Skip on retry — already notified on attempt 0 (see same-reason comment above).
1192
+ if (!retryAttempt) {
1193
+ for (const [collectionKey, batch] of collectionBatches) {
1194
+ keysChanged(collectionKey, batch.partial, batch.previous);
1195
+ }
1187
1196
  }
1188
1197
  const keyValuePairsToStore = keyValuePairsToSet.filter((keyValuePair) => {
1189
1198
  const [key] = keyValuePair;
1190
1199
  // Filter out the RAM-only key value pairs, as they should not be saved to storage
1191
1200
  return !OnyxKeys_1.default.isRamOnlyKey(key);
1192
1201
  });
1202
+ const inFlightKeys = new Set(keyValuePairsToSet.map(([key]) => key));
1193
1203
  return storage_1.default.multiSet(keyValuePairsToStore)
1194
- .catch((error) => OnyxUtils.retryOperation(error, multiSetWithRetry, newData, retryAttempt))
1204
+ .catch((error) => OnyxUtils.retryOperation(error, multiSetWithRetry, newData, retryAttempt, inFlightKeys))
1195
1205
  .then(() => {
1196
1206
  OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.MULTI_SET, undefined, newData);
1197
1207
  });
@@ -1247,14 +1257,19 @@ function setCollectionWithRetry({ collectionKey, collection }, retryAttempt) {
1247
1257
  const previousCollection = OnyxUtils.getCachedCollection(collectionKey);
1248
1258
  for (const [key, value] of keyValuePairs)
1249
1259
  OnyxCache_1.default.set(key, value);
1250
- keysChanged(collectionKey, mutableCollection, previousCollection);
1260
+ // Skip subscriber notification on retry — already notified on attempt 0.
1261
+ // waitForCollectionCallback subscribers re-fire on every keysChanged by contract.
1262
+ if (!retryAttempt) {
1263
+ keysChanged(collectionKey, mutableCollection, previousCollection);
1264
+ }
1251
1265
  // RAM-only keys are not supposed to be saved to storage
1252
1266
  if (OnyxKeys_1.default.isRamOnlyKey(collectionKey)) {
1253
1267
  OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET_COLLECTION, undefined, mutableCollection);
1254
1268
  return;
1255
1269
  }
1270
+ const inFlightKeys = new Set(keyValuePairs.map(([key]) => key));
1256
1271
  return storage_1.default.multiSet(keyValuePairs)
1257
- .catch((error) => OnyxUtils.retryOperation(error, setCollectionWithRetry, { collectionKey, collection }, retryAttempt))
1272
+ .catch((error) => OnyxUtils.retryOperation(error, setCollectionWithRetry, { collectionKey, collection }, retryAttempt, inFlightKeys))
1258
1273
  .then(() => {
1259
1274
  OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET_COLLECTION, undefined, mutableCollection);
1260
1275
  });
@@ -1364,10 +1379,15 @@ function mergeCollectionWithPatches({ collectionKey, collection, mergeReplaceNul
1364
1379
  // write fails.
1365
1380
  const previousCollection = getCachedCollection(collectionKey, existingKeys);
1366
1381
  OnyxCache_1.default.merge(finalMergedCollection);
1367
- keysChanged(collectionKey, finalMergedCollection, previousCollection);
1382
+ // Skip subscriber notification on retry — already notified on attempt 0.
1383
+ // waitForCollectionCallback subscribers re-fire on every keysChanged by contract.
1384
+ if (!retryAttempt) {
1385
+ keysChanged(collectionKey, finalMergedCollection, previousCollection);
1386
+ }
1368
1387
  const promises = [];
1369
- // New keys will be added via multiSet while existing keys will be updated using multiMerge
1370
- // This is because setting a key that doesn't exist yet with multiMerge will throw errors
1388
+ // New keys go through multiSet and existing keys through multiMerge. multiMerge on a
1389
+ // missing key stores the value just like multiSet across all backends; splitting them lets
1390
+ // multiSet strip nested nulls (the merge layer keeps them to delete nested storage keys).
1371
1391
  // We can skip this step for RAM-only keys as they should never be saved to storage
1372
1392
  if (!OnyxKeys_1.default.isRamOnlyKey(collectionKey) && keyValuePairsForExistingCollection.length > 0) {
1373
1393
  promises.push(storage_1.default.multiMerge(keyValuePairsForExistingCollection));
@@ -1376,8 +1396,9 @@ function mergeCollectionWithPatches({ collectionKey, collection, mergeReplaceNul
1376
1396
  if (!OnyxKeys_1.default.isRamOnlyKey(collectionKey) && keyValuePairsForNewCollection.length > 0) {
1377
1397
  promises.push(storage_1.default.multiSet(keyValuePairsForNewCollection));
1378
1398
  }
1399
+ const inFlightKeys = new Set(Object.keys(finalMergedCollection));
1379
1400
  return Promise.all(promises)
1380
- .catch((error) => retryOperation(error, mergeCollectionWithPatches, { collectionKey, collection: resultCollection, mergeReplaceNullPatches, isProcessingCollectionUpdate }, retryAttempt))
1401
+ .catch((error) => retryOperation(error, mergeCollectionWithPatches, { collectionKey, collection: resultCollection, mergeReplaceNullPatches, isProcessingCollectionUpdate }, retryAttempt, inFlightKeys))
1381
1402
  .then(() => {
1382
1403
  sendActionToDevTools(METHOD.MERGE_COLLECTION, undefined, resultCollection);
1383
1404
  });
@@ -1427,13 +1448,18 @@ function partialSetCollection({ collectionKey, collection }, retryAttempt) {
1427
1448
  const keyValuePairs = prepareKeyValuePairsForStorage(mutableCollection, true, undefined, true);
1428
1449
  for (const [key, value] of keyValuePairs)
1429
1450
  OnyxCache_1.default.set(key, value);
1430
- keysChanged(collectionKey, mutableCollection, previousCollection);
1451
+ // Skip subscriber notification on retry — already notified on attempt 0.
1452
+ // waitForCollectionCallback subscribers re-fire on every keysChanged by contract.
1453
+ if (!retryAttempt) {
1454
+ keysChanged(collectionKey, mutableCollection, previousCollection);
1455
+ }
1431
1456
  if (OnyxKeys_1.default.isRamOnlyKey(collectionKey)) {
1432
1457
  sendActionToDevTools(METHOD.SET_COLLECTION, undefined, mutableCollection);
1433
1458
  return;
1434
1459
  }
1460
+ const inFlightKeys = new Set(keyValuePairs.map(([key]) => key));
1435
1461
  return storage_1.default.multiSet(keyValuePairs)
1436
- .catch((error) => retryOperation(error, partialSetCollection, { collectionKey, collection }, retryAttempt))
1462
+ .catch((error) => retryOperation(error, partialSetCollection, { collectionKey, collection }, retryAttempt, inFlightKeys))
1437
1463
  .then(() => {
1438
1464
  sendActionToDevTools(METHOD.SET_COLLECTION, undefined, mutableCollection);
1439
1465
  });
@@ -41,6 +41,17 @@ const utils_1 = __importDefault(require("../../../utils"));
41
41
  const createStore_1 = __importDefault(require("./createStore"));
42
42
  const DB_NAME = 'OnyxDB';
43
43
  const STORE_NAME = 'keyvaluepairs';
44
+ /**
45
+ * Awaits an IndexedDB write transaction. idb-keyval's promisifyRequest rejects with
46
+ * `transaction.error`, which is `null` for an abort not caused by its own request
47
+ * (connection close / versionchange / a sibling transaction aborting). Normalize that
48
+ * `null` into a tagged AbortError.
49
+ */
50
+ function promisifyWriteTransaction(transaction) {
51
+ return IDB.promisifyRequest(transaction).catch((error) => {
52
+ throw error !== null && error !== void 0 ? error : new DOMException('IDB write transaction aborted without an error', 'AbortError');
53
+ });
54
+ }
44
55
  const provider = {
45
56
  // We don't want to initialize the store while the JS bundle loads as idb-keyval will try to use global.indexedDB
46
57
  // which might not be available in certain environments that load the bundle (e.g. electron main process).
@@ -66,7 +77,13 @@ const provider = {
66
77
  if (value === null) {
67
78
  return provider.removeItem(key);
68
79
  }
69
- return IDB.set(key, value, provider.store);
80
+ // Drive the write through the manual store transaction so promisifyWriteTransaction can
81
+ // normalize a null abort error — idb-keyval's IDB.set() awaits the raw transaction and
82
+ // would propagate the unclassifiable "Error: null".
83
+ return provider.store('readwrite', (store) => {
84
+ store.put(value, key);
85
+ return promisifyWriteTransaction(store.transaction);
86
+ });
70
87
  },
71
88
  multiGet(keysParam) {
72
89
  if (!provider.store) {
@@ -95,7 +112,7 @@ const provider = {
95
112
  store.put(newValue, key);
96
113
  }
97
114
  }
98
- return IDB.promisifyRequest(store.transaction);
115
+ return promisifyWriteTransaction(store.transaction);
99
116
  });
100
117
  });
101
118
  },
@@ -116,7 +133,7 @@ const provider = {
116
133
  store.put(value, key);
117
134
  }
118
135
  }
119
- return IDB.promisifyRequest(store.transaction);
136
+ return promisifyWriteTransaction(store.transaction);
120
137
  });
121
138
  },
122
139
  clear() {
@@ -149,13 +166,21 @@ const provider = {
149
166
  if (!provider.store) {
150
167
  throw new Error('Store not initialized!');
151
168
  }
152
- return IDB.del(key, provider.store);
169
+ return provider.store('readwrite', (store) => {
170
+ store.delete(key);
171
+ return promisifyWriteTransaction(store.transaction);
172
+ });
153
173
  },
154
174
  removeItems(keysParam) {
155
175
  if (!provider.store) {
156
176
  throw new Error('Store not initialized!');
157
177
  }
158
- return IDB.delMany(keysParam, provider.store);
178
+ return provider.store('readwrite', (store) => {
179
+ for (const key of keysParam) {
180
+ store.delete(key);
181
+ }
182
+ return promisifyWriteTransaction(store.transaction);
183
+ });
159
184
  },
160
185
  getDatabaseSize() {
161
186
  if (!provider.store) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-onyx",
3
- "version": "3.0.84",
3
+ "version": "3.0.86",
4
4
  "author": "Expensify, Inc.",
5
5
  "homepage": "https://expensify.com",
6
6
  "description": "State management for React Native",