react-native-onyx 3.0.65 → 3.0.66

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.
@@ -17,8 +17,6 @@ declare class OnyxCache {
17
17
  private nullishStorageKeys;
18
18
  /** A map of cached values */
19
19
  private storageMap;
20
- /** Cache of complete collection data objects for O(1) retrieval */
21
- private collectionData;
22
20
  /**
23
21
  * Captured pending tasks for already running storage methods
24
22
  * Using a map yields better performance on operations such a delete
@@ -28,6 +26,10 @@ declare class OnyxCache {
28
26
  private evictionAllowList;
29
27
  /** List of keys that have been directly subscribed to or recently modified from least to most recent */
30
28
  private recentlyAccessedKeys;
29
+ /** Frozen collection snapshots for structural sharing */
30
+ private collectionSnapshots;
31
+ /** Collections whose snapshots need rebuilding (lazy — rebuilt on next read) */
32
+ private dirtyCollections;
31
33
  constructor();
32
34
  /** Get all the storage keys */
33
35
  getAllKeys(): Set<OnyxKey>;
@@ -87,7 +89,7 @@ declare class OnyxCache {
87
89
  * @param taskName - unique name for the task
88
90
  */
89
91
  captureTask(taskName: CacheTask, promise: Promise<OnyxValue<OnyxKey>>): Promise<OnyxValue<OnyxKey>>;
90
- /** Check if the value has changed */
92
+ /** Check if the value has changed. Uses reference equality as a fast path, falls back to deep equality. */
91
93
  hasValueChanged(key: OnyxKey, value: OnyxValue<OnyxKey>): boolean;
92
94
  /**
93
95
  * Sets the list of keys that are considered safe for eviction
@@ -127,7 +129,18 @@ declare class OnyxCache {
127
129
  */
128
130
  setCollectionKeys(collectionKeys: Set<OnyxKey>): void;
129
131
  /**
130
- * Get all data for a collection key
132
+ * Rebuilds the frozen collection snapshot from current storageMap references.
133
+ * Uses the indexed collection->members map for O(collectionMembers) instead of O(totalKeys).
134
+ * Returns the previous snapshot reference when all member references are identical,
135
+ * preventing unnecessary re-renders in useSyncExternalStore.
136
+ *
137
+ * @param collectionKey - The collection key to rebuild
138
+ */
139
+ private rebuildCollectionSnapshot;
140
+ /**
141
+ * Get all data for a collection key.
142
+ * Returns a frozen snapshot with structural sharing — safe to return by reference.
143
+ * Lazily rebuilds the snapshot if the collection was modified since the last read.
131
144
  */
132
145
  getCollectionData(collectionKey: OnyxKey): Record<OnyxKey, OnyxValue<OnyxKey>> | undefined;
133
146
  }
package/dist/OnyxCache.js CHANGED
@@ -8,6 +8,12 @@ const fast_equals_1 = require("fast-equals");
8
8
  const bindAll_1 = __importDefault(require("lodash/bindAll"));
9
9
  const utils_1 = __importDefault(require("./utils"));
10
10
  const OnyxKeys_1 = __importDefault(require("./OnyxKeys"));
11
+ /**
12
+ * Stable frozen empty object used as the canonical value for empty collections.
13
+ * Returning the same reference avoids unnecessary re-renders in useSyncExternalStore,
14
+ * which relies on === equality to detect changes.
15
+ */
16
+ const FROZEN_EMPTY_COLLECTION = Object.freeze({});
11
17
  // Task constants
12
18
  const TASK = {
13
19
  GET: 'get',
@@ -28,10 +34,11 @@ class OnyxCache {
28
34
  this.storageKeys = new Set();
29
35
  this.nullishStorageKeys = new Set();
30
36
  this.storageMap = {};
31
- this.collectionData = {};
32
37
  this.pendingPromises = new Map();
38
+ this.collectionSnapshots = new Map();
39
+ this.dirtyCollections = new Set();
33
40
  // bind all public methods to prevent problems with `this`
34
- (0, bindAll_1.default)(this, 'getAllKeys', 'get', 'hasCacheForKey', 'addKey', 'addNullishStorageKey', 'hasNullishStorageKey', 'clearNullishStorageKeys', 'set', 'drop', 'merge', 'hasPendingTask', 'getTaskPromise', 'captureTask', 'setAllKeys', 'setEvictionAllowList', 'isEvictableKey', 'removeLastAccessedKey', 'addLastAccessedKey', 'addEvictableKeysToRecentlyAccessedList', 'getKeyForEviction', 'setCollectionKeys', 'getCollectionData', 'hasValueChanged');
41
+ (0, bindAll_1.default)(this, 'getAllKeys', 'get', 'hasCacheForKey', 'addKey', 'addNullishStorageKey', 'hasNullishStorageKey', 'clearNullishStorageKeys', 'set', 'drop', 'merge', 'hasPendingTask', 'getTaskPromise', 'captureTask', 'setAllKeys', 'setEvictionAllowList', 'isEvictableKey', 'removeLastAccessedKey', 'addLastAccessedKey', 'addEvictableKeysToRecentlyAccessedList', 'getKeyForEviction', 'setCollectionKeys', 'hasValueChanged', 'getCollectionData');
35
42
  }
36
43
  /** Get all the storage keys */
37
44
  getAllKeys() {
@@ -91,35 +98,30 @@ class OnyxCache {
91
98
  // since it will either be set to a non nullish value or removed from the cache completely.
92
99
  this.nullishStorageKeys.delete(key);
93
100
  const collectionKey = OnyxKeys_1.default.getCollectionKey(key);
101
+ const oldValue = this.storageMap[key];
94
102
  if (value === null || value === undefined) {
95
103
  delete this.storageMap[key];
96
- // Remove from collection data cache if it's a collection member
97
- if (collectionKey && this.collectionData[collectionKey]) {
98
- delete this.collectionData[collectionKey][key];
104
+ if (collectionKey && oldValue !== undefined) {
105
+ this.dirtyCollections.add(collectionKey);
99
106
  }
100
107
  return undefined;
101
108
  }
102
109
  this.storageMap[key] = value;
103
- // Update collection data cache if this is a collection member
104
- if (collectionKey) {
105
- if (!this.collectionData[collectionKey]) {
106
- this.collectionData[collectionKey] = {};
107
- }
108
- this.collectionData[collectionKey][key] = value;
110
+ if (collectionKey && oldValue !== value) {
111
+ this.dirtyCollections.add(collectionKey);
109
112
  }
110
113
  return value;
111
114
  }
112
115
  /** Forget the cached value for the given key */
113
116
  drop(key) {
114
117
  delete this.storageMap[key];
115
- // Remove from collection data cache if this is a collection member
116
118
  const collectionKey = OnyxKeys_1.default.getCollectionKey(key);
117
- if (collectionKey && this.collectionData[collectionKey]) {
118
- delete this.collectionData[collectionKey][key];
119
+ if (collectionKey) {
120
+ this.dirtyCollections.add(collectionKey);
119
121
  }
120
- // If this is a collection key, clear its data
122
+ // If this is a collection key, clear its snapshot
121
123
  if (OnyxKeys_1.default.isCollectionKey(key)) {
122
- delete this.collectionData[key];
124
+ this.collectionSnapshots.delete(key);
123
125
  }
124
126
  this.storageKeys.delete(key);
125
127
  OnyxKeys_1.default.deregisterMemberKey(key);
@@ -132,31 +134,45 @@ class OnyxCache {
132
134
  if (typeof data !== 'object' || Array.isArray(data)) {
133
135
  throw new Error('data passed to cache.merge() must be an Object of onyx key/value pairs');
134
136
  }
135
- this.storageMap = Object.assign({}, utils_1.default.fastMerge(this.storageMap, data, {
136
- shouldRemoveNestedNulls: true,
137
- objectRemovalMode: 'replace',
138
- }).result);
137
+ const affectedCollections = new Set();
139
138
  for (const [key, value] of Object.entries(data)) {
140
139
  this.addKey(key);
141
140
  const collectionKey = OnyxKeys_1.default.getCollectionKey(key);
142
- if (value === null || value === undefined) {
141
+ if (value === undefined) {
142
+ this.addNullishStorageKey(key);
143
+ // undefined means "no change" — skip storageMap modification
144
+ continue;
145
+ }
146
+ if (value === null) {
143
147
  this.addNullishStorageKey(key);
144
- // Remove from collection data cache if it's a collection member
145
- if (collectionKey && this.collectionData[collectionKey]) {
146
- delete this.collectionData[collectionKey][key];
148
+ delete this.storageMap[key];
149
+ if (collectionKey) {
150
+ affectedCollections.add(collectionKey);
147
151
  }
148
152
  }
149
153
  else {
150
154
  this.nullishStorageKeys.delete(key);
151
- // Update collection data cache if this is a collection member
155
+ // Per-key merge instead of spreading the entire storageMap
156
+ const existing = this.storageMap[key];
157
+ const merged = utils_1.default.fastMerge(existing, value, {
158
+ shouldRemoveNestedNulls: true,
159
+ objectRemovalMode: 'replace',
160
+ }).result;
161
+ // fastMerge is reference-stable: returns the original target when
162
+ // nothing changed, so a simple === check detects no-ops.
163
+ if (merged === existing) {
164
+ continue;
165
+ }
166
+ this.storageMap[key] = merged;
152
167
  if (collectionKey) {
153
- if (!this.collectionData[collectionKey]) {
154
- this.collectionData[collectionKey] = {};
155
- }
156
- this.collectionData[collectionKey][key] = this.storageMap[key];
168
+ affectedCollections.add(collectionKey);
157
169
  }
158
170
  }
159
171
  }
172
+ // Mark affected collections as dirty — snapshots will be lazily rebuilt on next read
173
+ for (const collectionKey of affectedCollections) {
174
+ this.dirtyCollections.add(collectionKey);
175
+ }
160
176
  }
161
177
  /**
162
178
  * Check whether the given task is already running
@@ -186,9 +202,12 @@ class OnyxCache {
186
202
  this.pendingPromises.set(taskName, returnPromise);
187
203
  return returnPromise;
188
204
  }
189
- /** Check if the value has changed */
205
+ /** Check if the value has changed. Uses reference equality as a fast path, falls back to deep equality. */
190
206
  hasValueChanged(key, value) {
191
- const currentValue = this.get(key);
207
+ const currentValue = this.storageMap[key];
208
+ if (currentValue === value) {
209
+ return false;
210
+ }
192
211
  return !(0, fast_equals_1.deepEqual)(currentValue, value);
193
212
  }
194
213
  /**
@@ -257,24 +276,91 @@ class OnyxCache {
257
276
  */
258
277
  setCollectionKeys(collectionKeys) {
259
278
  OnyxKeys_1.default.setCollectionKeys(collectionKeys);
260
- // Initialize collection data for existing collection keys
279
+ // Initialize frozen snapshots for collection keys
261
280
  for (const collectionKey of collectionKeys) {
262
- if (this.collectionData[collectionKey]) {
281
+ if (!this.collectionSnapshots.has(collectionKey)) {
282
+ this.collectionSnapshots.set(collectionKey, Object.freeze({}));
283
+ }
284
+ }
285
+ }
286
+ /**
287
+ * Rebuilds the frozen collection snapshot from current storageMap references.
288
+ * Uses the indexed collection->members map for O(collectionMembers) instead of O(totalKeys).
289
+ * Returns the previous snapshot reference when all member references are identical,
290
+ * preventing unnecessary re-renders in useSyncExternalStore.
291
+ *
292
+ * @param collectionKey - The collection key to rebuild
293
+ */
294
+ rebuildCollectionSnapshot(collectionKey) {
295
+ const previousSnapshot = this.collectionSnapshots.get(collectionKey);
296
+ const members = {};
297
+ let hasMemberChanges = false;
298
+ // Use the indexed forward lookup for O(collectionMembers) iteration.
299
+ // Falls back to scanning all storageKeys if the index isn't populated yet.
300
+ const memberKeys = OnyxKeys_1.default.getMembersOfCollection(collectionKey);
301
+ const keysToScan = memberKeys !== null && memberKeys !== void 0 ? memberKeys : this.storageKeys;
302
+ const needsPrefixCheck = !memberKeys;
303
+ for (const key of keysToScan) {
304
+ // When using the fallback path (scanning all storageKeys instead of the indexed
305
+ // forward lookup), skip keys that don't belong to this collection.
306
+ if (needsPrefixCheck && OnyxKeys_1.default.getCollectionKey(key) !== collectionKey) {
263
307
  continue;
264
308
  }
265
- this.collectionData[collectionKey] = {};
309
+ const val = this.storageMap[key];
310
+ // Skip null/undefined values — they represent deleted or unset keys
311
+ // and should not be included in the frozen collection snapshot.
312
+ if (val !== undefined && val !== null) {
313
+ members[key] = val;
314
+ // Check if this member's reference changed from the old snapshot
315
+ if (!hasMemberChanges && (!previousSnapshot || previousSnapshot[key] !== val)) {
316
+ hasMemberChanges = true;
317
+ }
318
+ }
266
319
  }
320
+ // Check if any members were removed from the previous snapshot.
321
+ // We can't rely on count comparison alone — if one key is removed and another added,
322
+ // the counts match but the snapshot content is different.
323
+ if (!hasMemberChanges && previousSnapshot) {
324
+ // eslint-disable-next-line no-restricted-syntax
325
+ for (const key in previousSnapshot) {
326
+ if (!(key in members)) {
327
+ hasMemberChanges = true;
328
+ break;
329
+ }
330
+ }
331
+ }
332
+ // If nothing actually changed, reuse the old snapshot reference.
333
+ // This is critical: useSyncExternalStore uses === to detect changes,
334
+ // so returning the same reference prevents unnecessary re-renders.
335
+ if (!hasMemberChanges && previousSnapshot) {
336
+ return;
337
+ }
338
+ Object.freeze(members);
339
+ this.collectionSnapshots.set(collectionKey, members);
267
340
  }
268
341
  /**
269
- * Get all data for a collection key
342
+ * Get all data for a collection key.
343
+ * Returns a frozen snapshot with structural sharing — safe to return by reference.
344
+ * Lazily rebuilds the snapshot if the collection was modified since the last read.
270
345
  */
271
346
  getCollectionData(collectionKey) {
272
- const cachedCollection = this.collectionData[collectionKey];
273
- if (!cachedCollection || Object.keys(cachedCollection).length === 0) {
347
+ if (this.dirtyCollections.has(collectionKey)) {
348
+ this.rebuildCollectionSnapshot(collectionKey);
349
+ this.dirtyCollections.delete(collectionKey);
350
+ }
351
+ const snapshot = this.collectionSnapshots.get(collectionKey);
352
+ if (utils_1.default.isEmptyObject(snapshot)) {
353
+ // We check storageKeys.size (not collection-specific keys) to distinguish
354
+ // "init complete, this collection is genuinely empty" from "init not done yet."
355
+ // During init, setAllKeys loads ALL keys at once — so if any key exists,
356
+ // the full storage picture is loaded and an empty collection is truly empty.
357
+ // Returning undefined before init prevents subscribers from seeing a false empty state.
358
+ if (this.storageKeys.size > 0) {
359
+ return FROZEN_EMPTY_COLLECTION;
360
+ }
274
361
  return undefined;
275
362
  }
276
- // Return a shallow copy to ensure React detects changes when items are added/removed
277
- return Object.assign({}, cachedCollection);
363
+ return snapshot;
278
364
  }
279
365
  }
280
366
  const instance = new OnyxCache();
package/dist/utils.js CHANGED
@@ -109,7 +109,17 @@ function mergeObject(target, source, options, metadata, basePath) {
109
109
  }
110
110
  /** Checks whether the given object is an object and not null/undefined. */
111
111
  function isEmptyObject(obj) {
112
- return typeof obj === 'object' && Object.keys(obj || {}).length === 0;
112
+ if (typeof obj !== 'object') {
113
+ return false;
114
+ }
115
+ // Use for-in loop to avoid an unnecessary array allocation from Object.keys()
116
+ // eslint-disable-next-line no-restricted-syntax
117
+ for (const key in obj) {
118
+ if (Object.hasOwn(obj, key)) {
119
+ return false;
120
+ }
121
+ }
122
+ return true;
113
123
  }
114
124
  /**
115
125
  * Checks whether the given value can be merged. It has to be an object, but not an array, RegExp or Date.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-onyx",
3
- "version": "3.0.65",
3
+ "version": "3.0.66",
4
4
  "author": "Expensify, Inc.",
5
5
  "homepage": "https://expensify.com",
6
6
  "description": "State management for React Native",