Skip to content

Keep cached Onyx arrays when re-sent data is unchanged to avoid recalculations and re-renders #104725

Open

Description

@MelvinBot

Problem

After every OpenApp or ReconnectApp, the app sends SearchForTodos. Its response merges records into several collections: transaction violations, transactions, report actions, and policies. Transaction violations are stored as arrays.

Since Expensify/react-native-onyx#765 and Expensify/react-native-onyx#768, a merge that changes nothing keeps the original object, and the cache skips that key.

Arrays are not covered. An incoming array always replaces the cached one, so the array and its parent objects get new references even when the content is identical. Derived values (OnyxDerived) and useOnyx subscribers without a selector detect changes by reference.

As a result, when the server re-sends unchanged records, the app recalculates and re-renders data that did not change. On Android, the Spend tab takes about 0.7 s (30%) longer to load.

Unchanged records re-sent by SearchForTodos on every cold start:

                  1,600 reports   3,900 reports
violations        1,355           212
transactions      201             106
report actions    31              165
policies          6               26
total             1,593           509

On the 1,600-report account, OpenSearchPage also re-sends 256 unchanged policies when the Spend tab opens.

Solution

In react-native-onyx, keep the cached array when the incoming one is deep-equal to it, for cache merges only. This is the same rule Expensify/react-native-onyx#765 already applies to objects.

Unchanged records then keep their references, so derived values skip them and subscribers do not re-render for them.

Draft PR: Deep compare arrays on merge and return old array if unchanged

Impact

Measured on a working Onyx branch against the same build without the change (medians, dev builds, account with about 1,600 reports):

                                       before   after   change
Android
  ManualNavigateToReportsContentLoad   3.07 s   2.35 s   -24%
  ManualNavigateToReports              3.06 s   2.35 s   -23%
  OnyxDerivedCompute_reportAttributes  471 ms   28 ms    -94%
Web
  ManualNavigateToInboxTab             216 ms   176 ms   -19%
  OnyxDerivedCompute_reportAttributes  28 ms    6 ms     -78%
  OnyxDerivedCompute, all values       103 ms   60 ms    -44%

No measured span got slower on either platform. Other spans (opening a report, the search router, the Inbox on Android) did not change.

The size of the gain depends on how much unchanged data the server re-sends for the account. The change is general: every key and every command that re-sends identical arrays stops triggering work.

Notes

Reported in Slack.

Issue OwnerCurrent Issue Owner: @jmusial

