Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 23 additions & 10 deletions lib/OnyxCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Expand All @@ -403,6 +403,7 @@ class OnyxCache {

const members: NonUndefined<OnyxCollection<KeyValueMapping[OnyxKey]>> = {};
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.
Expand All @@ -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)) {
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
}
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/onyxCacheTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'};
Expand Down
Loading