[WIP] perf: ContextMenu performance overhaul#83784
[WIP] perf: ContextMenu performance overhaul#83784roryabraham wants to merge 99 commits intomainfrom
Conversation
Replace 7 refs + 8 useState calls with a single PopoverContextMenuState object. Keep separate isPopoverVisible boolean for animation control: hideContextMenu sets isPopoverVisible=false (triggering animation), onModalHide clears menuState=null (clearing data after animation). Consolidate popoverAnchorPosition + contextMenuDimensions into PopoverPosition within the state object. Eliminate reportActionRef entirely (latent staleness bug -- only set in showDeleteModal, never in showContextMenu). Store only reportActionID in consolidated state. Derive full action from reportActions[reportActionID]. Move onEmojiPickerToggle from ref to state to avoid accessing refs during render. Remove all useCallback wrappers and inline the logic. Made-with: Cursor
…nContextMenu
Remove memo(deepEqual) wrapper and all useMemo calls (5 total).
React Compiler handles memoization automatically. Replace inline
{current: null} with stable nullRef to avoid new object creation
per render. Inline all computed values directly.
Made-with: Cursor
Extract delete confirmation flow from PopoverReportActionContextMenu into a standalone ConfirmDeleteReportActionModal component that mounts via the established global modal system (useModal/ModalProvider). The new component owns all ~16 delete-related Onyx subscriptions, which are only active when the delete modal is actually shown. This eliminates 5 duplicate subscriptions with BaseReportActionContextMenu and defers the remaining ~11 until actually needed. The promise-based API replaces 3 callback refs + 2 state vars from PopoverReportActionContextMenu. The shouldSetModalVisibility parameter is dropped as the global modal system manages visibility independently. Made-with: Cursor
Codecov Report✅ Changes either increased or maintained existing code coverage, great job!
|
Create MiniContextMenuProvider with split contexts (Actions/State) to avoid unnecessary re-renders in list items. The provider manages show/hide with 120ms delay, shouldKeepOpen/pendingHide for emoji picker flow, and stable action references via useState lazy init. Rewrite MiniReportActionContextMenu as an animated singleton rendered via createPortal to document.body for reliable position:fixed. Uses Reanimated shared values for animated row-to-row slides with overshoot easing, and CSS transitions for opacity fade. PureReportActionItem now measures its row via getBoundingClientRect on hover and calls showMiniContextMenu instead of rendering its own MiniReportActionContextMenu instance. This eliminates ~1100 Onyx subscriptions (24 per visible item). Made-with: Cursor
In mini mode, BaseReportActionContextMenu now uses the provider's keepOpen()/release() API for emoji picker and overflow menu flows. Non-mini (popover) mode retains local state. This ensures the singleton stays visible when submenus are open. Made-with: Cursor
Add a scroll event listener (capture phase) to dismiss the mini context menu when the list scrolls. The menu reappears at the correct position on the next hover via fresh getBoundingClientRect. Made-with: Cursor
Add ContextMenuPayloadProvider (shared context for all action components), ContextMenuLayout (visibility evaluation, mini-mode truncation, arrow key focus), and actionConfig (shouldShow registry with ordered action IDs). Foundation for converting the config array into individual dot-notation components. Made-with: Cursor
Replace the config array .filter().map() loop in BaseReportActionContextMenu with ContextMenuPayloadProvider, ContextMenuLayout, and individual dot-notation action components. Convert disabledActions (ContextMenuAction[]) to disabledActionIds (Set<string>) throughout the chain: PopoverReportActionContextMenu, MiniContextMenuProvider, ReportActionContextMenu, PureReportActionItem. Use Reanimated .get()/.set() API for shared values. Fix import alias in actionConfig. Made-with: Cursor
Organize component internals: hooks, derived values, callbacks, effects, render. Fix deprecated getReportNameDeprecated usage (add eslint-disable), fix no-default-id-values errors in Debug, Delete, and Explain. Use Reanimated .get()/.set() API. Fix import aliases for @pages prefix. Clean up unused imports. Reduce lint warning budget from 383 to 353 (30 warnings eliminated). Made-with: Cursor
|
npm has a |
This comment has been minimized.
This comment has been minimized.
Wire hideDeleteModal to call modalContext.closeModal() so the delete confirmation modal is dismissed when a report action item unmounts while the modal is open (e.g., navigating away). Made-with: Cursor
This comment has been minimized.
This comment has been minimized.
When the delete target is a money request, the effectiveReportID can be the IOU report ID, but the action lives in the chat report's REPORT_ACTIONS collection. Pass actionSourceReportID to the modal and fall back to it when the action isn't found in the primary collection. Made-with: Cursor
The positioning useEffect had no dependency array, causing it to run after every render. Scope it to state changes so animations only trigger when row measurements or visibility actually change. Made-with: Cursor
This comment has been minimized.
This comment has been minimized.
In mini-menu flows the popover menuState is unset, so deriving the source report from menuState?.reportID yields undefined. Pass the source report ID explicitly from Delete.tsx through the showDeleteModal interface so the modal can always resolve the action. Also guard hideDeleteModal with a ref so it only closes the modal when showDeleteModal actually opened one, preventing unrelated modals from being dismissed during navigation/unmount cleanup. Made-with: Cursor
This comment has been minimized.
This comment has been minimized.
The scroll handler repeatedly called hideMiniContextMenu() which uses
a 120ms debounced timer, causing the menu to stay visible while
scrolling. Add an {immediate: true} option to bypass the timer for
scroll-triggered hides.
Made-with: Cursor
This comment has been minimized.
This comment has been minimized.
The old code passed filteredContextMenuActions as disabledOptions when opening the overflow popover, hiding actions already visible in the mini row. Restore this behavior by passing the current visibleActionIds from ContextMenuLayout as disabledActionIds to the overflow menu. Made-with: Cursor
This comment has been minimized.
This comment has been minimized.
The file uses JSX but only imported named exports from 'react', causing "React is not defined" crash when opening the delete confirmation modal. Made-with: Cursor
hideAndRun only called release() but never initiated a hide, so the menu stayed visible. When navigating to a thread the old ReportScreen stays mounted and its Portal content leaked into the new screen's PortalHost. Two fixes: - hideAndRun now calls hideMiniContextMenu() after release() - Return null when !isVisible so no Portal content exists to leak Made-with: Cursor
Made-with: Cursor # Conflicts (all resolved): # BaseReportActionContextMenu.tsx - deleted (currentUserAccountID ported to holdAction/unholdAction) # ContextMenuActions.tsx - deleted (hold/unhold currentUserAccountID, REIMBURSED copy, flag-as-offensive dynamic routes, UPDATE_DESCRIPTION setClipboardMessage ported to individual action files) # PopoverReportActionContextMenu.tsx - deleted (currentUserEmail ported to ConfirmDeleteReportActionModal) # PureReportActionItem.tsx - kept our MiniContextMenuProvider import, added main's new actionContents imports
|
going to start resolving bug reports as they are fixed. Will also be updating the PR description to add testing steps specific to each bug report. |
…fter Portal mount When isVisible transitions from false to true, the @gorhom/portal library inserts content asynchronously via a state update in the PortalHost. The existing useLayoutEffect fires before the Portal content is in the DOM, so overlayRef.current is null and containerRect is never set — leaving the menu at opacity: 0. A callback ref fires exactly when the DOM element is attached, regardless of Portal timing. The useLayoutEffect is kept for subsequent rowMeasurements changes when the element is already mounted. Made-with: Cursor
When a component mounts under a stationary cursor (e.g. after navigation),
mouseenter doesn't fire because the cursor didn't physically enter the
element. Add a useEffect that checks element.matches(':hover') on mount
to handle this case. The same :hover technique is already used in the
scroll handler within the same file.
Made-with: Cursor
No changes to menu options were intentional. I'll look into them but if you can call out specifically what's different that would be helpful |
The mini context menu is now rendered via a Portal, placing it at the end of the DOM order. This broke keyboard navigation since Tab from a hovered report action would skip the menu entirely. Bridge focus by intercepting Tab/Shift+Tab between the row and the Portal-rendered menu container via keydown listeners. Made-with: Cursor
Made-with: Cursor # Conflicts: # src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx
Adds a jest UI test that renders PopoverReportActionContent and PopoverReportContent against seeded Onyx data and asserts the resulting items appear in the expected sequence (keyed off sentryLabel, already present on every item). The ordering is intentionally hardcoded in the test file so any accidental reshuffling — from the composition refactor or future edits — trips the assertion. Scenarios covered: - Plain comment by another user (Join thread appears because the current user is not subscribed to the thread). - Current user's own comment (Edit and Delete appear; Leave thread appears because the creator is auto-subscribed). - No reportAction supplied (only the overflow Menu is rendered). - Report-level menu on a read, unpinned chat (Mark as unread + Pin). - Report-level menu on an unread, pinned chat (Mark as read + Unpin). Made-with: Cursor
- ActiveHoverable: use early return in the :hover-on-mount effect to satisfy rulesdir/prefer-early-return. - MiniReportActionContextMenu: depend on the anchor RefObject itself instead of anchor?.current, avoiding ref access during render. Swap the react-hooks/exhaustive-deps disable (which made React Compiler skip the component) for prefer-narrow-hook-dependencies. - ConfirmDeleteReportActionModal: update reportActionsRef inside a useEffect instead of during render. - ContextMenuOrderingTest: stop importing the deprecated ReactTestInstance type from react-test-renderer; infer from @testing-library/react-native's RenderResult instead. Made-with: Cursor
showMiniContextMenu() set shouldKeepOpenRef=true to guard against the row→menu hover transition, but the only release path was the menu's own onHoverOut. When the cursor moved from the row straight to outside (the common case), hideMiniContextMenu() always deferred, release() never ran, and the menu stayed visible indefinitely. The stuck isVisible=true also blocked onMenuHide from firing, so PureReportActionItem's isContextMenuActive state couldn't clear, keeping the row highlighted. Replace the keep-open-on-show guard with a cancellable setTimeout: - hideMiniContextMenu() schedules the actual hide after an 80ms grace period so the menu's own onHoverIn has a chance to cancel it via keepOpen() during the row→menu transition. - showMiniContextMenu() cancels any pending hide, so rapid row-to-row hovers keep the menu visible. - keepOpen/release remain as an explicit lock for sub-interactions (overflow popover, emoji picker) that need the menu pinned regardless of hover state. Fixes three linked regressions reported on the PR: - reaction bar stays visible after the cursor leaves the row, - the row's highlighted state never clears (both instances). Made-with: Cursor
The global scroll listener used capture-phase window subscription and hid the menu for every scroll event. Horizontal-scrolling descendants of the row — notably the expense carousel inside a report preview — would bubble scroll events up and kick off an unwanted hide mid-swipe. On main this wasn't an issue because each row rendered its own menu inside its subtree. Skip scrolls whose target is contained by the anchor element. Only ancestor scrolls (the chat list itself) move the row, so only those need to dismiss the menu. Made-with: Cursor
The listener-attach useEffect depended on a ref populated by a callback ref on the menu's container View. @gorhom/portal mounts that View asynchronously via a state update in the PortalHost, so by the time our effect ran, localMenuContainerRef.current was still null and the blur/ Shift+Tab listeners never bound. Tab still worked because the handler lives on the (non-Portal) report action row, but Shift+Tab fell back to the browser default and focused the previous element in DOM order instead of returning to the row. Track the container as state (in addition to a ref for cases where a stale closure would be fine). Setting state in the callback ref triggers a re-render after the Portal commit, which re-runs the effect with the now-attached element so the listeners bind correctly. Made-with: Cursor
|
@huult this is ready for more testing |
Screen.Recording.2026-04-20.at.09.31.04.movScreen.Recording.2026-04-20.at.09.31.44.movHighlighted message does not unhighlight when out of focus (Bug not resovled). |
Screen.Recording.2026-04-20.at.09.35.28.movDifferent behavior from staging when opening the options modal |
|
@roryabraham Could you check the bugs above? |
|
@roryabraham please tag me when this PR is ready for review again. Thank you! |
|
@roryabraham Please resolve the conflict. |
|
@roryabraham How’s this PR going? |
|
@roryabraham Thanks for the update. |
|
Created #89683 |

Summary
Performance overhaul of the ContextMenu system. Converts per-row
MiniReportActionContextMenuinstances into a singleton rendered via@gorhom/portal, eliminating Onyx subscriptions and reducing the subtree size per visible report action.Architecture changes
ContextMenuActions.tsxwith individual files underactions/, each self-contained with its ownshouldShowpredicate.useRef+ 8useStatecalls inPopoverReportActionContextMenuwith a singlePopoverContextMenuStateobjectConfirmDeleteReportActionModalvia global modal system, so its ~16 Onyx subscriptions are only active when shownmemo/useMemo/useCallbackwrappersPerformance results
Compared via React Profiler traces on the same account with similar interaction patterns (~9K
PureReportActionItemrenders each, ~5s sessions):Overall:
Context menu components:
Non-context-menu render time is mostly flat (4,777ms → 4,823ms).
For context, the original profile that motivated this work (#83774) showed context menu components at 6.6% of total render time.
Fixed Issues
#83774
Tests
Mini context menu (web — singleton behavior)
Full context menu (web right-click, mobile long-press)
Delete action
Delete action does not crash (regression fix)
React is not defined) instead of the confirmation modalReply in Thread hides mini menu (regression fix)
First hover after Reply in Thread shows mini menu (regression fix)
Tab keyboard navigation focuses mini context menu (regression fix)
Emoji reactions (mini menu)
Edit action
Overflow menu
Offline tests
N/A — context menus are UI-only
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectioncanBeMissingparam foruseOnyxtoggleReportand notonIconClick)src/languages/*files and using the translation methodSTYLE.md) were followedAvatar, 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.ScrollViewcomponent to make it scrollable when more elements are added to the page.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps./** comment above it */thisproperly so there are no scoping issues (i.e. foronClick={this.submit}the methodthis.submitshould be bound tothisin the constructor)thisare necessary to be bound (i.e. avoidthis.submit = this.submit.bind(this);ifthis.submitis never passed to a component event handler likeonClick)Screenshots/Videos
Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari