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:
- Merge the ability to enable the Activity wrapper per screen (the core PR above).
- 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.
- 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
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 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:
StrictModein 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 underStrictModeis ready for Activity, and broken effects surface during development, before any cover does.Optional extras, each worth doing on its own but none of them blocking the rollout:
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,branchand%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
_rhpand_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:
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
inactiveBehaviorscreen option, and the RN8 PoC (#97081) reached the same design independently, usinginactiveBehavior: '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-freezeFreeze 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