Skip to content

[No QA] perf(contextmenu): ContextMenu performance overhaul#83784

Draft
roryabraham wants to merge 22 commits intomainfrom
rory-contextmenu-perf
Draft

[No QA] perf(contextmenu): ContextMenu performance overhaul#83784
roryabraham wants to merge 22 commits intomainfrom
rory-contextmenu-perf

Conversation

@roryabraham
Copy link
Contributor

@roryabraham roryabraham commented Feb 28, 2026

Summary

Performance overhaul and composition refactor of the ContextMenu system. This PR:

  1. React Compiler compliance - Removes manual memoization (memo, useMemo, useCallback) and fixes ref-during-render violations in PopoverReportActionContextMenu and BaseReportActionContextMenu
  2. State consolidation - Replaces 7 useRef + 8 useState calls with a single PopoverContextMenuState object, eliminating the stale reportActionRef bug
  3. Deferred Onyx subscriptions - Extracts delete confirmation into ConfirmDeleteReportActionModal using the global modal system, so its Onyx subscriptions are only active when shown
  4. Singleton MiniReportActionContextMenu - Reduces ~N instances to 1, cutting Onyx subscriptions from ~1100 to 24 per report screen. Uses react-native-reanimated for smooth position animations and CSS transitions for opacity
  5. Composition refactor - Replaces the 23-action config array monolith in ContextMenuActions.tsx with individual dot-notation components (<ContextMenuAction.EmojiReaction>, etc.), each self-contained in its own file
  6. Lower lint warning budget - Reduces --max-warnings from 383 to 353

Key architectural changes:

  • ContextMenuPayloadProvider provides shared context for all action components
  • ContextMenuLayout handles visibility evaluation, mini-mode truncation, and focus management
  • MiniContextMenuProvider manages singleton mini-menu state with split action/state contexts
  • actionConfig.ts centralizes shouldShow predicates for all 23 actions
  • disabledActions: ContextMenuAction[] converted to disabledActionIds: Set<string> throughout

Fixed Issues

Performance optimization - no specific issue

Tests

Mini context menu (web only - singleton behavior):

  1. Open a chat with multiple messages
  2. Hover over a message - verify the mini context menu appears
  3. Move mouse to a different message - verify the menu smoothly animates to the new position
  4. Hover over the mini menu itself - verify it stays visible
  5. Move mouse away - verify it fades out after a short delay
  6. Scroll the message list - verify the mini menu hides immediately

Context menu actions (web - right click, mobile - long press):

  1. Right-click a message - verify the full context menu appears with appropriate actions
  2. Click "Reply in thread" - verify navigation to the thread
  3. Right-click a message, click "Mark as unread" - verify the message is marked unread
  4. Right-click a message, click "Copy link" - verify the link is copied to clipboard
  5. Right-click a message, click "Copy message" - verify message content is copied
  6. Right-click a pinned chat header, click "Unpin" - verify the chat is unpinned
  7. Right-click an unpinned chat header, click "Pin" - verify the chat is pinned
  8. Right-click a message with an attachment, click "Download" - verify download starts

Delete action (deferred modal):

  1. Right-click your own message, click "Delete"
  2. Verify the confirmation modal appears with correct message preview
  3. Click "Delete" in the modal - verify the message is deleted
  4. Right-click another message, click "Delete", then click "Cancel" - verify the message is NOT deleted

Emoji reactions (mini menu):

  1. Hover over a message on web, click an emoji reaction in the mini menu
  2. Verify the reaction is added to the message
  3. Click the emoji picker icon in the mini menu - verify the emoji picker opens

Edit action:

  1. Right-click your own message, click "Edit"
  2. Verify the message enters edit mode with the compose box pre-filled
  3. Edit the message and save - verify the message updates

Hold/Unhold (requires money request):

  1. Create a money request in a workspace with approvals enabled
  2. Right-click the money request, verify "Hold" appears
  3. Click "Hold" - verify the expense is held
  4. Right-click again, verify "Unhold" appears

Overflow menu (mini mode):

  1. Hover over a message that has many applicable actions (e.g., your own message with a thread)
  2. Verify only 4 items show in the mini menu (including the "..." overflow)
  3. Click the "..." overflow button - verify the full context menu opens

Arrow key navigation (full context menu):

  1. Right-click a message to open the full context menu
  2. Press Up/Down arrow keys - verify focus moves between menu items
  3. Press Enter on a focused item - verify the action executes

QA Steps

Same as tests

Offline tests

N/A - Context menus are UI-only and do not initiate network requests directly

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 googly error message is displayed)
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & Desktop and verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
    • MacOS: Desktop
  • 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 Checklist)
  • I added any necessary analytics
  • I added any necessary migrations
  • 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 tested other components which use that component)
  • I verified the correct display on different screen sizes (Mobile, Tablet, Desktop)

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
Copy link

codecov bot commented Feb 28, 2026

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 Δ
...ox/report/ContextMenu/actions/ContextMenuAction.ts 100.00% <100.00%> (ø)
.../report/ContextMenu/ContextMenuPayloadProvider.tsx 33.33% <33.33%> (ø)
...nbox/report/ContextMenu/ReportActionContextMenu.ts 31.57% <0.00%> (ø)
src/pages/inbox/ReportScreen.tsx 69.50% <63.63%> (ø)
src/pages/inbox/report/PureReportActionItem.tsx 54.27% <27.27%> (-0.61%) ⬇️
.../inbox/report/ContextMenu/actions/OverflowMenu.tsx 0.00% <0.00%> (ø)
...box/report/ContextMenu/MiniContextMenuProvider.tsx 37.50% <37.50%> (ø)
...ages/inbox/report/ContextMenu/actions/CopyLink.tsx 0.00% <0.00%> (ø)
.../inbox/report/ContextMenu/actions/CopyOnyxData.tsx 0.00% <0.00%> (ø)
...box/report/ContextMenu/actions/CopyToClipboard.tsx 0.00% <0.00%> (ø)
... and 25 more
... and 35 files with indirect coverage changes

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
@melvin-bot
Copy link

melvin-bot bot commented Feb 28, 2026

npm has a package.json file and a package-lock.json file. It seems you updated one without the other, which is usually a sign of a mistake. If you are updating a package make sure that you update the version in package.json then run npm install

@roryabraham

This comment has been minimized.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

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
@roryabraham

This comment has been minimized.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

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
@roryabraham

This comment has been minimized.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

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
@roryabraham

This comment has been minimized.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

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
@roryabraham

This comment has been minimized.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

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
@roryabraham

This comment has been minimized.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

…ctions

The old code centrally wrapped all action onPress handlers with
interceptAnonymousUser in BaseReportActionContextMenu. The composition
refactor moved onPress to individual components but some non-anonymous
actions (EmojiReaction, MarkAsUnread, Explain, MarkAsRead, Edit,
Pin, Unpin) lost this guard, allowing anonymous users to execute
restricted handlers instead of being redirected to sign in.

Made-with: Cursor
@roryabraham

This comment has been minimized.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

@roryabraham roryabraham changed the title [No QA] refactor(contextmenu): ContextMenu performance overhaul [No QA] perf(contextmenu): ContextMenu performance overhaul Feb 28, 2026
Move visibility evaluation and focus management from ContextMenuLayout
into BaseReportActionContextMenu. The parent now conditionally renders
each action and passes isFocused/onFocus/onBlur as explicit props.

ContextMenuLayout is simplified to a pure wrapper (FocusTrap + View).

Made-with: Cursor
…ContextMenu

ContextMenuLayout was a thin wrapper after the previous refactor. Inline
its FocusTrap, View, and styles directly into BaseReportActionContextMenu
and delete the file.

Made-with: Cursor
Move the disabled action IDs computation from PureReportActionItem into
BaseReportActionContextMenu, which already subscribes to the report via
Onyx. This eliminates an unnecessary prop threaded through 6 intermediate
components (MiniContextMenuProvider, MiniReportActionContextMenu,
PopoverReportActionContextMenu, ReportActionContextMenu, OverflowMenu).
Also renames disabledActionIds to disabledActionIDs per code style.

Made-with: Cursor
Move isChronosReport, isArchivedRoom, isPinnedChat, isUnreadChat, and
isThreadReportParentAction computations from callers into
BaseReportActionContextMenu, which already subscribes to the report via
Onyx. This eliminates unnecessary prop threading through
MiniContextMenuProvider, PopoverReportActionContextMenu,
PureReportActionItem, ShowContextMenuContext, and their callers.

Made-with: Cursor
Convert all 22 action components to headless hooks returning
ActionDescriptor data, letting MiniReportActionContextMenu and
PopoverReportActionContextMenu each render their own UI wrappers.

- Extract shared Onyx subscriptions into useContextMenuData hook
- Create useContextMenuActions aggregator for all action hooks
- Add hideAndRun to ContextMenuPayloadContext for mode-agnostic close
- Remove isMini from ContextMenuPayloadContextValue, actionConfig
  ShouldShowArgs, and ContextMenuItem
- Delete BaseReportActionContextMenu (logic distributed to consumers)
- Delete stale types.ts and ReportActionContextMenuContent.tsx

Made-with: Cursor
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