Skip to content

perf(navigation): deprioritize covered screens with React Activity instead of react-freeze #98254

Open

Description

@mountiny

Background

In our stack navigation, every screen below the top one stays fully mounted. This is what keeps its state and scroll position, and what keeps it painted behind the RHP overlay or during an iOS swipe-back gesture. It also means that a covered screen re-renders on every Onyx broadcast, exactly like the screen the user is looking at. The heaviest screens we have, the Inbox tab and the Spend tab, are also the ones that routinely sit covered, because every drill-down into a report, an expense or an editor opens as an RHP or a modal on top of them.

We tried to stop that rendering once before, with react-freeze (proposal). That attempt failed and had to be rolled back.

Since then, two efforts have shown a safer path. In July, together with Jakub Magiera, we shipped React <Activity> on the MoneyRequest/Share modal stacks (#96485), and it has been stable on main since. In parallel, Lukasz Modzelewski proved the same idea inside the react-navigation 8 alpha (#97081, PoC: #97477). We are synced with each other; because the final shape of the RN8 API and its release date are unknown, this proposal does not wait for it.

Problem

When a thread or a side panel is open over a large report or the search table, sending a message, saving a field, or opening another panel briefly freezes the app. Clicks give no visual feedback, the response delay can exceed half a second, and the lag scales with both the amount of data and the number of screens open in the background.

Solution

Wrap covered screens in React <Activity mode="hidden">, so their render work leaves the critical path of the interaction. Core PR: #97990. The PR currently wraps almost every screen in the app, 837 of them, but that is deliberate and for demo purposes only, so the mechanism and the numbers can be evaluated at full scale. The real rollout will activate Activity screen by screen, as described in the migration strategy below.

This is not a second run of react-freeze. Freeze suspended the whole subtree, so a frozen screen painted nothing and could not process a modal dismiss, which is where its blockers came from; a hidden Activity keeps paint and state, processes updates at background priority and runs real effect cleanups, so those blockers do not apply. The full comparison is below.

How it works: a new per-screen navigation option decides what happens to a screen while it is covered, so every screen can opt into Activity on its own and can be switched back to today's behavior instantly. Covered screens stay painted, inert and correctly laid out through window resizes, and they are revealed only once the navigation transition ends, so visually nothing changes for the user. The implementation details are in the PR description.

The results, measured on a 53-scenario A/B suite (web, heavy account, 5 iterations per scenario, app offline against Onyx snapshots):

  • The headline scenario, sending a message in a thread over the heaviest report, goes from 594 ms to 270 ms INP (-54%), with render time down 73%. This is the responsiveness users actually feel when they navigate over heavy screens.
  • The win repeats across the common paths: closing an RHP over a report cuts INP by 73% (118 ms → 32 ms), the home → expense → editor chain by 69% (293 ms → 91 ms), and the inbox → search tab switch by 55% (176 ms → 80 ms).
  • Across the whole suite, interactions are 26.4% faster overall: INP improves in 29 scenarios, regresses in 4 (none beyond +7%), and total render time drops by 6.5%.

The trade-off: hiding a screen runs its effect cleanups and revealing it re-runs its effects, so code written as "run once on mount, clean up on unmount" needs care. An overview of these edge cases and their mitigations is below.

Migration strategy

The plan:

  1. Merge the ability to enable the Activity wrapper per screen (the core PR above).
  2. Use React StrictMode in dev as the qualification gate for every migrated screen: the double effect mount/unmount there exercises the same lifecycle as hide/reveal, so a screen that runs clean under StrictMode is ready for Activity, and broken effects surface during development, before any cover does.
  3. Start migrating individual screens, one at a time, picking the ones that cause performance problems when they keep re-rendering in the background. These migrations can have their own issues, or happen as refactors within the ongoing work on improving existing metrics.

Optional extras, each worth doing on its own but none of them blocking the rollout:

  • Add generic automated tests that drive the same update twice, once with the screen visible and once while hidden with a reveal afterwards, and assert that the outcome is identical and that no extra Onyx writes or API calls appear.
  • Write a dedicated AI skill with the mechanism, the known bug classes and the per-screen audit checklist, so every audit (human or agent) starts from the same place.
  • Brief QA on the new bug class, so regression passes probe cover/reveal cycles deliberately instead of hitting them by accident.

Results (the full A/B data)

Full per-scenario A/B results are attached. The suite covers 53 scenarios on web, on a heavy account, with the app running offline so the network cannot skew the timings. The table shows INP and render time as main, branch and % columns (times in ms), sorted by the INP delta; scenarios with no measurable interactions sit at the bottom.

One note for reading it: back navigation does more render work than on main, because the revealed screen catches up with everything it skipped while covered. That work happens after the transition though, which is why the INP of those scenarios stays flat or better.

Production evidence

The A/B numbers line up with production: Sentry tracks how long sending a message takes depending on where it is sent from, and the worst p95 cases today are _rhp and _rhp_from_report, meaning messages sent from a report opened as a panel over other screens. That is exactly where this mechanism helps the most.

Potential regressions

The regressions this mechanism can cause all come from a single source: hiding a screen runs all of its effect cleanups and revealing it re-runs all of its effects, while state, refs and the DOM survive. In practice it can look like this:

  • a cleanup written for "on unmount" fires when the screen merely gets covered, and can wipe external state such as an unsaved draft;
  • an effect written for "once on mount" fires again on every reveal, so a fetch can be re-issued or a list can jump back to its initial scroll position;
  • a subscription is torn down while the screen is hidden, so events fired in that window are missed, for example an unread marker going stale;
  • a timer or a debounced save is cancelled by the cover and restarts from zero on the reveal;
  • the painted content of a covered screen can briefly go stale if no update reaches it while hidden; in the vast majority of our tests the deferred updates kept it current, but a back navigation can occasionally reveal an outdated frame for a moment.

The important part: these problems are solvable. The functional regressions are fixed by adjusting the effect implementations, and each pattern has a known recipe: a ref guard for run-once effects, flushing a debounced save in its cleanup, a presence check before a destructive cleanup, or hoisting the few listeners that must survive covering. The performance side, meaning the extra render work a reveal does to catch up, can be mitigated to a large degree as well, and the deferred reveal already keeps it off the interaction's critical path.

Alignment with react-navigation 8

This is the same mechanism react-navigation 8 is building. Upstream already pauses covered screens with Activity behind the inactiveBehavior screen option, and the RN8 PoC (#97081) reached the same design independently, using inactiveBehavior: 'pauseWhenCovered' on the report and search screens. So this proposal is not a detour taken while waiting for v8; it is the v8 behavior brought forward.

Two consequences. First, the per-screen option here mirrors the upstream design, so when we move to v8 the migration is close to a rename plus deleting our wrapper, not a redesign. Second, and more useful: every screen audited and fixed now stays fixed. The bug class is identical in both worlds, because it comes from Activity itself and not from where the wrapper lives, so the work done on screen effects today is paid once and carries into v8.

One caveat: we expect upstream not to expose a way to pause a screen that is technically visible, which is exactly our dimmed-RHP case — it is the kind of behavior an app can patch for itself, but a library can hardly offer as a public option. If that holds, a local patch will be needed in the v8 world too. That is a reason to build the mechanism now, not a reason to wait.

This is not a second run of react-freeze

Freeze suspends the subtree through Suspense, so a frozen screen paints nothing, and that alone rules it out for our layout: the screen under an RHP stays visible behind the dimmed overlay, and the screen under an iOS swipe-back has to be on screen for the gesture. A suspended subtree also cannot render at all, so a modal open inside it cannot dismiss itself, while its passive effects and Onyx subscriptions keep firing, because only the render is skipped. A hidden Activity is different by construction: it keeps state, scroll position and paint, it unmounts the subscriptions instead of just blocking their renders, it processes updates at background priority, and it runs real effect cleanups, so the freeze blockers do not apply.

PR

#97990

Slack thread: https://expensify.slack.com/archives/C08CZDJFJ77/p1786542522214489

Issue OwnerCurrent Issue Owner: @truph01

Activity

  1. added and removed
    ReviewingHas a PR in review
    on Aug 13, 2026
  2. roryabraham commented on Aug 13, 2026

    @roryabraham
    Contributor

    @dariusz-biela feel free to ping me directly when the PR is ready for review. I think we should probably do a full round of Applause QA on an AdHoc build for this one

  3. dariusz-biela commented on Aug 14, 2026

    @dariusz-biela
    Contributor

    I'm currently in the process of adding unit tests and cleaning up the code. Here's my latest draft of the production code: #98575

    I’ve already added React Strict Mode to ActivityWrapper with forced double mounting to make it easier to detect errors during development.

    However, my plan for the first PR was not to attach this to any screen yet, because that would likely require a major refactor and fixing potential bugs, and the core itself is quite large.

    I think we could add the core for now, with normal testing by QA.

    Then we can prepare 2–3 PRs with migrations for individual screens, and we’ll ask the QA team to review those more thoroughly.

    @roryabraham
    What do you think?

  4. dariusz-biela commented on Aug 14, 2026

    @dariusz-biela
    Contributor

    Generally speaking, from what I've checked, we can't roll out this solution to all screens at once, because there would be a lot of regressions. Especially since React strict mode was specifically disabled two years ago due to the number of “dev-only” bugs caused by double mounting. Now, this implementation approach will prevent us from using Activity, and we'll have to resolve these issues as we go during the migration.

    That’s why I came up with these optional additions, which should help ensure a smooth migration:

    Optional extras, each worth doing on its own but none of them blocking the rollout:

    • Add generic automated tests that drive the same update twice, once with the screen visible and once while hidden with a reveal afterwards, and assert that the outcome is identical and that no extra Onyx writes or API calls appear.
    • Write a dedicated AI skill with the mechanism, the known bug classes and the per-screen audit checklist, so every audit (human or agent) starts from the same place.
    • Brief QA on the new bug class, so regression passes probe cover/reveal cycles deliberately instead of hitting them by accident.

    I’d make the decision on whether to implement them after merging the core and attempting to create the first migration PRs.

  5. 64 remaining items

  6. dariusz-biela commented on Sep 30, 2026

    @dariusz-biela
    Contributor

    Quick update:

    Next, I'll verify these fixes and then prepare the PR that migrates the home page to Activity.

  7. melvin-bot commented on Oct 9, 2026

    @melvin-bot

    @truph01

    The solution for this issue has been 🚀 deployed to production 🚀 in version 9.5.6-6 and is now subject to a 7-day regression period 📆. Here is the list of pull requests that resolve this issue:

  8. dariusz-biela commented on Oct 14, 2026

    @dariusz-biela
    Contributor

    Quick update:
    While working on improving the Home Page, I noticed that the costs of this refactor are quite high and that it would be worth reviewing which direction we want to take with this solution—or whether we might want to choose a completely different optimization approach.

    To that end, I sent @Guccio163 a PR #103320 with a workaround using useScreenActivityEffect for review in terms of trade-offs.

    And I’m waiting for @roryabraham to return from vacation so we can discuss the next steps.

  9. Guccio163 commented on Oct 20, 2026

    @Guccio163
    Contributor

    Hi, I left my two cents here. Overall I like the idea of the useScreenActivityEffect hook - it looks like a good fix for the few places where the plain hidden Activity would break 👍

  10. roryabraham commented on Oct 20, 2026

    @roryabraham
    Contributor

    I'm back from vacation - what is the status of this issue and how can I help unblock it?

  11. dariusz-biela commented on Oct 22, 2026

    @dariusz-biela
    Contributor

    @roryabraham Welcome back! Here is where things stand and the decision I need help with.

    Status: development paused

    The cost of this work keeps growing, so I've paused development for a moment to re-evaluate whether we want to keep going in this direction.

    Where the gain is

    In my opinion the goal that justifies Activity is migrating three pages: Home, Report and Spend. They are the heaviest screens in the app, and they usually stay mounted while the user is somewhere else, so they keep re-rendering on every Onyx update even though nobody sees them. That is where the biggest gain should be.

    The downside is that these are also the pages with the most effects and the most third-party libraries, and some of those libraries don't support Activity.

    Page Reachable effects Flagged by the audit
    Report 440 192 (9 critical, 57 high)
    Spend 370 not reviewed yet (an earlier partial manual pass already confirmed about 24 regressions with tests)
    Home 127 36 (22 fixed with ordinary patterns in fork#418, 14 hard)

    The library side is mostly handled for Home: Skia (#100714), live-markdown (#102415) and the RN renderer patch (#101577) are merged. The other two pages still have open ones, for example expo-video (expo/expo#49595) and the Mapbox map.

    The effects problem

    I don't think fixing every effect on these pages is achievable:

    • Even simple effects are tedious to fix, because each one has many edge cases (cover, reveal, removal while hidden, StrictMode double mount).
    • Without an escape hatch, the hard cases can only be fixed by making the code worse: an effect that has to know whether its screen is covered needs a dedicated Context, and the component logic ends up spread across several places.

    The escape hatch: useScreenActivityEffect

    To get around this I wrote useScreenActivityEffect (#103320). In most cases it behaves like a regular useEffect, but when the Activity mode changes it skips the cleanup and the setup. On Home it covers 12 of the 14 hard cases.

    It has real trade-offs:

    • It deliberately works around React's rules.
    • It has its own edge cases (stale closures, a dependency change deferred until the reveal, shared refs), so it can cause regressions in places where a plain useEffect without Activity would work fine. StrictMode also stops exercising the cleanup of these effects.
    • Rolling Activity out widely means using it at many call sites across the codebase. On Home alone those 12 cases sit in 9 shared components and hooks, so they affect every screen that uses them.

    @Guccio163 reviewed the hook and considers the implementation and the trade-offs acceptable, on the condition that its use is guarded the way Onyx.connectWithoutView is: a documented policy, a justification at every call site, a test for every call site, and only where simpler fixes are not enough.

    Where Activity pays off

    The biggest gain is in RHPs that write to Onyx. Today every screen mounted under the RHP re-renders in the same commit as the screen the user is looking at, even though it isn't visible. With Activity the covered screens skip that work. In the demo (#97990, whole navigators opted in) for example:

    • Editing and saving the merchant of an expense: render time 401 ms -> 153 ms (-62%) from Spend, 267 ms -> 157 ms (-41%) from Inbox, 200 ms -> 141 ms (-29%) from Home.
    • Sending a message in a thread over the heaviest report: render time 295 ms -> 79 ms (-73%), INP 594 ms -> 270 ms.

    That work isn't removed, it's deferred: a hidden screen catches up when it is revealed. For example, going back from that thread to the heavy report costs 110 ms -> 237 ms of render time.

    These numbers are this high only because the covered screens are not optimized today: a single Onyx write makes them re-render far more than needed. Developers keep improving these pages (for example with narrower useOnyx selectors), and every such improvement shrinks what Activity can save, while also helping when nothing is hidden.

    Decision needed

    It's hard to estimate precisely, but with the current approach migrating these three pages is a matter of months, not days or weeks. I'd like to agree on one of these before I continue:

    1. Continue with Activity: merge useScreenActivityEffect with a usage policy, ship Home first, measure it in production, and only then decide on Report and Spend.
    2. Change approach: stop the Activity migration after the work that's already merged, and spend the effort on making updates of these pages cheaper instead. I'd keep the merged code, though: there may be simpler screens that are easy to opt in, and it gives anyone a cheap way to a perf gain there. A small, targeted use of Activity already paid off in @jmgraa's Share Extension PR (#96485), where the ShareExtensionOpenSubmitFlow span dropped from 801-1054 ms to 418-605 ms.
  12. dariusz-biela commented on Oct 22, 2026

    @dariusz-biela
    Contributor

    If you have any questions, I'll try to be on GitHub today at 10 PM Polish time (20:00 UTC) for about an hour to help speed up the
    discussion a bit.

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

Metadata

Metadata

Labels

ReviewingHas a PR in reviewSkip PaymentSkips all the payment process steps for an issue. Use it for fixed-price projects.TaskWeeklyKSv2

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions