perf: batch cold-cache pre-warm in mergeCollectionWithPatches via multiGet#793
Conversation
…tiGet Replaces the unconditional Promise.all(existingKeys.map(get)) pre-warm with a hybrid: - Fast path (every existingKey is already in cache): use a sync- resolved Promise — no extra microtask hops, preserving the original promise-chain depth and subscriber-callback timing that dependent tests rely on (Onyx.update batch tests broadcast a single merged callback rather than an `undefined` initial followed by the merged result). - Slow path (at least one cache-miss existingKey): use multiGet — one Storage.multiGet round-trip for the missing keys instead of N parallel get() invocations. Net result: same correctness as before, fewer storage operations on cold-cache merges, identical broadcast timing for warm-cache merges. Addresses follow-up from Expensify#787 review.
Adds a new describe('mergeCollection pre-warm', ...) block with 5 tests:
1. Fast path: skips storage reads entirely when every existing key is
warm in cache (spies on StorageMock.multiGet and StorageMock.getItem,
asserts both are called 0 times).
2. Slow path: batches cold existing keys into a single Storage.multiGet,
with no individual getItem calls.
3. Slow path: cold-cache merge layers the new delta on top of existing
storage data (no field drops) — guards the correctness invariant the
in-code comment specifically calls out.
4. Warm cache: subscriber receives a single merged broadcast for an
Onyx.update batch (no transient undefined) — guards promise-chain
depth.
5. Equivalence: warm-path and cold-path produce the same final cache
state for the same merge.
Includes a suite-pollution fix (capture pristine StorageMock refs and
restore them in beforeEach so seeding via Onyx.set isn't intercepted by
mocks leaking from the earlier retryOperation describe block), and an
evictFromCache helper that uses for…of to satisfy
unicorn/no-array-for-each.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Inside OnyxUtils.multiGet, the cached-value path was guarded by a truthy
check (`if (cacheValue)`), which treats cached falsy values (0, '',
false, null) as cache misses. Those keys fell through to
missingKeys → Storage.multiGet → cache.merge(temp), which would
overwrite the warm cached value with whatever stale value sat in
storage.
This mattered most after the new perf hybrid pre-warm in
mergeCollectionWithPatches started routing cold-cache merges through
multiGet — the outer check (`!cache.hasCacheForKey(key)`) and the inner
check disagreed for falsy values. Concrete case: `Onyx.set('coll_1', 0)`
updates cache to `0` but the storage write is still in flight; a
mergeCollection over the same collection key would re-fetch `coll_1`
from storage and clobber the cached `0`.
Aligns the inner check with hasCacheForKey so both sides agree, and
adds a regression test that seeds cache with `0` and asserts multiGet
returns it without touching Storage.multiGet / Storage.getItem.
Addresses fabioh8010's review on PR #5
(#5 (review)).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
73416e8 to
d66152a
Compare
|
Addressing the review on the predecessor PR (callstack-internal/react-native-onyx#5) — both points handled in this branch: 1. Added a regression test under 2. Unrelated reformat hunks in Resulting 3-commit history vs Total: lib +28/−6, tests +209/−0. 457/457 unit tests green, typecheck clean, prettier clean. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d66152a3be
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
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 Expensify#787 (Expensify#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 Expensify#793 (Expensify#793 (comment)). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Details
Hybrid pre-warm strategy for
mergeCollectionWithPatchesthat replaces the unconditionalPromise.all(existingKeys.map((key) => get(key))):Fast path (every
existingKeyis already warm in cache): use a sync-resolvedPromise.resolve(). No extra microtask hops, preserving the original promise-chain depth and the subscriber-callback timing that dependent tests rely on (Onyx.updatebatch tests broadcast a single merged callback rather thanundefinedfollowed by the merged value).Slow path (at least one
existingKeyis a cache miss): usemultiGet, which batches the missing keys into a singleStorage.multiGetround-trip instead of N parallelget()invocations and writes the storage values back to cache before resolving.Net result: same correctness as before, fewer storage operations on cold-cache merges, identical broadcast timing for warm-cache merges.
Addresses follow-up from #787 review.
Related Issues
Expensify/App#90634
Linked E/App PR
Expensify/App#91585
Automated Tests
Added a new
describe('mergeCollection pre-warm', ...)block intests/unit/onyxUtilsTest.tswith 5 tests:fast path: skips storage reads entirely when every existing key is warm in cache— Seeds two members viaOnyx.set, spies onStorageMock.multiGetandStorageMock.getItem, then runsOnyx.mergeCollection. Asserts both spies are called 0 times. Confirms the diff'sPromise.resolve()shortcut.slow path: batches cold existing keys into a single Storage.multiGet, with no individual getItem calls— Seeds three members, evicts two from cache (cold), leaves one warm. AssertsStorage.multiGetis called exactly once with only the two cold keys (the warm key is filtered out byOnyxUtils.multiGet), andStorage.getItemis never called during pre-warm.slow path: cold-cache merge layers the new delta on top of existing storage data (no field drops)— Seeds a key with{a:1, b:2}then evicts from cache. Merges{c:3}and asserts the cache holds{a:1, b:2, c:3}. Without the pre-warm reading from storage,cache.mergewould start fromundefinedand drop{a:1, b:2}— this guards the correctness invariant the in-code comment specifically calls out.warm cache: subscriber receives a single merged broadcast for an Onyx.update batch (no transient undefined)— Subscribes to a warm collection key, fires anOnyx.updatewith aMERGE_COLLECTIONop. Asserts subscriber was called exactly once with the final merged value (NOTundefinedthen merged on a later microtask). Guards the promise-chain-depth invariant.equivalence: warm-path and cold-path produce the same final cache state for the same merge— Runs the same effective merge against a warm cache and against a cold cache; asserts the post-merge collection state is identical in both runs.Suite-pollution fix: the
retryOperationdescribe block earlier in the file mutatesStorageMock.setItem(and other methods) without restoring them. This block captures pristine references at file-load time and restores them inbeforeEachso seeding viaOnyx.setactually persists.Helper note:
OnyxCache.dropremoves the key fromstorageKeys, which makesgetAllKeys()miss it when other keys remain in cache. TheevictFromCachehelper callsOnyxCache.addKey(key)afterdropso the key stays "tracked but unloaded" — exactly the cold-but-persisted state the slow path is meant to handle.Local results:
npx jest— 450/450 pass across 16 suites.npx tsc --noEmit— clean.Manual Tests
End-to-end verification against
Expensify/Appvia the companion PR Expensify/App#91585, which pinsreact-native-onyx'spackage.jsonto this branch's head SHA.Setup
react-native-onyxto this PR's head SHA.npm installunder Node 20.20.0, thennpm run web.Functional smoke (same flows as #787, expect no regression)
For each: open the screen, perform the action, verify UI updates immediately, persist after reload.
Cold-cache merge correctness
MERGE_COLLECTIONagainst one of those keys before the LHN hydrates it.Storage-failure regression (carry-over from #787)
OnyxDB. Do not reload.MERGE_COLLECTIONaction.Author Checklist
### Related Issuessection above### Linked E/App PRsection above, and verified this change against it (E/App CI passed and manual testing completed)TestssectiontoggleReportand notonIconClick)myBool && <MyComponent />.STYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)/** comment above it */thisproperly so there are no scoping issues (i.e. foronClick={this.submit}the methodthis.submitshould be bound tothisin the constructor)thisare necessary to be bound (i.e. avoidthis.submit = this.submit.bind(this);ifthis.submitis never passed to a component event handler likeonClick)Avataris modified, I verified thatAvataris working as expected in all cases)mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari
Screen.Recording.2026-05-25.at.12.15.01.mov
Screen.Recording.2026-05-25.at.12.15.33.mov
Screen.Recording.2026-05-25.at.12.21.34.mov
Screen.Recording.2026-05-25.at.12.25.17.mov