Skip to content

Persist reportAttributes recompute baselines so app restarts do not t… - #96071

Closed
sumo-slonik wants to merge 13 commits into
Expensify:mainfrom
software-mansion-labs:perf/report-attributes-policy-signatures
Closed

Persist reportAttributes recompute baselines so app restarts do not t…#96071
sumo-slonik wants to merge 13 commits into
Expensify:mainfrom
software-mansion-labs:perf/report-attributes-policy-signatures

Conversation

@sumo-slonik

@sumo-slonik sumo-slonik commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Explanation of Change

On large accounts (thousands of reports), cold-opening the app and going to Reports/Inbox could freeze the UI for up to ~20s.

The report list metadata (report names, unread/error/GBR badges) is produced by the reportAttributes derived value. To stay fast it only recomputes when something relevant changed — in particular, when a policy changed. But the "what did the policies look like last time?" baseline was stored in an in-memory variable that resets on every app restart. Because OpenApp/Reconnect always delivers a policy update right after boot, that first update always looked like "everything changed" → the app rescanned every report. On a 22k-report account that's a single ~14s synchronous compute that blocks the main thread.

Fix

Persist the change-detection baseline inside the stored derived value (the same way locale already is), so it survives restarts:

  • Policies → a persisted policySignatures map. A real policy change still recomputes only the reports it affects.
  • conciergeReportID / introSelected → persisted too; they recompute only when the value actually changes.

Plus a smaller, always-on win: the parent-chat error-propagation pass (which scans every report) now runs only when a recomputed report's error status actually changed, instead of on every update.

The OnyxDerivedCompute span also gains a derived_trigger attribute (the dependency keys that fired the compute), so production telemetry can attribute long computes to their triggers.

Small/medium accounts are unaffected — the rescan was already cheap at that report count.

Performance results

Seeded 22k-report account, cold restart, web + Playwright, 50 runs each:

First policy merge after a cold restart main this PR
reportAttributes recompute — median 13.9 s 0.1 s
reportAttributes recompute — p90 19.8 s 0.1 s
how often the full rescan fires 72% of restarts never
time to content (ContentLoad) — p90 23 s 4.6 s

Re-validated on 2026-07-27, after merging main — which meanwhile landed derived-value compute coalescing (queueMicrotask batching) and moved trigger detection to triggeredKeys; this PR's checks were adapted to that engine. Same seeded 22k-report account, cold restart + immediate Reports-tab tap, 3 runs per side:

Restart + tap Reports main this PR
reportAttributes recompute — longest 19.3 / 19.8 / 20.4 s 0.15 / 0.15 / 0.15 s
time to content (ContentLoad) 22.5 / 23.4 / 24.6 s 2.7 / 4.1 / 3.8 s
full rescan on the policy_ trigger every restart never

Coalescing on main batches computes but does not remove the false full rescan — only the persisted baseline does, so the two changes are complementary. The first restart after upgrading to the new persisted format runs a one-time reconciliation pass that seeds the baselines; measured at ~27 ms, not a rescan.

Fixed Issues

$
PROPOSAL:

Tests

  1. Sign in with a High Traffic account.
  2. Let the app settle, then reload (web) or restart (native).
  3. Verify LHN/Reports show correct names, unread states, RBR/GBR indicators.
  4. Change a tracked policy field (e.g. Workflows > Approvals) — verify affected reports update.
  5. Change the app language — verify report names update (full recompute still works).
  6. Reload again — verify names/indicators stay correct with the persisted baselines.
  7. Trigger a real error/violation on an expense in a workspace chat — verify the parent chat still shows the RBR/Fix badge, and that it clears once the violation is resolved.
  • Verify that no errors appear in the JS console

Offline tests

Unnecessary — only changes when the local reportAttributes derived value recomputes; no network/persistence logic beyond the derived value itself.

QA Steps

Same as tests.

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • 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: mWeb Chrome
    • iOS: Native
    • iOS: mWeb 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 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 any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • 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)
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • 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 PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • 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.

Screenshots/Videos

Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari

…rigger full rescans

Policy change detection now compares signatures stored in the derived value
instead of a module-level snapshot that resets on every app restart, so the
first post-startup policy merge no longer rescans every report. The same
value-comparison guard is applied to conciergeReportID and introSelected,
which previously forced a full recompute whenever the key was re-delivered.
Each OnyxDerivedCompute span now carries a derived_trigger attribute.
Policy signatures now hash the whole policy content (minus Onyx write-bookkeeping
keys) via a stable stringify, so no consumed policy field can fall outside change
detection; the old full-rescan-on-reconnect was the accidental healer for fields
like policy.name. Signatures advance only when the recompute they imply actually
ran (or had no reports to touch), a missing baseline with computed attributes
falls back to recomputing the delivered policies' reports, a full scan snapshots
the baseline even without a policy trigger, and the concierge/introSelected
baselines advance only on full-recompute passes so a drifted value still heals
on its next delivery.
The concierge baseline sentinel is an empty string because Onyx.set strips
nested null values on persist. Policy signatures additionally exclude loading
flags, lastModified and the accounting connections subtree (volatile and never
read by the compute), and carry a format version so older stored signatures
degrade to one scoped recompute. The all-policies snapshot is built once per
pass and useIncrementalUpdates reuses hasComputedReports.
@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ Changes either increased or maintained existing code coverage, great job!

Files with missing lines Coverage Δ
src/CONST/index.ts 94.81% <ø> (ø)
src/libs/actions/OnyxDerived/index.ts 97.41% <100.00%> (+0.02%) ⬆️
...bs/actions/OnyxDerived/configs/reportAttributes.ts 98.21% <98.50%> (+0.45%) ⬆️
... and 77 files with indirect coverage changes

A value persisted before these fields existed left them permanently
undefined if the key that should detect a change landed on a pass
with no other updates, since the early-return metaPatch only seeded
policySignatures.
…anged

The propagation pass rebuilt parent/child maps by scanning every report
on every compute call, even tiny incremental updates touching one or two
reports. On a 22k-report account this ran unconditionally on each of the
several recomputes fired during OpenApp hydration, adding real overhead
on top of the already-fixed policy_ full-rescan. Skips the scan when none
of the reports just recomputed have a different needsParentChatErrorPropagation
or brickRoadStatus than before, which is the only state the propagation
depends on.
…s-policy-signatures

# Conflicts:
#	src/libs/actions/OnyxDerived/configs/reportAttributes.ts
@sumo-slonik

Copy link
Copy Markdown
Contributor Author

@codex review

@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: 9796fb2dee

ℹ️ 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 on lines +328 to +331
const hasConciergeReportIDChanged =
hasKeyTriggeredCompute(ONYXKEYS.CONCIERGE_REPORT_ID, sourceValues) && storedConciergeReportID !== undefined && storedConciergeReportID !== nextConciergeReportID;
const hasIsTrackIntentUserChanged =
hasKeyTriggeredCompute(ONYXKEYS.NVP_INTRO_SELECTED, sourceValues) && storedIsTrackIntentUser !== undefined && storedIsTrackIntentUser !== nextIsTrackIntentUser;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Treat missing delivered baselines as changes

When upgrading from a persisted reportAttributes value that predates these new baseline fields, an explicit CONCIERGE_REPORT_ID or NVP_INTRO_SELECTED delivery is now treated as unchanged because the stored baseline is undefined; the later meta patch then stores the delivered value without recomputing reports. If that value actually changed while the app was not running (or was first populated after the old value was computed), the only delivery that should update Concierge/track-dependent report names is absorbed, and later identical deliveries are skipped because the baseline now matches. Consider forcing a full recompute when one of these keys triggers and its stored baseline is missing, or only seeding on non-trigger passes.

Useful? React with 👍 / 👎.

…livery pass

On the upgrade path (a persisted reportAttributes value that predates these baseline fields), the pass delivering CONCIERGE_REPORT_ID / NVP_INTRO_SELECTED was seeded without recomputing, so a value that changed while the app was closed could be absorbed silently and later identical deliveries skipped. A missing baseline on the delivery pass now counts as a change and forces a recompute (matching the policy-signature and display-name baselines); a missing baseline is seeded only on passes that did not deliver the key. Adds unit tests for both paths.
…s-policy-signatures

# Conflicts:
#	src/libs/actions/OnyxDerived/configs/reportAttributes.ts
#	tests/unit/reportAttributesTest.ts
- Document that policy signature key exclusion applies at every nesting depth
- Lead the signature comment with its invariant instead of a rejected alternative
- Skip the full report walk when a coalesced policy trigger delivers an empty delta
- Sort derived_trigger keys for stable Sentry group-bys, stamp the from-scratch
  first compute as 'initial', and hoist that value into CONST.TELEMETRY
- Add a triggeredBy() helper for the repeated triggeredKeys sets in tests
@sumo-slonik

Copy link
Copy Markdown
Contributor Author

Closing this — #97093 fixed the root cause this PR was targeting.

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.

1 participant