Skip to content

Skip unchanged cached subtrees when removing nested nulls - #856

Open
arekm213 wants to merge 7 commits into
Expensify:mainfrom
arekm213:perf/skip-unchanged-subtrees
Open

arekm213 wants to merge 7 commits into
Expensify:mainfrom
arekm213:perf/skip-unchanged-subtrees

Conversation

@arekm213

@arekm213 arekm213 commented Oct 6, 2026 •

Copy link
Copy Markdown

Details

Every Onyx.set (and multiSet, setCollection, new members in mergeCollection) runs removeNestedNullValues over the whole value before caching and storing it. The App rewrites some large values after almost every update, for example derived values that hold one entry per report, so each write walks the entire object even when only one field changed.

Since #765, values built from the cached one keep the same object reference for every subtree that didn't change. This PR uses that:

  1. Skip subtrees the cache already holds. removeNestedNullValues(value, nullFreeReference) takes the current cached value and returns any nested object that is the same reference as in the cache as is, without walking it. The cache holds it, so it has no nulls. The returned objects are exactly the same as before (same references, same values), so subscribers get the same values and notifications. setWithRetry passes the existing value, prepareKeyValuePairsForStorage passes cache.get(key).

  2. Close the four paths that put nested nulls into the cache today, since step 1 relies on the cache being null-free:

    • Batched remove-then-replace merges: the replace branch in mergeObject now cleans the replacement object when shouldRemoveNestedNulls is set (lib/utils.ts).
    • SQLite storage: JSON_REPLACE stores the replacement objects of these merges as they are, nulls included, and lazy reads then put them into the cache. getItem now cleans them on read, and only walks objects whose JSON contains null, so reads of values without nulls cost the same as before. They are not cleaned before they are stored, because a later patch in the same batch can target a path inside an earlier replacement, and that path must still exist when the later patch runs (lib/storage/providers/SQLiteProvider.ts). multiGet now hands out what the cache holds after cache.merge(temp), so callers and cache agree (lib/OnyxUtils.ts). On web, IndexedDB getItem cleans the same way, since older versions could also store nested nulls there (lib/storage/providers/IDBKeyValProvider/index.ts).
    • Web cross-tab sync: values from another tab are cleaned before cache.set (lib/Onyx.ts).
    • Onyx.clear(): default key states are cleaned before cache.set. For example the App's default for account is {errors: null, ...}, so after a clear subscribers saw an errors key that every other path omits (lib/Onyx.ts).

    On these paths subscribers now get an omitted key instead of {key: null}, the same as on every other path.

  3. Dev-only guard. If a skipped subtree does hold nulls, App code mutated a cached object in place, which Onyx doesn't support. In development this logs an alert, so it is caught early. In production the check is skipped, because it would need the walk this PR removes.

Prior work: skipNullRemoval (#655, #659) was removed in #666 because the App mutated shared nested objects. This PR still removes nulls from everything that changed and only skips subtrees that are the same reference as the cache. Draft #751 removes null pruning entirely, which would change what subscribers receive. This PR doesn't.

Results (release builds, heavy test account, median before vs. after; 8 runs per variant on an iOS simulator and 6 on an Android emulator, web in Chrome with CPU x4 throttling):

Scenario iOS Android Web (CPU x4)
Setting a large object ~290 to ~265 ms (~-9%) 293 to 282 ms (-3.8%)
Each Pusher update 347 to 331 ms (-4.6%) 349 to 336 ms (-3.7%) 326 to 310 ms (-4.9%)
Incremental reconnect ~700 to ~660 ms (~-6%) inconclusive (emulator noise) 602 to 564 ms (-6.6%)

On top of the pending OnyxDerived change that skips writes of unchanged derived values (callstack-internal/Expensify-App#76, part of Expensify/App#98956), the gain holds on web: each Pusher update 341 to 316 ms (-7.2%), incremental reconnect 580 to 532 ms (-5.5%), faster in 5 of 5 runs.

Reassure: the get test is unchanged against main. setCollection with 10k heavy objects can show up to about +8% locally. That test replaces every object with a new one, so nothing is shared and the lookup in the cached value is pure overhead. In the App, writes mostly reuse cached objects.

Related Issues

Expensify/App#103616

Linked E/App PR

Expensify/App#103978

Automated Tests

  • utilsTest.ts: removeNestedNullValues with a reference. It returns the same output as without one for any null-free reference on randomized (seeded) inputs, returns the reference itself as is, doesn't traverse shared subtrees, still removes nulls from subtrees that differ, and ignores references that aren't plain objects. Also covers the replace branch of mergeObject cleaning only when shouldRemoveNestedNulls is true.
  • SQLiteProviderTest.ts (real SQLite engine): a replacement nested inside an earlier replacement in the same batch is applied, and getItem removes nested nulls from a stored value while leaving arrays as they are.
  • IDBKeyvalProviderTest.ts: getItem removes nested nulls from a stored value while leaving arrays as they are.
  • onyxTest.ts (null-free cache): the native replace branch and a batched remove-then-replace update, multiGet handing out the cleaned cached value, set and multiSet keeping cached subtrees while removing new nulls, the dev alert on a mutated shared subtree, and a randomized test that runs sequences of set, merge, multiSet, mergeCollection and update with nested nulls and checks that every cached value stays null-free.
  • Full suite: 693/693 passing (after merging main at 3.0.120).

Manual Tests

Tested with the App pinned to this branch (Expensify/App#103978), against the production API.

  1. Sign in, open the Inbox and a few reports, and verify they load as before.
  2. Send a message in a chat and verify it shows up.
  3. Go offline, send another message, and verify it shows as pending. Go back online and verify it syncs.
  4. Open an expense, edit its description and save it, then clear it again. Reload and verify the expense shows the expected values.
  5. Open the Spend tab and verify the expenses list loads.
  6. Sign in again or switch to a Copilot account, and verify that no false error indicator shows on the account (the errors key from the default account state is omitted now).
  7. In a dev build, verify that the console shows no removeNestedNullValues skipped a cached subtree alert during the steps above.

Done on web with a high traffic account (read only) and a smaller account (including writes), and on all native and mobile web platforms with a test account. No new console errors and no dev alerts. When Onyx.clear() ran on sign in or on a Copilot switch, the only null this PR removed was errors from the default account state, as expected.

Author Checklist

  • I linked the correct issue in the ### Related Issues section above
  • I linked the corresponding Expensify/App PR in the ### Linked E/App PR section above, and verified this change against it (E/App CI passed and manual testing completed)
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android / native
    • Android / Chrome
    • iOS / native
    • iOS / Safari
    • MacOS / Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • I verified that the left part of a conditional rendering a React component is a boolean and NOT a string, e.g. myBool && <MyComponent />.
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.js or at the top of the file that uses the constant) are defined as such
  • I verified that if a function's arguments changed that all usages have also been updated correctly
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately and each prop has a /** comment above it */
    • The file is named correctly
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • If we are not using the full Onyx data that we loaded, I've added the proper selector in order to ensure the component only re-renders when the data it is using changes
    • For Class Components, any internal methods passed to components event handlers are bound to this properly so there are no scoping issues (i.e. for onClick={this.submit} the method this.submit should be bound to this in the constructor)
    • Any internal methods bound to this are necessary to be bound (i.e. avoid this.submit = this.submit.bind(this); if this.submit is never passed to a component event handler like onClick)
    • All JSX used for rendering exists in the render method
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.
  • I have checked off every checkbox in the PR author checklist, including those that don't apply to this PR.

Screenshots/Videos

No UI change. Smoke tested on all platforms listed above, no errors or regressions found.

@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

Cleaning every value read by OnyxUtils.get() walked the whole value and made
the Reassure get test 41% slower. The only provider that could hand out
nested nulls is SQLite, because JSON_REPLACE stores the replacement object
as is. Clean the replacement objects before they are stored, and clean
values that older versions already stored when they are read, skipping the
walk for objects whose JSON has no null.
@arekm213

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

exfy-clabot Bot added a commit to Expensify/CLA that referenced this pull request Oct 13, 2026
@Krishna2323

Copy link
Copy Markdown
Contributor

I'll review this one. Let me know once it's ready for review. Thanks!

@arekm213

Copy link
Copy Markdown
Author

@MelvinBot review

arekm213 added a commit to arekm213/expensify-app that referenced this pull request Oct 15, 2026
@arekm213
arekm213 marked this pull request as ready for review October 15, 2026 11:44
@arekm213
arekm213 requested a review from a team as a code owner October 15, 2026 11:44
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-15T13:17:12.164464Z 65f5f9d Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@melvin-bot
melvin-bot Bot requested review from mollfpr and removed request for a team October 15, 2026 11:44
@arekm213

Copy link
Copy Markdown
Author

@fabioh8010 could you please take a look too? thanks!

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 94edaee019

ℹ️ 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".

Comment thread lib/storage/providers/SQLiteProvider.ts Outdated
const jsonPath = `$.${pathArray.join('.')}`;
return [jsonPath, JSON.stringify(value), key];
// JSON_REPLACE stores the object as is, so nested nulls are removed first.
return [jsonPath, JSON.stringify(utils.removeNestedNullValues(value as OnyxValue<OnyxKey>)), key];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve placeholders needed by descendant replacements

In a native batch that removes and recreates overlapping objects—for example merges {a: null}, {a: {b: null}}, then {a: {b: {c: 1}}}—mergeAndMarkChanges emits $.a -> {b: null} followed by $.a.b -> {c: 1}. Cleaning the first patch here turns it into {}; after SQLite applies that outer JSON_REPLACE, the subsequent JSON_REPLACE cannot create the now-missing $.a.b path, so storage ends as {a: {}} while the cache has {a: {b: {c: 1}}}, losing data on restart. Preserve or fold these null placeholders until dependent patches have been applied, and only then normalize.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed, the replace patches are stored as they were before this PR, and nested nulls are removed on read instead (getItem, cache.hydrate, cache.merge), so the cache still never holds them. Added a regression test for nested replacements in SQLiteProviderTest.ts

@mollfpr
mollfpr requested a review from Krishna2323 October 15, 2026 12:00
@arekm213

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: 65f5f9dc17

ℹ️ 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".

arekm213 added a commit to arekm213/expensify-app that referenced this pull request Oct 15, 2026

@fabioh8010 fabioh8010 left a comment

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.

@arekm213 In case you didn't before, could you run some perf benchmarks of before/after this change around some main metrics? For example:

  • ManualNavigateToInboxTab
  • ManualOpenReport
  • ManualAppStartup
  • ManualSendMessageVisible

Just to make sure we are safe perf-wise

Comment thread lib/utils.ts
}

if (value === nullFreeReference) {
if (process.env.NODE_ENV === 'development' && needsNormalization(value)) {

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
if (process.env.NODE_ENV === 'development' && needsNormalization(value)) {
if ((process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') && needsNormalization(value)) {

We should log during tests too, no?

Comment thread lib/utils.ts
Comment on lines +231 to +236
/**
* Deep removes the nested null values from the given value. Returns the original reference if no nulls were found.
*
* Nested objects that are the same reference as the object at the same path in `nullFreeReference` are returned as is,
* without being traversed. Pass the live cache value here, since cached values never hold nested nulls.
*/

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.

NAB: arrays are never cleaned (neither null elements nor nulls in objects inside them), so "never hold nested nulls" isn't fully true. Could we mention arrays here? Otherwise someone may add array cleaning later and break the skip without noticing.

@mollfpr

mollfpr commented Oct 20, 2026

Copy link
Copy Markdown

@Krishna2323 could you take a look on the PR when you get a chance? Thanks!

@Krishna2323

Copy link
Copy Markdown
Contributor

I'll review it today, probably in an hour or two.

Comment thread lib/Onyx.ts
if (!isKeyToPreserve) {
const oldValue = cache.get(key);
const newValue = defaultKeyStates[key] ?? null;
const newValue = utils.removeNestedNullValues(defaultKeyStates[key] ?? null);

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.

NAB: removeNestedNullValues() returns a new object on every clear() when the default holds nested nulls, so newValue !== oldValue is always true. Example: default {errors: null, isLoading: false}, 3 calls to clear(). A useOnyx hook without a selector re-renders 1 time on main, 3 times on this PR.

Comment thread lib/Onyx.ts
continue;
}

const value = utils.needsNormalization(valueFromStorage) ? utils.removeNestedNullValues(valueFromStorage) : valueFromStorage;

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.

NAB: No test covers this fix. If this line is changed back to const value = valueFromStorage;, all tests still pass.

Comment thread lib/Onyx.ts
if (!isKeyToPreserve) {
const oldValue = cache.get(key);
const newValue = defaultKeyStates[key] ?? null;
const newValue = utils.removeNestedNullValues(defaultKeyStates[key] ?? null);

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.

NAB: No test covers this either. Reverting to defaultKeyStates[key] ?? null keeps all 693 tests passing. The randomized null-free cache test never calls clear().

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants