Skip to content

[Due for payment 2026-05-11] Lazy-load PopoverReportActionContextMenu to reduce app startup time #88622

@MelvinBot

Description

@MelvinBot

Background

The Expensify App renders GlobalModals at the top level during startup. GlobalModals eagerly imports and mounts several modal components, including PopoverReportActionContextMenu — the context menu that appears when a user long-presses a report action.

PopoverReportActionContextMenu imports BaseReportActionContextMenu, which imports ContextMenuActions (1,386 lines), ModifiedExpenseMessage, and multiple functions from ReportUtils (17,000+ lines). ReportUtils in turn imports BankAccounts.ts (1,719 lines) and other action modules. This entire dependency chain is evaluated synchronously during startup because JavaScript module loading is recursive — importing one module forces all its transitive dependencies to be evaluated immediately.

The context menu ref API (ReportActionContextMenu.ts) already handles null refs gracefully — every method checks if (!contextMenuRef.current) return; before proceeding. The context menu component itself is invisible until a user actively long-presses a report action, which cannot happen during startup.

Problem

When the app starts, GlobalModals renders PopoverReportActionContextMenu eagerly, so the entire context menu dependency chain (ContextMenuActions, ReportUtils, ModifiedExpenseMessage, BankAccounts, etc.) is evaluated during the ManualAppStartup span, adding hundreds of milliseconds of blocking JS evaluation for a component that is invisible and cannot be interacted with until well after startup completes.

Proposed Solution

  1. Replace the static import of PopoverReportActionContextMenu in GlobalModals.tsx with React.lazy(), and defer rendering until after startup using requestIdleCallback with a {timeout: 2000} cap.
  2. Wrap the component in a Suspense boundary with a null fallback (since it is an invisible modal).
  3. Use a state flag (shouldRenderContextMenu) that flips to true once the browser reports an idle period (or within 2 seconds at most), ensuring the heavy dependency chain loads after the ManualAppStartup span ends.
  4. Consider also moving the component to AuthScreens (these solutions are not mutually exclusive — moving to AuthScreens would help unauthenticated sessions).
  5. Consider polyfilling requestIdleCallback for Safari < 16.4. Note: requestIdleCallback is available in React Native (docs).

By the time a user can realistically long-press a report action, the component and its dependencies are already loaded and the ref is available.

Benchmarks (Web, 10 trials each, authenticated cold start)

Metric Before After Delta
Avg 3906ms 3717ms -189ms (-4.8%)
P50 3674ms 3676ms flat
P75 4133ms 3761ms -372ms (-9.0%)
P90 4755ms 3976ms -779ms (-16.4%)
Max 4755ms 3976ms -779ms

Related

Slack thread: https://expensify.slack.com/archives/C05LX9D6E07/p1776954171771989

Issue OwnerCurrent Issue Owner: @daledah

Metadata

Metadata

Labels

BugSomething is broken. Auto assigns a BugZero manager.WeeklyKSv2

Type

No type
No fields configured for issues without a type.

Projects

Status

SUBISSUE

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions