Skip to content

Commit 7fb447d

Browse files
elirangoshenclaude
andcommitted
fix: preserve cache-first merge when multiGet pre-warm rejects
The new hybrid pre-warm in mergeCollectionWithPatches replaced `Promise.all(existingKeys.map(get))` with `multiGet(existingKeys)` on the slow path. The two have different failure semantics: - get() catches storage read errors per-key and resolves with undefined (see Logger.logInfo catch at the bottom of get()). - multiGet() has no .catch, so Storage.multiGet rejections propagate all the way up. In the cold-key path this meant a transient IndexedDB read error rejected before cache.merge() and keysChanged() ran — subscribers missed the in-memory merge and the outer Onyx.mergeCollection / Onyx.update promise rejected, regressing the cache-first invariant established in PR #787 (#787). Adds a .catch at the call site that swallows the rejection (logging via Logger.logInfo for visibility) so cache.merge + keysChanged still fire when pre-warm reads fail. Doesn't modify multiGet itself — other callers may legitimately depend on rejection visibility. Adds a regression test that mocks Storage.multiGet to reject on the pre-warm read and asserts: 1. Onyx.mergeCollection resolves (doesn't reject up to caller). 2. The waitForCollectionCallback subscriber sees the merged delta. Confirmed: test fails on pre-fix code with "Received: [Error: Transient IndexedDB read error]" on `expect(outerRejected).toBeNull()`. Addresses chatgpt-codex-connector review on PR #793 (#793 (comment)). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d66152a commit 7fb447d

2 files changed

Lines changed: 64 additions & 1 deletion

File tree

lib/OnyxUtils.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1611,7 +1611,14 @@ function mergeCollectionWithPatches<TKey extends CollectionKeyBase>(
16111611
// missing keys into a single Storage.multiGet call (vs. N parallel get() invocations) and
16121612
// writes the storage values back to cache before resolving.
16131613
const hasColdExistingKey = existingKeys.some((key) => !cache.hasCacheForKey(key));
1614-
const prewarmPromise = hasColdExistingKey ? multiGet(existingKeys) : Promise.resolve();
1614+
// Swallow pre-warm read failures the same way the previous get()-based pre-warm did
1615+
// (see get() catch at the bottom of its definition). Without this, a transient
1616+
// Storage.multiGet rejection would skip cache.merge() + keysChanged() below and
1617+
// regress the cache-first invariant established in #787 — subscribers would miss
1618+
// the merge and Onyx.mergeCollection would reject up to the caller.
1619+
const prewarmPromise = hasColdExistingKey
1620+
? multiGet(existingKeys).catch((err) => Logger.logInfo(`mergeCollectionWithPatches pre-warm failed; proceeding with cache-only merge. Error: ${err}`))
1621+
: Promise.resolve();
16151622
return prewarmPromise.then(() => {
16161623
// Snapshot previous values from the (now-warm) cache for keysChanged's diff, then update
16171624
// cache and notify subscribers synchronously BEFORE issuing storage writes. This matches

tests/unit/onyxUtilsTest.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1089,6 +1089,62 @@ describe('OnyxUtils', () => {
10891089
expect(warmResult).toEqual(coldResult);
10901090
expect(coldResult).toEqual({value: 'after', extra: 'kept'});
10911091
});
1092+
it('preserves cache-first invariant when Storage.multiGet rejects on the slow path', async () => {
1093+
// Regression for codex review #3302454987 / PR #793. Before the .catch() at the
1094+
// pre-warm call site, a Storage.multiGet rejection would propagate up and skip
1095+
// cache.merge() + keysChanged() entirely — subscribers would miss the merge and
1096+
// Onyx.mergeCollection would reject. The previous get()-based pre-warm swallowed
1097+
// these errors per-key inside get()'s own .catch(), so the cache-first invariant
1098+
// from #787 held even on a flaky read.
1099+
const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY;
1100+
const coldMemberKey = `${collectionKey}1`;
1101+
const newMemberKey = `${collectionKey}2`;
1102+
1103+
// Seed an existing member, then evict it from cache so it's "tracked but unloaded" —
1104+
// the slow path will try to multiGet it.
1105+
await Onyx.set(coldMemberKey, {value: 'persisted'});
1106+
evictFromCache(coldMemberKey);
1107+
1108+
// Connect the subscriber and flush its initial data load FIRST, before installing
1109+
// the rejecting mock. Otherwise the connect's getCollectionDataAndSendAsObject path
1110+
// (whose multiGet call has no .catch) would consume the mockRejectedValueOnce and
1111+
// leak an unhandled rejection — not the merge pre-warm we're actually exercising.
1112+
const collectionCallback = jest.fn();
1113+
Onyx.connect({
1114+
key: collectionKey,
1115+
waitForCollectionCallback: true,
1116+
callback: collectionCallback,
1117+
});
1118+
await waitForPromisesToResolve();
1119+
collectionCallback.mockClear();
1120+
1121+
// The subscriber's connect re-populated cache, so re-evict to force the merge into
1122+
// the slow (cold-key) path. Then reject the next Storage.multiGet so the pre-warm
1123+
// read fails.
1124+
evictFromCache(coldMemberKey);
1125+
const transientError = new Error('Transient IndexedDB read error');
1126+
StorageMock.multiGet = jest.fn(pristineMultiGet).mockRejectedValueOnce(transientError);
1127+
1128+
// Outer promise must resolve, not reject, even when the pre-warm read fails.
1129+
let outerRejected: unknown = null;
1130+
const result = await Onyx.mergeCollection(collectionKey, {
1131+
[coldMemberKey]: {merged: true},
1132+
[newMemberKey]: {value: 'new'},
1133+
} as GenericCollection).catch((e: unknown) => {
1134+
outerRejected = e;
1135+
});
1136+
expect(outerRejected).toBeNull();
1137+
expect(result).toBeUndefined();
1138+
1139+
// cache.merge() + keysChanged() must still have fired so subscribers see the merge.
1140+
// Use toMatchObject for the cold key because a successful concurrent read may have
1141+
// re-populated the persisted value into cache; what we care about is that the new
1142+
// {merged: true} delta is applied.
1143+
expect(collectionCallback).toHaveBeenCalled();
1144+
const lastBroadcast = collectionCallback.mock.calls.at(-1)?.[0] as Record<string, unknown> | undefined;
1145+
expect(lastBroadcast?.[coldMemberKey]).toMatchObject({merged: true});
1146+
expect(lastBroadcast?.[newMemberKey]).toEqual({value: 'new'});
1147+
});
10921148
});
10931149

10941150
describe('multiGet cache hit consistency', () => {

0 commit comments

Comments
 (0)