Skip to content

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

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

perf(navigation): deprioritize covered screens with React Activity instead of react-freeze#97666
dariusz-biela wants to merge 22 commits into
Expensify:mainfrom
software-mansion-labs:dariusz-biela/perf/screen-activity-wrapper

Conversation

@dariusz-biela

@dariusz-biela dariusz-biela commented Aug 3, 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. The solution is modeled on #96485 and generalizes its pattern (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 code

  • nonTopScreensBehavior: 'freeze' | 'activity' replaces freezeNonTopScreens; freeze stays available as a fallback. Rolled out to modal stacks, RightModal, SearchFullscreen, Workspace and Split navigators; the root stack keeps its current behavior.
  • useScreenActivityMode hides a screen when it is covered or its navigator chain loses focus: hiding is immediate, revealing waits for the transition to end (useDeferVisibleUntilFocusTransitionEnd).
  • CustomViewWrapper neutralizes the display: none a hidden Activity commits (native view config, web MutationObserver), so card visibility decides what is on screen.

Handled edge cases

  • A screen that mounts already covered gets one frame at full priority so its mount effects can run, then is deprioritized (with a setTimeout fallback for backgrounded apps and hidden tabs).
  • A window resize or orientation change reveals hidden screens for 250 ms so they re-layout while covered; keyboard-driven height changes are ignored.
  • A reveal overtaken by another navigation is cancelled, and TransitionTracker timeouts guarantee a reveal even when no transition starts, so a screen cannot get stuck hidden.
  • Persistent screens (web sidebars) are never wrapped; wide RHP width registrations survive hides and routes closed while hidden are deregistered by a navigation state listener.

Known issue: reanimated web animations replay under Activity

React re-invokes class component lifecycles on Activity hide/reveal, and reanimated runs its web entering/exiting animations exactly there, so search expense rows (configured with exiting: FadeOutUp) replayed their exit and entrance on every navigation. This PR disables those row animations as a workaround (including the delete-expense exit animation); the proper fix belongs upstream in reanimated.

Performance

Measured on a web dev build with a data-heavy account, this branch vs main, median of 10 iterations. All scenarios run over an open RHP stack on the Search page (report, expense, description editor):

Scenario Render: main → branch Render delta INP: main → branch INP delta
Open: drill from the Search results down to the description editor 913.6 → 698.2 ms -23.6% 676 → 584 ms -13.6%
Resize with the full stack open 676.5 → 501.1 ms -25.9% no input interaction n/a
Close: unwind the whole stack with the back button 401.9 → 394.5 ms -1.8% 132 → 104 ms -21.2%
Send a message in a thread over the heavy report 291.3 → 81.8 ms -71.9% 464 → 296 ms -36.2%
Total (sum of render medians) 2283.2 → 1675.6 ms -26.6%

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 the rows do not replay any exit or entrance animation when the RHP closes.
  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. 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.
  • 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

… Activity

Replace the freezeNonTopScreens flag with a nonTopScreensBehavior option
('freeze' | 'activity'). With 'activity' a covered screen is wrapped in
React <Activity>, which defers its updates to background priority and runs
effect cleanups while hidden, so a dismissing modal always finishes its
close chain and no freeze delay or modal coordination is needed.

On native the content stays painted through CustomViewWrapper (extracted
from ScreenFreezeWrapper and shared with it) so the underlay screen stays
visible during swipe-back. On web hiding is deferred until the navigator
hides the surrounding card, observed through a sentinel outside the
Activity boundary, and fails open if the card never gets hidden.

Enable 'activity' on all modal stack navigators, split navigators
(reports, settings, workspace, domain), RightModalNavigator,
SearchFullscreenNavigator and WorkspaceNavigator. The 'freeze' behavior
stays available as a per-navigator fallback.
…ot stack

Web ScreenActivityWrapper no longer waits for the navigator to hide the
covered card (IntersectionObserver sentinel removed). The mode flips to
hidden as soon as the screen is blurred, and a new web CustomViewWrapper
forces 'display: contents !important' back through a MutationObserver,
neutralizing the inline 'display: none !important' that React applies to
the content of a hidden Activity. Visibility stays fully controlled by
the navigator's cards, so a covered screen that is still on screen (e.g.
dimmed under the RHP overlay on wide layouts) stays painted and only
stops updating. This mirrors the native view config trick, now split
into platform variants of CustomViewWrapper.

Enable 'activity' on RootStackNavigator so fullscreen content (tabs,
search, splits) is deprioritized when another root route covers it. The
root persistentScreens only drive the dontDetachScreen flag, so the new
shouldWrapPersistentScreens option opts them into wrapping anyway.
Wrapping whole root routes puts the entire app tree in a half-alive
state: a hidden Activity keeps rendering context updates at background
priority while its effects (and Onyx subscriptions) are unmounted. With
TabNavigator hidden, the LHN received new report IDs through context
with dead per-item subscriptions and crashed in the FlashList
keyExtractor. Per-navigator wrapping of individual screens stays; the
shouldWrapPersistentScreens option goes away with its only consumer.
A hidden Activity unmounts the effects of its subtree, so the
MutationObserver living in CustomViewWrapper's layout effect was
disconnected (with its pending records discarded) in the same commit
that applied React's inline 'display: none !important' - the override
never ran and hidden screens disappeared anyway. The observer is now
attached once through a callback ref, which runs during commit and
survives hide/show cycles, and is deliberately never disconnected so it
keeps enforcing 'display: contents !important' while the tree is
hidden.
A covered RHP route is not always fully covered: a wide RHP (e.g.
search/view expense report) stays visible behind the next card, renders
its own dimming overlay and keeps its width registered through
useRHPWidth. That registration lives in an effect whose cleanup
deregisters the route, and the wide RHP system treats the cleanup as the
screen closing. A hidden Activity unmounts exactly those effects while
the screen is still on stage, so expandedRHPProgress snapped to 0, the
navigator container clipped to the single RHP width and the covered wide
RHP looked like it lost its content. Modal-stack-level wrapping is
unaffected - both useRHPWidth consumers are direct RightModalNavigator
routes.
…ty on RHP routes

useRHPWidth stored the wide/super-wide registration in an effect whose
cleanup deregistered the route, treating effect unmount as the screen
closing. A hidden Activity unmounts effects while the screen is still in
the stack, which snapped expandedRHPProgress to 0 and clipped the RHP
container to the single width. The cleanup now checks the navigation
state first and deregisters only when the route is actually gone; on a
hide the registration survives and the reveal re-registers it
idempotently. With that fixed, RightModalNavigator routes are wrapped in
Activity again - a covered wide RHP stays painted at full width (dimmed
by the navigator-level overlay) and only stops updating while covered.
A screen can be invisible without being blurred inside its own
navigator: the search expense list stays the top route of
SearchFullscreenNavigator while an RHP covers it from the root stack.
ScreenActivityWrapper now also hides when useIsFocused reports false -
it is chain-aware, so losing focus anywhere up the tree deprioritizes
the covered screen. Persistent screens (split sidebars) are no longer
wrapped at all instead of being wrapped with a never-true blur flag,
so chain focus loss can never hide a screen that is visible alongside
the top one.
A screen that mounted while already covered started as a hidden Activity,
and React never mounts the effects of a hidden Activity, so the screen sat
in the stack without fetching its data, subscribing or measuring itself
until it was revealed. The first render now always goes through and the
screen is deprioritized one frame later, which matches how
ScreenFreezeWrapper mounts unfrozen and freezes afterwards. The flip races
requestAnimationFrame with a timeout because the frame loop is paused in
the background and in hidden browser tabs.

Deprioritized screens also render at background priority with no mounted
effects, so their layout goes stale when the window is resized or the
device is rotated and they catch up in front of the user on reveal.
windowSizeChangeStore watches Dimensions through a single app wide listener
and reveals every wrapped screen for 250 ms after the last change, so they
lay themselves out while still covered. Only width and orientation changes
count: the soft keyboard changes the window height on Android and mobile
web, and reacting to that would remount and clean up the effects of every
hidden screen on each keyboard toggle.

Both platform wrappers were identical apart from comments, so the mode
logic moved to useScreenActivityMode. Logging follows the navigation driven
state only, with the window size change logged once for all screens instead
of twice per screen.
A screen hidden by Activity has no mounted effects, so a route closed in
that state never ran its useRHPWidth cleanup and its registered width
survived forever. A navigation state listener in WideRHPContextProvider
now deregisters every registered route once it leaves the state,
counting preloaded routes as still present.

Also move useIsWindowSizeChanging into its own file so both React
Compiler toolchains agree on memoizing it.
…commit

Deriving the Activity mode directly from the navigation state put the
whole reveal (subtree re-render plus every effect re-mounting) into the
same synchronous commit as the pop, blocking the main thread for over
500ms before the browser could paint. Revealing now follows the
navigation state through useDeferredValue, so the pop commits cheaply
and the reveal runs in a later, interruptible render. The screen stays
painted throughout because CustomViewWrapper keeps hidden content
visible. Hiding stays urgent so a covered screen stops updating right
away.
Replace the useDeferredValue reveal with the pattern merged in
Expensify#96485: useDeferVisibleUntilFocusTransitionEnd (ported
verbatim from upstream) waits for TransitionTracker before showing a
hidden screen again. The reveal work no longer competes with the pop
animation at all, and a reveal overtaken by another navigation is
cancelled before it mounts anything. Hiding stays urgent.
…reen-activity-wrapper

# Conflicts:
#	src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/ScreenFreezeWrapper/index.native.tsx
Reanimated runs its web entering/exiting animations in class lifecycles,
which React re-invokes on every Activity hide and reveal. Expense rows
configured exiting FadeOutUp, so they replayed their exit and entrance on
every navigation over the search screen. The exit flow also reparents row
content into a detached clone while the screen is hidden.

The delete-expense exit animation is disabled together with the navigation
replay; it can come back once reanimated distinguishes a real unmount from
an Activity hide.
@codecov

codecov Bot commented Aug 3, 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 Δ
src/components/Search/ExpenseFlatSearchView.tsx 100.00% <ø> (ø)
src/components/Search/ExpenseGroupedSearchView.tsx 59.78% <ø> (-0.44%) ⬇️
...gation/AppNavigator/ModalStackNavigators/index.tsx 6.42% <100.00%> (ø)
...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% <ø> (ø)
...rmStackNavigation/createPlatformStackNavigator.tsx 100.00% <100.00%> (ø)
...orComponent/ScreenActivityWrapper/index.native.tsx 100.00% <100.00%> (ø)
...t/ScreenActivityWrapper/useIsWindowSizeChanging.ts 100.00% <100.00%> (ø)
... and 12 more
... and 65 files with indirect coverage changes

The behavior a covered screen uses (react-freeze, React <Activity> or
nothing) was baked into the navigator component at creation time, so a
whole stack shared one setting. It is a navigation option now, so a
navigator can pick it for all of its screens through screenOptions and a
single screen can override that choice through its own options. Screens
that pick nothing keep rendering as is, which makes the option a per
screen opt in rather than a per navigator one.

The option travels along the platform specific options, which is the
only channel a navigator can read it back from, and the rollout stays
where it was: the right modal, split, search and workspace navigators
keep it on their whole stack, and so do the modal stacks.
The wrapper keeps a covered screen painted, so its stale content stayed
reachable by keyboard. react-navigation already marks every unfocused card
with aria-hidden and pointer-events none, but neither of those touches the
tab order, so a covered screen could still be tabbed into.

Mark the painted content inert for as long as the screen is covered, the way
react-navigation does it in its ActivityView. On web the wrapper renders a
plain div, which is what makes inert available at all; on native it renders
the pair of views that upstream renders, the outer one neutralizing the
hiding and the inner one carrying aria-hidden and pointer events.

The flag follows the navigation state rather than the Activity mode, because
the reveal is deferred until the transition ends and the screen the user is
already looking at has to be usable right away. useIsScreenCovered owns that
state now and feeds both the mode and the accessibility flag.
Two helpers the Activity tests share. completeRevealTransition drives
TransitionTracker through one full cycle, which is what commits a reveal that
the wrapper deferred until the navigation transition ends.
mockWindowDimensionsChange replaces the Dimensions listener, so a test can
emit a resize or an orientation change without a real window.
The pieces that decide when a screen is deprioritized and what happens to the
content while it is: useIsScreenCovered reading the navigation state,
useScreenActivityMode turning it into a mode with the first render pass, the
deferred reveal and the resize reveal on top, and windowSizeChangeStore
behind the last of those.

CustomViewWrapper is covered per platform, because the two implementations
share nothing: the web one enforces display contents through a
MutationObserver, the native one through a view config, and both keep the
content painted while their Activity is hidden.
The web test started red, as the proof of EC-17, and holds the wrapper to the
contract that took its place: a covered screen keeps its content painted but
loses inert only when it stops being covered.

The second file is the one that runs on both real hooks and the real
TransitionTracker, replacing only the navigator, because it guards a timing
that a harness stubbing either hook cannot see: the accessibility state
follows the navigation state, while the Activity mode lags behind it until
the transition ends. Verified by mutation, both of its timing tests go red
when the flag is derived from the mode instead.
Three levels of the same decision. wrapDescriptorsWithNonTopScreensBehavior
picks a wrapper per screen from its own options and leaves persistent screens
alone. The rollout test scans the sources and pins down which navigators and
screens actually opted in, so the root stack staying out of it is a fact the
suite states rather than a comment.

The behavior test drives a real navigator and checks what covering a screen
does to it end to end: effects unmount and mount again on the reveal, state
survives, a screen covered from its first render still gets its mount frame,
and mounts stay balanced over repeated round trips.
Two complementary files. The risks one works on synthetic screens shaped like
the patterns the app repeats, a screen that loads once, one that drops its
data in a cleanup, one that measures itself, one with a repeating timer, so
the cost of a hide and a reveal is visible in isolation.

The regressions one proves the same edge cases on real app code and pins each
one to its EC number in the catalogue. Its 28 it.failing cases are deliberate:
they mark bugs that are open, so jest reports the day one of them starts
passing, and the marker comes off together with the catalogue entry.
Hiding a screen with Activity runs its effect cleanups, which for an RHP
screen means the cleanup that deregisters its width. The difference the tests
draw is between a screen that is only hidden and a route that is really gone:
a hidden screen keeps its registration and its width hint, because its route
is still in the navigation state, while a closed one gives both back and lets
the expanded RHP fold once no RHP is left above the root stack.
Reassure scenarios that pair every measurement with the same navigator built
without the wrapper, so the number that matters is the difference rather than
the absolute cost of the harness. They cover mounting a stack, covering
screens, updating a store the covered screens subscribe to, and a full cover
and reveal round trip.
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