perf(navigation): deprioritize covered screens with React Activity instead of react-freeze - #97666
Draft
dariusz-biela wants to merge 22 commits into
Draft
Conversation
… 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.
This was referenced Aug 3, 2026
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.
40 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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'replacesfreezeNonTopScreens; freeze stays available as a fallback. Rolled out to modal stacks, RightModal, SearchFullscreen, Workspace and Split navigators; the root stack keeps its current behavior.useScreenActivityModehides a screen when it is covered or its navigator chain loses focus: hiding is immediate, revealing waits for the transition to end (useDeferVisibleUntilFocusTransitionEnd).CustomViewWrapperneutralizes thedisplay: nonea hidden Activity commits (native view config, web MutationObserver), so card visibility decides what is on screen.Handled edge cases
setTimeoutfallback for backgrounded apps and hidden tabs).TransitionTrackertimeouts guarantee a reveal even when no transition starts, so a screen cannot get stuck hidden.Known issue: reanimated web animations replay under Activity
React re-invokes class component lifecycles on Activity hide/reveal, and reanimated runs its web
entering/exitinganimations exactly there, so search expense rows (configured withexiting: 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):Fixed Issues
$
PROPOSAL:
Tests
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.
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectionAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari