Skip to content

Commit 2283ce8

Browse files
committed
Merge branch 'main' of github.com:callstack-internal/react-native-onyx into feat/87780-improve-logging
2 parents c1ecb3f + 879ba25 commit 2283ce8

8 files changed

Lines changed: 374 additions & 53 deletions

File tree

lib/OnyxCache.ts

Lines changed: 152 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,19 @@ import {deepEqual} from 'fast-equals';
22
import bindAll from 'lodash/bindAll';
33
import type {ValueOf} from 'type-fest';
44
import utils from './utils';
5-
import type {OnyxKey, OnyxValue} from './types';
5+
import type {CollectionKeyBase, KeyValueMapping, NonUndefined, OnyxCollection, OnyxKey, OnyxValue} from './types';
66
import OnyxKeys from './OnyxKeys';
77

8+
/** Frozen object containing all collection members — safe to return by reference */
9+
type CollectionSnapshot = Readonly<NonUndefined<OnyxCollection<KeyValueMapping[OnyxKey]>>>;
10+
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: Readonly<NonUndefined<OnyxCollection<KeyValueMapping[OnyxKey]>>> = Object.freeze({});
17+
818
// Task constants
919
const TASK = {
1020
GET: 'get',
@@ -28,9 +38,6 @@ class OnyxCache {
2838
/** A map of cached values */
2939
private storageMap: Record<OnyxKey, OnyxValue<OnyxKey>>;
3040

31-
/** Cache of complete collection data objects for O(1) retrieval */
32-
private collectionData: Record<OnyxKey, Record<OnyxKey, OnyxValue<OnyxKey>>>;
33-
3441
/**
3542
* Captured pending tasks for already running storage methods
3643
* Using a map yields better performance on operations such a delete
@@ -43,12 +50,19 @@ class OnyxCache {
4350
/** List of keys that have been directly subscribed to or recently modified from least to most recent */
4451
private recentlyAccessedKeys = new Set<OnyxKey>();
4552

53+
/** Frozen collection snapshots for structural sharing */
54+
private collectionSnapshots: Map<OnyxKey, CollectionSnapshot>;
55+
56+
/** Collections whose snapshots need rebuilding (lazy — rebuilt on next read) */
57+
private dirtyCollections: Set<CollectionKeyBase>;
58+
4659
constructor() {
4760
this.storageKeys = new Set();
4861
this.nullishStorageKeys = new Set();
4962
this.storageMap = {};
50-
this.collectionData = {};
5163
this.pendingPromises = new Map();
64+
this.collectionSnapshots = new Map();
65+
this.dirtyCollections = new Set();
5266

5367
// bind all public methods to prevent problems with `this`
5468
bindAll(
@@ -74,8 +88,8 @@ class OnyxCache {
7488
'addEvictableKeysToRecentlyAccessedList',
7589
'getKeyForEviction',
7690
'setCollectionKeys',
77-
'getCollectionData',
7891
'hasValueChanged',
92+
'getCollectionData',
7993
);
8094
}
8195

@@ -147,24 +161,21 @@ class OnyxCache {
147161
this.nullishStorageKeys.delete(key);
148162

149163
const collectionKey = OnyxKeys.getCollectionKey(key);
164+
const oldValue = this.storageMap[key];
165+
150166
if (value === null || value === undefined) {
151167
delete this.storageMap[key];
152168

153-
// Remove from collection data cache if it's a collection member
154-
if (collectionKey && this.collectionData[collectionKey]) {
155-
delete this.collectionData[collectionKey][key];
169+
if (collectionKey && oldValue !== undefined) {
170+
this.dirtyCollections.add(collectionKey);
156171
}
157172
return undefined;
158173
}
159174

160175
this.storageMap[key] = value;
161176

162-
// Update collection data cache if this is a collection member
163-
if (collectionKey) {
164-
if (!this.collectionData[collectionKey]) {
165-
this.collectionData[collectionKey] = {};
166-
}
167-
this.collectionData[collectionKey][key] = value;
177+
if (collectionKey && oldValue !== value) {
178+
this.dirtyCollections.add(collectionKey);
168179
}
169180

170181
return value;
@@ -174,15 +185,14 @@ class OnyxCache {
174185
drop(key: OnyxKey): void {
175186
delete this.storageMap[key];
176187

177-
// Remove from collection data cache if this is a collection member
178188
const collectionKey = OnyxKeys.getCollectionKey(key);
179-
if (collectionKey && this.collectionData[collectionKey]) {
180-
delete this.collectionData[collectionKey][key];
189+
if (collectionKey) {
190+
this.dirtyCollections.add(collectionKey);
181191
}
182192

183-
// If this is a collection key, clear its data
193+
// If this is a collection key, clear its snapshot
184194
if (OnyxKeys.isCollectionKey(key)) {
185-
delete this.collectionData[key];
195+
this.collectionSnapshots.delete(key);
186196
}
187197

188198
this.storageKeys.delete(key);
@@ -198,37 +208,54 @@ class OnyxCache {
198208
throw new Error('data passed to cache.merge() must be an Object of onyx key/value pairs');
199209
}
200210

201-
this.storageMap = {
202-
...utils.fastMerge(this.storageMap, data, {
203-
shouldRemoveNestedNulls: true,
204-
objectRemovalMode: 'replace',
205-
}).result,
206-
};
211+
const affectedCollections = new Set<OnyxKey>();
207212

208213
for (const [key, value] of Object.entries(data)) {
209214
this.addKey(key);
210215

211216
const collectionKey = OnyxKeys.getCollectionKey(key);
212217

213-
if (value === null || value === undefined) {
218+
if (value === undefined) {
219+
this.addNullishStorageKey(key);
220+
// undefined means "no change" — skip storageMap modification
221+
continue;
222+
}
223+
224+
if (value === null) {
214225
this.addNullishStorageKey(key);
226+
delete this.storageMap[key];
215227

216-
// Remove from collection data cache if it's a collection member
217-
if (collectionKey && this.collectionData[collectionKey]) {
218-
delete this.collectionData[collectionKey][key];
228+
if (collectionKey) {
229+
affectedCollections.add(collectionKey);
219230
}
220231
} else {
221232
this.nullishStorageKeys.delete(key);
222233

223-
// Update collection data cache if this is a collection member
234+
// Per-key merge instead of spreading the entire storageMap
235+
const existing = this.storageMap[key];
236+
const merged = utils.fastMerge(existing, value, {
237+
shouldRemoveNestedNulls: true,
238+
objectRemovalMode: 'replace',
239+
}).result;
240+
241+
// fastMerge is reference-stable: returns the original target when
242+
// nothing changed, so a simple === check detects no-ops.
243+
if (merged === existing) {
244+
continue;
245+
}
246+
247+
this.storageMap[key] = merged;
248+
224249
if (collectionKey) {
225-
if (!this.collectionData[collectionKey]) {
226-
this.collectionData[collectionKey] = {};
227-
}
228-
this.collectionData[collectionKey][key] = this.storageMap[key];
250+
affectedCollections.add(collectionKey);
229251
}
230252
}
231253
}
254+
255+
// Mark affected collections as dirty — snapshots will be lazily rebuilt on next read
256+
for (const collectionKey of affectedCollections) {
257+
this.dirtyCollections.add(collectionKey);
258+
}
232259
}
233260

234261
/**
@@ -264,9 +291,12 @@ class OnyxCache {
264291
return returnPromise;
265292
}
266293

267-
/** Check if the value has changed */
294+
/** Check if the value has changed. Uses reference equality as a fast path, falls back to deep equality. */
268295
hasValueChanged(key: OnyxKey, value: OnyxValue<OnyxKey>): boolean {
269-
const currentValue = this.get(key);
296+
const currentValue = this.storageMap[key];
297+
if (currentValue === value) {
298+
return false;
299+
}
270300
return !deepEqual(currentValue, value);
271301
}
272302

@@ -345,26 +375,103 @@ class OnyxCache {
345375
setCollectionKeys(collectionKeys: Set<OnyxKey>): void {
346376
OnyxKeys.setCollectionKeys(collectionKeys);
347377

348-
// Initialize collection data for existing collection keys
378+
// Initialize frozen snapshots for collection keys
349379
for (const collectionKey of collectionKeys) {
350-
if (this.collectionData[collectionKey]) {
380+
if (!this.collectionSnapshots.has(collectionKey)) {
381+
this.collectionSnapshots.set(collectionKey, Object.freeze({}));
382+
}
383+
}
384+
}
385+
386+
/**
387+
* Rebuilds the frozen collection snapshot from current storageMap references.
388+
* Uses the indexed collection->members map for O(collectionMembers) instead of O(totalKeys).
389+
* Returns the previous snapshot reference when all member references are identical,
390+
* preventing unnecessary re-renders in useSyncExternalStore.
391+
*
392+
* @param collectionKey - The collection key to rebuild
393+
*/
394+
private rebuildCollectionSnapshot(collectionKey: OnyxKey): void {
395+
const previousSnapshot = this.collectionSnapshots.get(collectionKey);
396+
397+
const members: NonUndefined<OnyxCollection<KeyValueMapping[OnyxKey]>> = {};
398+
let hasMemberChanges = false;
399+
400+
// Use the indexed forward lookup for O(collectionMembers) iteration.
401+
// Falls back to scanning all storageKeys if the index isn't populated yet.
402+
const memberKeys = OnyxKeys.getMembersOfCollection(collectionKey);
403+
const keysToScan = memberKeys ?? this.storageKeys;
404+
const needsPrefixCheck = !memberKeys;
405+
406+
for (const key of keysToScan) {
407+
// When using the fallback path (scanning all storageKeys instead of the indexed
408+
// forward lookup), skip keys that don't belong to this collection.
409+
if (needsPrefixCheck && OnyxKeys.getCollectionKey(key) !== collectionKey) {
351410
continue;
352411
}
353-
this.collectionData[collectionKey] = {};
412+
const val = this.storageMap[key];
413+
// Skip null/undefined values — they represent deleted or unset keys
414+
// and should not be included in the frozen collection snapshot.
415+
if (val !== undefined && val !== null) {
416+
members[key] = val;
417+
418+
// Check if this member's reference changed from the old snapshot
419+
if (!hasMemberChanges && (!previousSnapshot || previousSnapshot[key] !== val)) {
420+
hasMemberChanges = true;
421+
}
422+
}
354423
}
424+
425+
// Check if any members were removed from the previous snapshot.
426+
// We can't rely on count comparison alone — if one key is removed and another added,
427+
// the counts match but the snapshot content is different.
428+
if (!hasMemberChanges && previousSnapshot) {
429+
// eslint-disable-next-line no-restricted-syntax
430+
for (const key in previousSnapshot) {
431+
if (!(key in members)) {
432+
hasMemberChanges = true;
433+
break;
434+
}
435+
}
436+
}
437+
438+
// If nothing actually changed, reuse the old snapshot reference.
439+
// This is critical: useSyncExternalStore uses === to detect changes,
440+
// so returning the same reference prevents unnecessary re-renders.
441+
if (!hasMemberChanges && previousSnapshot) {
442+
return;
443+
}
444+
445+
Object.freeze(members);
446+
447+
this.collectionSnapshots.set(collectionKey, members);
355448
}
356449

357450
/**
358-
* Get all data for a collection key
451+
* Get all data for a collection key.
452+
* Returns a frozen snapshot with structural sharing — safe to return by reference.
453+
* Lazily rebuilds the snapshot if the collection was modified since the last read.
359454
*/
360455
getCollectionData(collectionKey: OnyxKey): Record<OnyxKey, OnyxValue<OnyxKey>> | undefined {
361-
const cachedCollection = this.collectionData[collectionKey];
362-
if (!cachedCollection || Object.keys(cachedCollection).length === 0) {
456+
if (this.dirtyCollections.has(collectionKey)) {
457+
this.rebuildCollectionSnapshot(collectionKey);
458+
this.dirtyCollections.delete(collectionKey);
459+
}
460+
461+
const snapshot = this.collectionSnapshots.get(collectionKey);
462+
if (utils.isEmptyObject(snapshot)) {
463+
// We check storageKeys.size (not collection-specific keys) to distinguish
464+
// "init complete, this collection is genuinely empty" from "init not done yet."
465+
// During init, setAllKeys loads ALL keys at once — so if any key exists,
466+
// the full storage picture is loaded and an empty collection is truly empty.
467+
// Returning undefined before init prevents subscribers from seeing a false empty state.
468+
if (this.storageKeys.size > 0) {
469+
return FROZEN_EMPTY_COLLECTION;
470+
}
363471
return undefined;
364472
}
365473

366-
// Return a shallow copy to ensure React detects changes when items are added/removed
367-
return {...cachedCollection};
474+
return snapshot;
368475
}
369476
}
370477

lib/OnyxUtils.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,6 @@ const IDB_STORAGE_ERRORS = [
5757
// SQLite errors that indicate storage capacity issues where eviction can help
5858
const SQLITE_STORAGE_ERRORS = [
5959
'database or disk is full', // Device storage is full
60-
'disk I/O error', // File system I/O failure, often due to insufficient space or corrupted storage
61-
'out of memory', // Insufficient RAM or storage space to complete the operation
6260
] as const;
6361

6462
const STORAGE_ERRORS = [...IDB_STORAGE_ERRORS, ...SQLITE_STORAGE_ERRORS];

lib/utils.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,19 @@ function mergeObject<TObject extends Record<string, unknown>>(
172172

173173
/** Checks whether the given object is an object and not null/undefined. */
174174
function isEmptyObject<T>(obj: T | EmptyValue): obj is EmptyValue {
175-
return typeof obj === 'object' && Object.keys(obj || {}).length === 0;
175+
if (typeof obj !== 'object') {
176+
return false;
177+
}
178+
179+
// Use for-in loop to avoid an unnecessary array allocation from Object.keys()
180+
// eslint-disable-next-line no-restricted-syntax
181+
for (const key in obj) {
182+
if (Object.hasOwn(obj, key)) {
183+
return false;
184+
}
185+
}
186+
187+
return true;
176188
}
177189

178190
/**

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "react-native-onyx",
3-
"version": "3.0.64",
3+
"version": "3.0.66",
44
"author": "Expensify, Inc.",
55
"homepage": "https://expensify.com",
66
"description": "State management for React Native",

0 commit comments

Comments
 (0)