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
- 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.
- Wrap the component in a
Suspense boundary with a null fallback (since it is an invisible modal).
- 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.
- Consider also moving the component to
AuthScreens (these solutions are not mutually exclusive — moving to AuthScreens would help unauthenticated sessions).
- 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 Owner
Current Issue Owner: @daledah
Background
The Expensify App renders
GlobalModalsat the top level during startup.GlobalModalseagerly imports and mounts several modal components, includingPopoverReportActionContextMenu— the context menu that appears when a user long-presses a report action.PopoverReportActionContextMenuimportsBaseReportActionContextMenu, which importsContextMenuActions(1,386 lines),ModifiedExpenseMessage, and multiple functions fromReportUtils(17,000+ lines).ReportUtilsin turn importsBankAccounts.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 checksif (!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,
GlobalModalsrendersPopoverReportActionContextMenueagerly, so the entire context menu dependency chain (ContextMenuActions,ReportUtils,ModifiedExpenseMessage,BankAccounts, etc.) is evaluated during theManualAppStartupspan, 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
PopoverReportActionContextMenuinGlobalModals.tsxwithReact.lazy(), and defer rendering until after startup usingrequestIdleCallbackwith a{timeout: 2000}cap.Suspenseboundary with anullfallback (since it is an invisible modal).shouldRenderContextMenu) that flips totrueonce the browser reports an idle period (or within 2 seconds at most), ensuring the heavy dependency chain loads after theManualAppStartupspan ends.AuthScreens(these solutions are not mutually exclusive — moving toAuthScreenswould help unauthenticated sessions).requestIdleCallbackfor Safari < 16.4. Note:requestIdleCallbackis 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)
Related
Slack thread: https://expensify.slack.com/archives/C05LX9D6E07/p1776954171771989
Issue Owner
Current Issue Owner: @daledah