Skip to content

Commit 5f51997

Browse files
committed
Merge remote-tracking branch 'origin/main' into feature/onyxutils-get-synchronous
2 parents 84ea7e8 + 8ae75ea commit 5f51997

10 files changed

Lines changed: 569 additions & 72 deletions

File tree

API-INTERNAL.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ that this internal function allows passing an additional <code>mergeReplaceNullP
145145
Any existing collection members not included in the new data will not be removed.
146146
Retries on failure.</p>
147147
</dd>
148+
<dt><a href="#getCallbackToStateMapping">getCallbackToStateMapping()</a></dt>
149+
<dd><p>Getter - returns the callback to state mapping, useful in test environments.</p>
150+
</dd>
148151
<dt><a href="#clearOnyxUtilsInternals">clearOnyxUtilsInternals()</a></dt>
149152
<dd><p>Clear internal variables used in this file, useful in test environments.</p>
150153
</dd>
@@ -519,6 +522,12 @@ Retries on failure.
519522
| params.collection | Object collection keyed by individual collection member keys and values |
520523
| retryAttempt | retry attempt |
521524

525+
<a name="getCallbackToStateMapping"></a>
526+
527+
## getCallbackToStateMapping()
528+
Getter - returns the callback to state mapping, useful in test environments.
529+
530+
**Kind**: global function
522531
<a name="clearOnyxUtilsInternals"></a>
523532

524533
## clearOnyxUtilsInternals()

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

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

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

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

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

lib/OnyxUtils.ts

Lines changed: 26 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];
@@ -903,6 +901,24 @@ function subscribeToKey<TKey extends OnyxKey>(connectOptions: ConnectOptions<TKe
903901
callbackToStateMapping[subscriptionID] = mapping as CallbackToStateMapping<OnyxKey>;
904902
callbackToStateMapping[subscriptionID].subscriptionID = subscriptionID;
905903

904+
// If the subscriber is attempting to connect to a collection member whose ID is skippable (e.g. "undefined", "null", etc.)
905+
// we suppress wiring the subscription fully to avoid unnecessary callback emissions such as for "report_undefined".
906+
// We still return a valid subscriptionID so callers can disconnect safely.
907+
try {
908+
const skippableIDs = getSkippableCollectionMemberIDs();
909+
if (skippableIDs.size) {
910+
const [, collectionMemberID] = OnyxKeys.splitCollectionMemberKey(mapping.key);
911+
if (skippableIDs.has(collectionMemberID)) {
912+
// Clean up the provisional mapping to avoid retaining unused subscribers.
913+
cache.addNullishStorageKey(mapping.key);
914+
delete callbackToStateMapping[subscriptionID];
915+
return subscriptionID;
916+
}
917+
}
918+
} catch (e) {
919+
// Not a collection member key, proceed as usual.
920+
}
921+
906922
// When keyChanged is called, a key is passed and the method looks through all the Subscribers in callbackToStateMapping for the matching key to get the subscriptionID
907923
// to avoid having to loop through all the Subscribers all the time (even when just one connection belongs to one key),
908924
// We create a mapping from key to lists of subscriptionIDs to access the specific list of subscriptionIDs.
@@ -1496,6 +1512,13 @@ function logKeyRemoved(onyxMethod: Extract<OnyxMethod, 'set' | 'merge'>, key: On
14961512
Logger.logInfo(`${onyxMethod} called for key: ${key} => null passed, so key was removed`);
14971513
}
14981514

1515+
/**
1516+
* Getter - returns the callback to state mapping, useful in test environments.
1517+
*/
1518+
function getCallbackToStateMapping(): Record<number, CallbackToStateMapping<OnyxKey>> {
1519+
return callbackToStateMapping;
1520+
}
1521+
14991522
/**
15001523
* Clear internal variables used in this file, useful in test environments.
15011524
*/
@@ -1555,6 +1578,7 @@ const OnyxUtils = {
15551578
setWithRetry,
15561579
multiSetWithRetry,
15571580
setCollectionWithRetry,
1581+
getCallbackToStateMapping,
15581582
};
15591583

15601584
export type {OnyxMethod};

0 commit comments

Comments
 (0)