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
75 changes: 40 additions & 35 deletions lib/OnyxUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1579,47 +1579,52 @@ function mergeCollectionWithPatches<TKey extends CollectionKeyBase>(
// because we will simply overwrite the existing values in storage.
const keyValuePairsForNewCollection = prepareKeyValuePairsForStorage(newCollection, true);

const promises = [];

// We need to get the previously existing values so we can compare the new ones
// against them, to avoid unnecessary subscriber updates.
const previousCollectionPromise = Promise.all(existingKeys.map((key) => get(key).then((value) => [key, value]))).then(Object.fromEntries);

// New keys will be added via multiSet while existing keys will be updated using multiMerge
// This is because setting a key that doesn't exist yet with multiMerge will throw errors
// We can skip this step for RAM-only keys as they should never be saved to storage
if (!OnyxKeys.isRamOnlyKey(collectionKey) && keyValuePairsForExistingCollection.length > 0) {
promises.push(Storage.multiMerge(keyValuePairsForExistingCollection));
}

// We can skip this step for RAM-only keys as they should never be saved to storage
if (!OnyxKeys.isRamOnlyKey(collectionKey) && keyValuePairsForNewCollection.length > 0) {
promises.push(Storage.multiSet(keyValuePairsForNewCollection));
}

// finalMergedCollection contains all the keys that were merged, without the keys of incompatible updates
const finalMergedCollection = {...existingKeyCollection, ...newCollection};

// Prefill cache if necessary by calling get() on any existing keys and then merge original data to cache
// and update all subscribers
const promiseUpdate = previousCollectionPromise.then((previousCollection) => {
// Pre-warm cache for any existing storage keys that aren't yet in cache. get() is a no-op
// (sync-resolved) for cache hits, and on a cache miss it reads from storage and writes the
// value back to cache. This is required so the subsequent cache.merge() merges the new delta
// into the real previous storage value (rather than starting from `undefined` and dropping
// the existing keys).
return Promise.all(existingKeys.map((key) => get(key))).then(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return Promise.all(existingKeys.map((key) => get(key))).then(() => {
return multiGet(existingKeys).then(() => {

NAB but something to explore in the future or follow-up – Use multiGet to return all keys (just one trip to cache/DB) instead of doing N get()s. This should have beneficial performance gains but it made 2 tests failed because now some Onyx.connect fired with empty state before what we expected – it seems because we have more microtasks in the chain and consequently a minor delay that make this happen.

I suggest we don't include this change here, but it's something to explore

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will create an internal ticket for it

// Snapshot previous values from the (now-warm) cache for keysChanged's diff, then update
// cache and notify subscribers synchronously BEFORE issuing storage writes. This matches
// the cache-first / storage-second invariant followed by every other Onyx write method
// (setWithRetry, applyMerge, setCollectionWithRetry, partialSetCollection, clear),
// ensuring subscribers still reflect the merged data even if the subsequent storage
// write fails.
const previousCollection = getCachedCollection(collectionKey, existingKeys);
cache.merge(finalMergedCollection);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We always merge finalMergedCollection into the cache; if mergeCollectionWithPatches fails on the first run, all keys will be considered as existingKeys in the next attempt. This causes the Storage.multiMerge function to be called instead of Storage.multiSet for new keys.

@fabioh8010 fabioh8010 May 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catches @dmkt9. I was analysing and actually both pre-exist in main though, just less reliably (depends on whether promiseUpdate resolves before Promise.all(promises) rejects).

#2 is mostly handled by structural sharing + the === dedup in keysChanged. Only waitForCollectionCallback: true re-fires, and that one fires on every change by contract anyway.

#1 is real but benign — multiMerge on a missing key just stores the patch (works like a set in that case), so end state is the same.

I suggest we address this issue as a follow-up as same pattern affects multiSetWithRetry / setCollectionWithRetry / partialSetCollection and possibly all the functions that use retryOperation.

cc @elirangoshen

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest we address this issue as a follow-up as same pattern affects multiSetWithRetry / setCollectionWithRetry / partialSetCollection and possibly all the functions that use retryOperation.

Yes, that makes sense to me too.

keysChanged(collectionKey, finalMergedCollection, previousCollection);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, we also call keysChanged for each attempt after mergeCollectionWithPatches failed. This also causes Onyx.connect with waitForCollectionCallback: true to potentially be called more than once with the same payload.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

});

return Promise.all(promises)
.catch((error) =>
retryOperation(
error,
mergeCollectionWithPatches,
{collectionKey, collection: resultCollection as OnyxMergeCollectionInput<TKey>, mergeReplaceNullPatches, isProcessingCollectionUpdate},
retryAttempt,
),
)
.then(() => {
sendActionToDevTools(METHOD.MERGE_COLLECTION, undefined, resultCollection);
return promiseUpdate;
});
const promises = [];

// New keys will be added via multiSet while existing keys will be updated using multiMerge
// This is because setting a key that doesn't exist yet with multiMerge will throw errors
// We can skip this step for RAM-only keys as they should never be saved to storage
if (!OnyxKeys.isRamOnlyKey(collectionKey) && keyValuePairsForExistingCollection.length > 0) {
promises.push(Storage.multiMerge(keyValuePairsForExistingCollection));
}

// We can skip this step for RAM-only keys as they should never be saved to storage
if (!OnyxKeys.isRamOnlyKey(collectionKey) && keyValuePairsForNewCollection.length > 0) {
promises.push(Storage.multiSet(keyValuePairsForNewCollection));
}

return Promise.all(promises)
.catch((error) =>
retryOperation(
error,
mergeCollectionWithPatches,
{collectionKey, collection: resultCollection as OnyxMergeCollectionInput<TKey>, mergeReplaceNullPatches, isProcessingCollectionUpdate},
retryAttempt,
),
)
.then(() => {
sendActionToDevTools(METHOD.MERGE_COLLECTION, undefined, resultCollection);
});
});
})
.then(() => undefined);
}
Expand Down
95 changes: 95 additions & 0 deletions tests/unit/onyxUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,101 @@ describe('OnyxUtils', () => {
});
});

