perf: skip per-key clone when Onyx.init() hydrates the cache - #821
Conversation
|
All contributors have signed the CLA ✍️ ✅ |
|
I have read the CLA Document and I hereby sign the CLA |
b24ee81 to
25cf1cb
Compare
Regression test cases1. Cold start with existing data (the main path)
Why: 2. Collections render fully
Why: 3. Values with removed properties survive the restart
Why: 4. Offline data after restart
Why: exercises hydrate against optimistic data written by the previous session, including 5. Multi-tab race (web only — highest-risk scenario)
Why: the 6. Fresh install / empty storage
Why: 7. Sign out / sign in / switch accounts
Why: 8. Performance verification (this is the PR's actual goal)
A Sentry span or a PlatformsRun 1–4 and 6–7 on iOS, Android, and web (different storage providers: SQLite vs IndexedDB, and only SQLite batches the whole table through one |
roryabraham
left a comment
There was a problem hiding this comment.
Code LGTM but we should link an App PR to help the Onyx merge queue go smoother.
|
Codex Review: Didn't find any major issues. Can't wait for the next one! Reviewed commit: ℹ️ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
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". |
Reviewer Checklist
Screenshots/VideosAndroid: HybridAppAndroid: mWeb ChromeiOS: HybridAppmobile.moviOS: mWeb SafariMacOS: Chrome / Safariweb.mov |
App PR is here - Expensify/App#98649 |
|
@roryabraham @mkhutornyi |
mountiny
left a comment
There was a problem hiding this comment.
code looks good, waiting for @fabioh8010 review too
|
Checking internally the CLA action is failing due to new org wide rule |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d1f9732745
ℹ️ 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".
|
|
||
| // Use for-in loop to avoid an unnecessary array allocation from Object.keys() | ||
| // eslint-disable-next-line no-restricted-syntax, guard-for-in | ||
| for (const key in data) { |
There was a problem hiding this comment.
Ignore inherited properties when hydrating cache batches
When storage contains a key named __proto__ with an object value, the assignment in initializeWithDefaultKeyStates() changes allDataFromStorage's prototype; this unguarded for...in then treats every enumerable property of that stored value as a separate Onyx key and registers/caches data that does not exist under those keys. The previous Object.entries() path only processed own properties, so guard the loop with Object.hasOwn(data, key) (and likewise for the matching loop in merge()).
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
The mechanism is correct, but I don't think the guard is necessary here, and it was intentionally removed in review #821 (comment)
There was a problem hiding this comment.
Yeah I dont think this kind of situation would ever happen
mountiny
left a comment
There was a problem hiding this comment.
Thanks for the thorough review, I think we can move this ahead now
Details
Onyx.init()loaded the persisted database intoOnyxCachethroughmerge(), which runsfastMerge(existing, value)per key. At init timeexistingis alwaysundefined, sofastMergenever actually merges anything - it just walks and deep-clones every nested object of every row for no reason. On a heavy account this clone-heavy cache-write phase was measured at ~29% of totalOnyx.init()time.Adds
OnyxCache.hydrate(), a bulk-load variant used only byOnyx.init(). Values are stored by reference when safe (utils.needsNormalization()is a read-only, non-allocating check for nested null/undefined and the internalONYX_INTERNALS__REPLACE_OBJECT_MARK). Falls back to the existingfastMergepath in two cases: the key already has a value in cache (a cross-tab sync listener can race ahead of init and write a key beforehydrate()reaches it), or the value itself needs normalizing.OnyxUtils.ts'sinitializeWithDefaultKeyStates()now callscache.hydrate(allDataFromStorage)instead ofcache.merge(allDataFromStorage).merge()'s inlinefastMergeoptions were also extracted into a sharedCACHE_MERGE_OPTIONSconstant used by bothmerge()andhydrate()'s fallback, so the cache write paths can't drift apart.Related Issues
Expensify/App#98553
Linked E/App PR
Expensify/App#98649
Automated Tests
tests/unit/onyxCacheTest.tsxcovershydrate()directly and throughOnyx.init():merge(): identical cache state on an empty cache, and when keys already hold values, across fixtures with nested nulls, nestedundefined, the replace-object mark, and plain values.Manual Tests
Verify that the app boots normally, the LHN and reports render with the same data as before the change, and no console errors appear.
nullon disk).Verify that the report renders correctly with those fields absent, matching pre-change behavior.
Verify that tab B's cache ends up consistent with tab A's write - i.e. the value isn't clobbered by
hydrate()'s bulk load racing the cross-tab sync listener.Offline tests
Verify that the app boots with the last-persisted cache (same as before this change) and no data is lost or duplicated.
QA Steps
Same as tests.
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-08-14.at.15.07.49.mov