Activity

  1. zhumeiyan commented on Oct 23, 2026

    @zhumeiyan

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    On every OpenApp / ReconnectApp, SearchForTodos re-sends records that have not changed. PR #765 and #768 made object merges keep the original object when nothing changed, so the cache skips those keys. Arrays were not covered. An incoming array always replaces the cached one, so the array and its parent objects get new references even when the contents are byte-identical. OnyxDerived and useOnyx subscribers without a selector compare by reference, so they see a change that did not happen and recalculate / re-render. On a 1,600-report account that is ~1,593 unchanged records on cold start, and the Android Spend tab takes ~0.7 s (30%) longer.

    What is the root cause of that problem?

    Reference identity is being used as a proxy for value equality, and for arrays that proxy is broken. After #765/#768 an unchanged object survives a merge with its reference intact, so its subscribers correctly stay quiet. An array carrying the same unchanged objects still gets rebuilt, so it hands out a fresh reference and every subscriber downstream treats it as new data. The upstream data is fine — the identity contract is what breaks, and it breaks specifically on the array branch of the merge path.

    What changes do you think we should make in order to solve the problem?

    Follow the issue's stated solution in react-native-onyx: on cache merges, keep the cached array when the incoming one is deep-equal to it. This is the array counterpart of what #765/#768 already do for objects. Draft PR #860 exists.

    What I would add on top of the draft, and why it matters:

    The issue reports that "no measured span got slower," and I believe that measurement — but it does not cover the cost that the fix itself introduces. A deep compare now runs on every merge of every array, which is a hot path that did not previously do this work. The reported spans measure end-to-end navigation; they would only surface a deep-compare regression if it were large enough to move a whole navigation span. A per-merge cost increase that is real but smaller than span noise would not appear.

    So I propose measuring the new cost directly and separately, stratified by array size:

      array length      1        10       100      1,000    5,000
      deep compare      __ ms    __ ms    __ ms    __ ms    __ ms
      (per merge, median of N>=50, same device)
    

    The 1,600-report account in the issue has 1,355 violations in one array. If the curve is superlinear in that range, the fix trades a render cost for a compare cost on large accounts, and that trade should be visible in the PR rather than discovered later. If the curve is flat and negligible, the fix is unambiguously good and the table says so. Either result is a useful outcome — I am not proposing this because I expect to find a problem, but because a cost added to a hot path should be measured on its own rather than inferred from a span that got faster.

    If the curve does turn out to be a problem at the large end, the natural mitigation is to compare cheap first (length, then element-wise Object.is, then fall back to deep compare only when the shallow pass is inconclusive) so the common case stays O(n) pointer comparisons.

    What specific scenarios should we cover in automated tests to prevent reintroducing this issue in the future?

    Four tests, and the fourth one is the one I care most about:

    1. Merge an array whose contents are identical → the cached reference is preserved.
      This is the fix itself. Assert Onyx.get()/cache returns the same array object before and after.

    2. Merge an array whose contents differ → the reference does change.
      If the comparison is written too eagerly (for instance by short-circuiting on length alone, or by comparing only the first element), test 1 passes and the store silently stops propagating real updates. Test 2 is the guard against that. A fix that keeps references too aggressively is worse than the bug, because lost updates are silent and re-renders are not.

    3. An element that is itself an unchanged object survives inside the array.
      Per the root cause, arrays are rebuilt even when their elements are the objects Update version to 1.0.1-171 #765/[HOLD] Add Automated Tests for Onyx / withOnyx #768 already preserved. This test pins the interaction between the two fixes rather than assuming it composes.

    4. A negative control: the same assertions run against a deliberately broken implementation must fail.
      This is the one that catches a suite that passes because it is not actually exercising the path — for example if the test fixture never puts an array in the cache, or if the assertion compares with toEqual instead of toBe. toEqual passes on a deep-equal copy and would make every test above green while the bug is still live. I have been bitten by exactly this shape of false green, so I would rather assert on a known-bad implementation once than trust three green checks.

    What I cannot do, stated up front

    • No Android or macOS toolchain and no device. I cannot reproduce the 0.7 s / 30% Android figure, or any of the platform spans in the issue. I would measure reference stability and the deep-compare cost curve in a JS environment and report those, and I would not extrapolate them to device timings.
    • The issue's numbers are the author's local measurements, same caveat as the ones I flagged in [Due for payment 2026-11-03] [$350] [Performance] Reduce Search re-renders when opening reports #104742. I treat them as a direction and a ranking, not as a target to reproduce.
    • I have not read draft PR Standardize JSDoc type annotations #860. If it already covers the cost measurement or the tests above, I would rather extend it than open a competing PR.

    Why this proposal is shaped this way

    The fix is already specified in the issue and largely drafted. The remaining risk is not "will it work" but "what does it cost, and will it stay fixed." Those are measurement and test questions, which is where I would put the effort. I would rather deliver a table that says "compare cost is flat, no concern" than deliver a fix with an unmeasured cost on a hot path.

  2. melvin-bot commented on Oct 23, 2026

    @melvin-bot

    📣 @zhumeiyan! 📣
    Hey, it seems we don’t have your contributor details yet! You'll only have to do this once, and this is how we'll hire you on Upwork.
    Please follow these steps:

    1. Make sure you've read and understood the contributing guidelines.
    2. Get the email address used to login to your Expensify account. If you don't already have an Expensify account, create one here. If you have multiple accounts (e.g. one for testing), please use your main account email.
    3. Get the link to your Upwork profile. It's necessary because we only pay via Upwork. You can access it by logging in, and then clicking on your name. It'll look like this. If you don't already have an account, sign up for one here.
    4. Copy the format below and paste it in a comment on this issue. Replace the placeholder text with your actual details.
      Screen Shot 2022-11-16 at 4 42 54 PM
      Format:
    Contributor details
    Your Expensify account email: <REPLACE EMAIL HERE>
    Upwork Profile Link: <REPLACE LINK HERE>
    
  3. zhumeiyan commented on Oct 23, 2026

    @zhumeiyan

    Follow-up: I measured the deep-compare cost, and it disproves my own concern

    In my proposal above I raised the possibility that adding a deep compare to every array merge could become a new hot-path cost, and suggested a cheap-first tiered comparison as mitigation. I ran the measurement, and the concern does not hold. I'm posting it rather than quietly dropping it.

    Method: synthetic arrays, three scenarios, 200 samples after 20 warmup iterations, median. Node on my machine — not device numbers, not extrapolable to Android/iOS.

    length   scenario                    deep (ms)   tiered (ms)   ratio
    1        same element refs            0.0002      0.0002        1.1
             all-new elements (worst)     0.0007      0.0006        1.3
             last element differs         0.0003      0.0004        0.9
    10       same element refs            0.0003      0.0003        0.8
             all-new elements (worst)     0.0058      0.0038        1.5
    100      same element refs            0.0015      0.0021        0.7
             all-new elements (worst)     0.0150      0.0148        1.0
             last element differs         0.0149      0.0148        1.0
    1000     same element refs            0.0016      0.0019        0.9
             all-new elements (worst)     0.1523      0.1500        1.0
             last element differs         0.1510      0.1498        1.0
    5000     same element refs            0.0079      0.0094        0.8
             all-new elements (worst)     0.8010      0.7879        1.0
             last element differs         0.7955      0.7596        1.0
    

    What this says

    1. The mitigation I proposed is not worth adding. In the scenario that actually occurs — unchanged re-sent data, where #765/#768 have already preserved element object references — 5,000 elements cost 0.0079 ms. The 1,355-violation array from the issue is well below that. The tiered comparison is not faster (ratio 0.8, slightly slower, since the tier checks themselves cost something). Please ignore that part of my proposal.

    2. The cost lives almost entirely in "elements were rebuilt." Same-ref 0.0079 ms vs all-new 0.80 ms at length 5,000 — roughly 100x. Deep compare returns nearly free when Object.is holds element-wise; it only recurses when elements are genuinely new objects.

    3. This makes the cost curve useful as a diagnostic, which I did not expect. If #765/#768 are working, merged element objects keep their references and the cost lands in the 0.0079 ms bucket. If a real account measures near 0.8 ms, that is not "deep compare is slow" — it would mean element-level reference preservation is not taking effect on the array path. The measurement doubles as a probe for whether #765/#768 actually hold here. That seems more valuable than the performance number itself, and it's the version I'd want in CI: not a timing assertion, but a reference-identity assertion.

    Caveats

    • No Android or macOS toolchain and no device. These are JS-level numbers; I cannot reproduce the platform spans in the issue and am not extrapolating to them.
    • Synthetic element shape ({id, type, name, amount, meta}). Real violation objects may nest deeper, which would widen the all-new scenario specifically.
    • I have not read draft PR Standardize JSDoc type annotations #860. If it already covers this, treat this as confirmation from a second machine rather than a new finding.

    Net: the issue's approach looks unconditionally fine on cost. The thing worth testing in CI is reference identity, not timing.

  4. zhumeiyan commented on Oct 23, 2026

    @zhumeiyan

    Your Expensify account email: [email protected]
    Upwork Profile Link: https://www.upwork.com/freelancers/z4570285

  5. jmusial commented on Oct 27, 2026

    @jmusial
    Contributor
  6. melvin-bot commented on Oct 30, 2026

    @melvin-bot

    @jmusial Uh oh! This issue is overdue by 2 days. Don't forget to update your issues!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions