Skip to content

perf(navigation): deprioritize covered screens with React Activity (core) - #97990

Draft
dariusz-biela wants to merge 19 commits into
Expensify:mainfrom
software-mansion-labs:dariusz-biela/perf/screen-activity-core
Draft

perf(navigation): deprioritize covered screens with React Activity (core)#97990
dariusz-biela wants to merge 19 commits into
Expensify:mainfrom
software-mansion-labs:dariusz-biela/perf/screen-activity-core

Conversation

@dariusz-biela

@dariusz-biela dariusz-biela commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Explanation of Change

This PR replaces react-freeze with React <Activity> for covered (non-top) screens. A hidden Activity keeps its state, cleans up its effects and processes updates at background priority, which removes the freeze regression classes (modal-race hard freezes, stale side effects) by construction. It is the core of #97666, extracted on its own so the solution can be reviewed and discussed without the follow-up work around it, and it generalizes the pattern from #96485 (defer the reveal behind the navigation transition, keep content painted with the display-contents underlay) from a single selector to every covered screen in the opted-in navigators.

In the app

  • Covered screens stop doing render work while they are covered, which makes navigation over a heavy screen (open an RHP over the Reports list, send a message in a thread over a heavy report) noticeably cheaper.
  • Covered screens stay painted, so the screen behind the RHP overlay and the underlay of an iOS swipe-back gesture look exactly as they do today.
  • Covered screens keep their state and their scroll position, and they are taken out of the accessibility tree, the tab order and the reach of the pointer while their content is stale.

In the code

  • nonTopScreenBehavior: 'none' | 'freeze' | 'activity' replaces the freezeNonTopScreens navigator flag. It is a normal navigation option, so a navigator picks the behavior for all of its screens through screenOptions and a single screen can override it. Freeze stays available as a fallback.
  • wrapDescriptorsWithNonTopScreensBehavior replaces wrapDescriptorsWithFreeze and picks the wrapper per screen from that option. Persistent screens (web sidebars) are never wrapped.
  • ScreenActivityWrapper renders the screen inside <Activity>. useIsScreenCovered reports whether the screen is covered, useScreenActivityMode turns that into the Activity mode: hiding is immediate, revealing waits for the navigation transition to end so a pop commits cheaply and animates undisturbed.
  • CustomViewWrapper neutralizes the display: none a hidden Activity commits (native view config, web MutationObserver), so the navigator's card visibility, not Activity, decides what is on screen.
  • windowSizeChangeStore reveals hidden screens for the duration of a resize or an orientation change, so they re-layout while still covered. Keyboard-driven height changes are ignored.
  • Rolled out to the modal stacks, RightModal, SearchFullscreen, Workspace and Split navigators. The root stack keeps its current behavior.

Wide RHP width across a hide

A hidden Activity unmounts the effects of its subtree, and the wide RHP registers its width from one of them, so a covered wide RHP would fold back to the single width. This is a companion fix rather than part of the mechanism, and it is here because the RHP is one of the navigators the PR opts in.

  • useRHPWidth deregisters a route only once that route has actually left the navigation state, so a hide leaves the registration in place.
  • WideRHPContextProvider keeps the registered routes and deregisters them from a navigation state listener, which covers a route closed while its screen was hidden and never runs its cleanup again.
  • extractPresentNavigationKeys counts preloaded routes as present, so a preloaded route is not mistaken for a closed one.

Deliberately out of scope

This branch carries the core mechanism only, so the gaps it leaves are visible in the app rather than hidden behind follow-up work. The workaround for reanimated replaying its web entering/exiting animations on Activity hide and reveal (visible on search expense rows) lives in #97666 and is left out here. There are no tests in this PR either.

Fixed Issues

$
PROPOSAL:

Tests

  1. On web with a wide window, open the Reports tab, open an expense to show the RHP over the list, then close it. Verify that the covered list stays painted behind the overlay and that it comes back with the correct content and scroll position.
  2. With an RHP open, resize the browser window across the narrow-layout breakpoint in both directions, then close the RHP. Verify that the revealed screens already have the correct layout for the new window size.
  3. Open a workspace, drill into a few settings screens and go back. Verify that the covered screens keep their state and that nothing flashes blank during the transitions.
  4. On iOS, open a report and swipe back slowly. Verify that the underlying screen is visible during the whole gesture and after cancelling it mid-way.
  5. With an RHP open on web, press Tab repeatedly. Verify that focus stays inside the RHP and never lands on the covered screen behind it.
  6. On web with a wide window, open an expense with a receipt so the wide RHP shows the receipt pane, open a further RHP screen on top of it (e.g. a field to edit) and go back. Verify that the RHP stays at the wide width the whole time and folds back to the single width only once it is closed.
  • Verify that no errors appear in the JS console

Offline tests

N/A - this PR only changes how covered screens are rendered and makes no API, Onyx or network changes.

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

Replace react-freeze with React <Activity> for covered (non-top) screens.
A hidden Activity keeps its state, cleans up its effects and processes
updates at background priority.

- nonTopScreenBehavior: 'none' | 'freeze' | 'activity' replaces the
  freezeNonTopScreens navigator flag, so freeze stays available as a
  fallback and a navigator or a single screen picks its own behavior.
- useScreenActivityMode hides a screen when it is covered or its
  navigator chain loses focus. Hiding is immediate, revealing waits for
  the navigation transition to end.
- CustomViewWrapper neutralizes the display none a hidden Activity
  commits, so the navigator's card visibility decides what is on screen.
- Rolled out to modal stacks, RightModal, SearchFullscreen, Workspace
  and Split navigators. The root stack keeps its current behavior.
- Add deprioritize and deprioritized to the project dictionary, so the
  spellcheck job passes.
- Merge ScreenActivityWrapper/index.native.tsx into index.tsx. The two
  files were identical apart from their comments, because
  CustomViewWrapper already resolves per platform.
- Share one NonTopScreenWrapperProps between the wrapper and the
  descriptor mapping instead of declaring the same shape twice.
- Correct the isScreenBlurred doc. A covered screen often stays visible,
  which is the whole point of CustomViewWrapper.
- Write display contents on the element even where MutationObserver is
  missing, and key the guard on the element so a new node is served too.
- Say in useIsScreenCovered where each half of the covered state comes
  from, and note that the wrapper reads the navigator's focus.
- Split hyphenated clauses into sentences, and state that the native view
  config was validated on Fabric.
A hidden Activity unmounts the effects of its subtree, so useRHPWidth
deregistered its route whenever the screen was covered and the RHP
container snapped back to the single width.

- useRHPWidth deregisters only when the route has actually left the
  navigation state, so a hide leaves the registration in place.
- WideRHPContextProvider keeps the registered routes and deregisters
  them from a navigation state listener, which covers a route closed
  while its screen was hidden and never runs its cleanup again.
- extractPresentNavigationKeys counts preloaded routes as present, so a
  preloaded route is not mistaken for a closed one.
@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Looks like you've decreased code coverage for some files. Please write tests to increase, or at least maintain, the existing level of code coverage. See our documentation here for how to interpret this table.

Files with missing lines Coverage Δ
...n/AppNavigator/createRightModalNavigator/index.tsx 0.00% <ø> (ø)
...avigator/createSearchFullscreenNavigator/index.tsx 75.00% <ø> (ø)
...gation/AppNavigator/createSplitNavigator/index.tsx 90.90% <ø> (ø)
...on/AppNavigator/createWorkspaceNavigator/index.tsx 75.00% <ø> (ø)
...NavigatorComponent/ScreenActivityWrapper/index.tsx 100.00% <100.00%> (ø)
...t/ScreenActivityWrapper/useIsWindowSizeChanging.ts 100.00% <100.00%> (ø)
...nt/ScreenActivityWrapper/useScreenActivityState.ts 100.00% <100.00%> (ø)
...tePlatformStackNavigatorComponent/index.native.tsx 100.00% <100.00%> (ø)
...onent/wrapDescriptorsWithNonTopScreensBehavior.tsx 100.00% <100.00%> (ø)
...gation/AppNavigator/ModalStackNavigators/index.tsx 6.39% <0.00%> (-0.03%) ⬇️
... and 9 more
... and 561 files with indirect coverage changes

React hides a covered screen with an inline 'display: none !important' in the mutation phase, and layout
effects force layout later in the same commit. The MutationObserver only ran on the microtask checkpoint
after that commit, so the value really landed: the layout tree of the whole covered screen was torn down and
built again on the way back. In the tab-inbox-to-workspaces canary that cost 4.0ms of layout and 6.9ms of
style recalculation per pass against 0.3ms and 0.8ms on main.

Patching the element's own style declaration to ignore every write to 'display' removes both passes. The
observer stays as the fallback for a React version that writes the style attribute as a whole, and normally
never fires.

Canary, 5 iterations of tab-inbox-to-workspaces on one dev server session:
layout 4.01ms -> 0.30ms (main 0.30ms), recalc style 6.91ms -> 2.43ms (main 0.76ms), layout passes 8 -> 5,
style passes 11 -> 8.
Fabric never received the hiding write in the first place, because the view config resolves display to
'contents' before the payload leaves JS (ReactFabric-dev.js cloneHiddenInstance). What it did receive was a
different value on every toggle: the visible wrapper had no display at all, so Yoga read the default flex,
and the hidden one read contents.

That difference is not free. YogaLayoutableShadowNode::updateYogaProps dirties a node only when its Yoga
style really changed, so every hide and reveal dirtied the wrapper and relaid out its subtree, and the same
value flips ForceFlattenView, so the native view was destroyed and created again on each toggle.

Pinning the value both states share leaves nothing to diff. The wrapper is now always flattened, which is
also one native view less per screen, and it matches the web wrapper, which is display contents from its
first render.
@dariusz-biela
dariusz-biela force-pushed the dariusz-biela/perf/screen-activity-core branch from 28f2866 to f918452 Compare August 10, 2026 10:13
Shorten every comment introduced here to the information that is not obvious
from the code, and drop the navigator level notes that described the rollout
as a demonstration, which is no longer what this branch does.
useIsScreenCovered carried a single expression, so it moves into the hook
that consumed it. useScreenActivityMode becomes useScreenActivityState and
returns the pair the wrapper needs, the Activity mode and the covered flag
its accessibility state follows.

Drops the per screen Activity log along with routeKey and routeName, which
existed only to feed it, and the comment on getCommonNavigationOptions that
described nonTopScreenBehavior from a file that knows nothing about it.
The hook has nothing navigator specific in it, so it does not belong in the
ScreenActivityWrapper folder. Its doc comment now says how it differs from
useIsResizing, which answers a similar question for tooltips on web only.
Rotating a device always changes the width, so the orientation check never
fired on its own. Dropping it removes the isPortrait helper and the second
tracked value. The height stays out of the filter because the soft keyboard
resizes the window on Android and on mobile web.
Reverts the move to src/hooks and the width only filter. The store keeps the
orientation check, and its header now says why the flag lives in a module
store rather than in each screen.
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