From 90c8957ba6418ecba76d1031f87c62980b000c9e Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Wed, 22 Jul 2026 14:05:29 +0200 Subject: [PATCH 1/2] perf: detect empty collections via shared empty object in getCollectionData --- lib/OnyxCache.ts | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/lib/OnyxCache.ts b/lib/OnyxCache.ts index 23fae2130..e105a0ab6 100644 --- a/lib/OnyxCache.ts +++ b/lib/OnyxCache.ts @@ -385,7 +385,7 @@ class OnyxCache { // Initialize frozen snapshots for collection keys for (const collectionKey of collectionKeys) { if (!this.collectionSnapshots.has(collectionKey)) { - this.collectionSnapshots.set(collectionKey, Object.freeze({})); + this.collectionSnapshots.set(collectionKey, FROZEN_EMPTY_COLLECTION); } } } @@ -403,6 +403,7 @@ class OnyxCache { const members: NonUndefined> = {}; let hasMemberChanges = false; + let hasMembers = false; // Use the indexed forward lookup for O(collectionMembers) iteration. // Falls back to scanning all storageKeys if the index isn't populated yet. @@ -421,6 +422,7 @@ class OnyxCache { // and should not be included in the frozen collection snapshot. if (val !== undefined && val !== null) { members[key] = val; + hasMembers = true; // Check if this member's reference changed from the old snapshot if (!hasMemberChanges && (!previousSnapshot || previousSnapshot[key] !== val)) { @@ -449,6 +451,14 @@ class OnyxCache { return; } + // When the collection has no members, reuse one shared empty object for every empty + // collection. That way reads can tell a collection is empty with a quick `===` check + // instead of looping over its keys every time. + if (!hasMembers) { + this.collectionSnapshots.set(collectionKey, FROZEN_EMPTY_COLLECTION); + return; + } + Object.freeze(members); this.collectionSnapshots.set(collectionKey, members); @@ -466,18 +476,21 @@ class OnyxCache { } const snapshot = this.collectionSnapshots.get(collectionKey); - if (utils.isEmptyObject(snapshot)) { - // We check storageKeys.size (not collection-specific keys) to distinguish - // "init complete, this collection is genuinely empty" from "init not done yet." - // During init, setAllKeys loads ALL keys at once — so if any key exists, - // the full storage picture is loaded and an empty collection is truly empty. - // Returning undefined before init prevents subscribers from seeing a false empty state. - if (this.storageKeys.size > 0) { - return FROZEN_EMPTY_COLLECTION; - } + + // We never stored anything for this collection key. + if (snapshot === undefined) { return undefined; } + // The collection is empty (it holds our shared empty object). But "empty" is ambiguous + // during startup: we can't tell an actually-empty collection apart from one whose data + // hasn't loaded yet. Once any key exists, we know setAllKeys has run and loaded everything, + // so an empty collection really is empty. Before that, return undefined so subscribers + // don't briefly see a collection as empty when it just hasn't loaded. + if (snapshot === FROZEN_EMPTY_COLLECTION) { + return this.storageKeys.size > 0 ? FROZEN_EMPTY_COLLECTION : undefined; + } + return snapshot; } } From 8bdc36a2756871f4bca9007a4b763c3447b50036 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Thu, 23 Jul 2026 09:51:36 +0200 Subject: [PATCH 2/2] test: cover non-empty to empty collection transition --- tests/unit/onyxCacheTest.tsx | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/unit/onyxCacheTest.tsx b/tests/unit/onyxCacheTest.tsx index 713232459..e7a3bc7af 100644 --- a/tests/unit/onyxCacheTest.tsx +++ b/tests/unit/onyxCacheTest.tsx @@ -625,6 +625,27 @@ describe('Onyx', () => { }); }); + it('should return the stable empty reference after all members are removed', async () => { + await initOnyx(); + // Unrelated key keeps storageKeys non-empty so an empty collection is treated as "loaded and empty" + await Onyx.set(ONYX_KEYS.TEST_KEY, 'value'); + await Onyx.set(`${ONYX_KEYS.COLLECTION.MOCK_COLLECTION}1`, {id: 1}); + + const populated = cache.getCollectionData(ONYX_KEYS.COLLECTION.MOCK_COLLECTION); + expect(Object.keys(populated!)).toHaveLength(1); + + // Remove the last member — the collection becomes empty + await Onyx.set(`${ONYX_KEYS.COLLECTION.MOCK_COLLECTION}1`, null); + + const first = cache.getCollectionData(ONYX_KEYS.COLLECTION.MOCK_COLLECTION); + const second = cache.getCollectionData(ONYX_KEYS.COLLECTION.MOCK_COLLECTION); + + expect(first).toBeDefined(); + expect(Object.keys(first!)).toHaveLength(0); + // Reads must return the same empty reference so useSyncExternalStore doesn't re-render + expect(first).toBe(second); + }); + it('should preserve unchanged member references when a sibling is updated', async () => { await initOnyx(); const member1Value = {id: 1, name: 'unchanged'};