Skip to content

Commit b70a835

Browse files
authored
Merge pull request #812 from callstack-internal/perf/getcollectiondata-empty-object
2 parents 856bcb7 + 8bdc36a commit b70a835

2 files changed

Lines changed: 44 additions & 10 deletions

File tree

lib/OnyxCache.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@ class OnyxCache {
385385
// Initialize frozen snapshots for collection keys
386386
for (const collectionKey of collectionKeys) {
387387
if (!this.collectionSnapshots.has(collectionKey)) {
388-
this.collectionSnapshots.set(collectionKey, Object.freeze({}));
388+
this.collectionSnapshots.set(collectionKey, FROZEN_EMPTY_COLLECTION);
389389
}
390390
}
391391
}
@@ -403,6 +403,7 @@ class OnyxCache {
403403

404404
const members: NonUndefined<OnyxCollection<KeyValueMapping[OnyxKey]>> = {};
405405
let hasMemberChanges = false;
406+
let hasMembers = false;
406407

407408
// Use the indexed forward lookup for O(collectionMembers) iteration.
408409
// Falls back to scanning all storageKeys if the index isn't populated yet.
@@ -421,6 +422,7 @@ class OnyxCache {
421422
// and should not be included in the frozen collection snapshot.
422423
if (val !== undefined && val !== null) {
423424
members[key] = val;
425+
hasMembers = true;
424426

425427
// Check if this member's reference changed from the old snapshot
426428
if (!hasMemberChanges && (!previousSnapshot || previousSnapshot[key] !== val)) {
@@ -449,6 +451,14 @@ class OnyxCache {
449451
return;
450452
}
451453

454+
// When the collection has no members, reuse one shared empty object for every empty
455+
// collection. That way reads can tell a collection is empty with a quick `===` check
456+
// instead of looping over its keys every time.
457+
if (!hasMembers) {
458+
this.collectionSnapshots.set(collectionKey, FROZEN_EMPTY_COLLECTION);
459+
return;
460+
}
461+
452462
Object.freeze(members);
453463

454464
this.collectionSnapshots.set(collectionKey, members);
@@ -466,18 +476,21 @@ class OnyxCache {
466476
}
467477

468478
const snapshot = this.collectionSnapshots.get(collectionKey);
469-
if (utils.isEmptyObject(snapshot)) {
470-
// We check storageKeys.size (not collection-specific keys) to distinguish
471-
// "init complete, this collection is genuinely empty" from "init not done yet."
472-
// During init, setAllKeys loads ALL keys at once — so if any key exists,
473-
// the full storage picture is loaded and an empty collection is truly empty.
474-
// Returning undefined before init prevents subscribers from seeing a false empty state.
475-
if (this.storageKeys.size > 0) {
476-
return FROZEN_EMPTY_COLLECTION;
477-
}
479+
480+
// We never stored anything for this collection key.
481+
if (snapshot === undefined) {
478482
return undefined;
479483
}
480484

485+
// The collection is empty (it holds our shared empty object). But "empty" is ambiguous
486+
// during startup: we can't tell an actually-empty collection apart from one whose data
487+
// hasn't loaded yet. Once any key exists, we know setAllKeys has run and loaded everything,
488+
// so an empty collection really is empty. Before that, return undefined so subscribers
489+
// don't briefly see a collection as empty when it just hasn't loaded.
490+
if (snapshot === FROZEN_EMPTY_COLLECTION) {
491+
return this.storageKeys.size > 0 ? FROZEN_EMPTY_COLLECTION : undefined;
492+
}
493+
481494
return snapshot;
482495
}
483496
}

tests/unit/onyxCacheTest.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,27 @@ describe('Onyx', () => {
625625
});
626626
});
627627

628+
it('should return the stable empty reference after all members are removed', async () => {
629+
await initOnyx();
630+
// Unrelated key keeps storageKeys non-empty so an empty collection is treated as "loaded and empty"
631+
await Onyx.set(ONYX_KEYS.TEST_KEY, 'value');
632+
await Onyx.set(`${ONYX_KEYS.COLLECTION.MOCK_COLLECTION}1`, {id: 1});
633+
634+
const populated = cache.getCollectionData(ONYX_KEYS.COLLECTION.MOCK_COLLECTION);
635+
expect(Object.keys(populated!)).toHaveLength(1);
636+
637+
// Remove the last member — the collection becomes empty
638+
await Onyx.set(`${ONYX_KEYS.COLLECTION.MOCK_COLLECTION}1`, null);
639+
640+
const first = cache.getCollectionData(ONYX_KEYS.COLLECTION.MOCK_COLLECTION);
641+
const second = cache.getCollectionData(ONYX_KEYS.COLLECTION.MOCK_COLLECTION);
642+
643+
expect(first).toBeDefined();
644+
expect(Object.keys(first!)).toHaveLength(0);
645+
// Reads must return the same empty reference so useSyncExternalStore doesn't re-render
646+
expect(first).toBe(second);
647+
});
648+
628649
it('should preserve unchanged member references when a sibling is updated', async () => {
629650
await initOnyx();
630651
const member1Value = {id: 1, name: 'unchanged'};

0 commit comments

Comments
 (0)