describe('mergeCollection cache-first ordering', () => {
// Save originals so we can restore them after each test. The tests below replace
// StorageMock.multiMerge / StorageMock.multiSet with rejecting mocks; without
// restoring, the mock leaks into later describe blocks (e.g. eviction tests) whose
// setup relies on these storage methods working normally.
const originalMultiMerge = StorageMock.multiMerge;
const originalMultiSet = StorageMock.multiSet;

afterEach(() => {
StorageMock.multiMerge = originalMultiMerge;
StorageMock.multiSet = originalMultiSet;
});

it('updates cache and notifies subscribers even when Storage.multiMerge rejects', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also test for Storage.multiSet failure

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY;
const existingMemberKey = `${collectionKey}1`;
const newMemberKey = `${collectionKey}2`;

// Seed an existing member so the merge path exercises multiMerge (existing) + multiSet (new)
await Onyx.set(existingMemberKey, {value: 'initial'});

const collectionCallback = jest.fn();
Onyx.connect({
key: collectionKey,
waitForCollectionCallback: true,
callback: collectionCallback,
});
await waitForPromisesToResolve();
collectionCallback.mockClear();

// Force Storage.multiMerge to reject with a non-retriable IDB error so the failure
// path is taken without burning the full retry budget and without rejecting the
// outer Onyx.mergeCollection promise.
const nonRetriableIdbError = Object.assign(new Error('Internal error opening backing store for indexedDB.open.'), {name: 'UnknownError'});
StorageMock.multiMerge = jest.fn().mockRejectedValue(nonRetriableIdbError);

await Onyx.mergeCollection(collectionKey, {
[existingMemberKey]: {value: 'merged'},
[newMemberKey]: {value: 'new'},
} as GenericCollection);

// Cache must reflect the merge regardless of the multiMerge rejection. This is the
// cache-first / storage-second invariant that mergeCollectionWithPatches must honor.
const cachedCollection = OnyxCache.getCollectionData(collectionKey);
expect(cachedCollection?.[existingMemberKey]).toEqual({value: 'merged'});
expect(cachedCollection?.[newMemberKey]).toEqual({value: 'new'});

// Subscribers must have been notified with the merged values.
expect(collectionCallback).toHaveBeenCalled();
const lastBroadcast = collectionCallback.mock.calls.at(-1)?.[0] as Record<string, unknown> | undefined;
expect(lastBroadcast?.[existingMemberKey]).toEqual({value: 'merged'});
expect(lastBroadcast?.[newMemberKey]).toEqual({value: 'new'});
});

it('updates cache and notifies subscribers even when Storage.multiSet rejects', async () => {
const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY;
const newMemberKey1 = `${collectionKey}1`;
const newMemberKey2 = `${collectionKey}2`;

// No keys are seeded, so every merged key is a "new" key. This forces the merge path
// to use Storage.multiSet (existing keys would go through Storage.multiMerge).
const collectionCallback = jest.fn();
Onyx.connect({
key: collectionKey,
waitForCollectionCallback: true,
callback: collectionCallback,
});
await waitForPromisesToResolve();
collectionCallback.mockClear();

// Force Storage.multiSet to reject with a non-retriable IDB error so the failure
// path is taken without burning the full retry budget and without rejecting the
// outer Onyx.mergeCollection promise.
const nonRetriableIdbError = Object.assign(new Error('Internal error opening backing store for indexedDB.open.'), {name: 'UnknownError'});
StorageMock.multiSet = jest.fn().mockRejectedValue(nonRetriableIdbError);

await Onyx.mergeCollection(collectionKey, {
[newMemberKey1]: {value: 'first'},
[newMemberKey2]: {value: 'second'},
} as GenericCollection);

// Cache must reflect the merge regardless of the multiSet rejection. This is the
// cache-first / storage-second invariant that mergeCollectionWithPatches must honor.
const cachedCollection = OnyxCache.getCollectionData(collectionKey);
expect(cachedCollection?.[newMemberKey1]).toEqual({value: 'first'});
expect(cachedCollection?.[newMemberKey2]).toEqual({value: 'second'});

// Subscribers must have been notified with the merged values.
expect(collectionCallback).toHaveBeenCalled();
const lastBroadcast = collectionCallback.mock.calls.at(-1)?.[0] as Record<string, unknown> | undefined;
expect(lastBroadcast?.[newMemberKey1]).toEqual({value: 'first'});
expect(lastBroadcast?.[newMemberKey2]).toEqual({value: 'second'});
});
});

describe('storage eviction', () => {
const diskFullError = new Error('database or disk is full');

Expand Down
Loading