Skip to content

Commit 58141ba

Browse files
authored
Merge pull request Expensify#88331 from callstack-internal/eliran-lazy-load-context-menu
lazy load context menu
2 parents abbaa5e + 87ae385 commit 58141ba

5 files changed

Lines changed: 101 additions & 5 deletions

File tree

index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
// import of polyfills should always be first
55
import './src/polyfills/PromiseWithResolvers';
6+
import './src/polyfills/requestIdleCallback';
67
import {AppRegistry} from 'react-native';
78
import App from './src/App';
89
import Config from './src/CONFIG';

jest/setup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type Animated from 'react-native-reanimated';
1313
import 'setimmediate';
1414
import {TextDecoder, TextEncoder} from 'util';
1515
import '@src/polyfills/PromiseWithResolvers';
16+
import '@src/polyfills/requestIdleCallback';
1617
import mockFSLibrary from './setupMockFullstoryLib';
1718
import setupMockImages from './setupMockImages';
1819

src/GlobalModals.tsx

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from 'react';
1+
import React, {Suspense, useEffect, useState} from 'react';
22
import DelegateNoAccessModalProvider from './components/DelegateNoAccessModalProvider';
33
import EmojiPicker from './components/EmojiPicker/EmojiPicker';
44
import GrowlNotification from './components/GrowlNotification';
@@ -7,21 +7,47 @@ import ScreenShareRequestModal from './components/ScreenShareRequestModal';
77
import UpdateAppModal from './components/UpdateAppModal';
88
import * as EmojiPickerAction from './libs/actions/EmojiPickerAction';
99
import {growlRef} from './libs/Growl';
10-
import PopoverReportActionContextMenu from './pages/inbox/report/ContextMenu/PopoverReportActionContextMenu';
1110
import * as ReportActionContextMenu from './pages/inbox/report/ContextMenu/ReportActionContextMenu';
1211

12+
const LazyPopoverReportActionContextMenu = React.lazy(() => import('./pages/inbox/report/ContextMenu/PopoverReportActionContextMenu'));
13+
14+
// Maximum time (ms) the context menu mount can stay deferred before requestIdleCallback forces it to run,
15+
// guaranteeing mount even if the main thread never becomes idle.
16+
const IDLE_CALLBACK_TIMEOUT_MS = 2000;
17+
1318
/**
1419
* Renders global modals and overlays that are mounted once at the top level.
1520
*/
1621
function GlobalModals() {
22+
const [shouldRenderContextMenu, setShouldRenderContextMenu] = useState(false);
23+
24+
useEffect(() => {
25+
// Defer loading the context menu until after startup to avoid pulling in heavy
26+
// dependencies (ContextMenuActions, ReportUtils, ModifiedExpenseMessage, etc.)
27+
// during the ManualAppStartup span.
28+
const id = requestIdleCallback(() => setShouldRenderContextMenu(true), {timeout: IDLE_CALLBACK_TIMEOUT_MS});
29+
30+
// Allow showContextMenu() to force eager mount if the user interacts before the idle callback fires.
31+
ReportActionContextMenu.registerEnsureContextMenuMounted(() => setShouldRenderContextMenu(true));
32+
33+
return () => {
34+
cancelIdleCallback(id);
35+
ReportActionContextMenu.registerEnsureContextMenuMounted(null);
36+
};
37+
}, []);
38+
1739
return (
1840
<>
1941
<UpdateAppModal />
2042
{/* Those below are only available to the authenticated user. */}
2143
<GrowlNotification ref={growlRef} />
2244
<DelegateNoAccessModalProvider>
23-
{/* eslint-disable-next-line react-hooks/refs -- module-level createRef, safe to pass as ref prop */}
24-
<PopoverReportActionContextMenu ref={ReportActionContextMenu.contextMenuRef} />
45+
{shouldRenderContextMenu && (
46+
<Suspense fallback={null}>
47+
{/* eslint-disable-next-line react-hooks/refs -- module-level createRef, safe to pass as ref prop */}
48+
<LazyPopoverReportActionContextMenu ref={ReportActionContextMenu.contextMenuRef} />
49+
</Suspense>
50+
)}
2551
</DelegateNoAccessModalProvider>
2652
{/* eslint-disable-next-line react-hooks/refs -- module-level createRef, safe to pass as ref prop */}
2753
<EmojiPicker ref={EmojiPickerAction.emojiPickerRef} />

src/pages/inbox/report/ContextMenu/ReportActionContextMenu.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,20 @@ type ReportActionContextMenu = {
7171

7272
const contextMenuRef = React.createRef<ReportActionContextMenu>();
7373

74+
// Retry budget for waiting on the lazy-mounted popover ref.
75+
// 10 attempts × ~16ms ≈ 160ms total, enough to cover React.lazy chunk load + commit
76+
// on a cold start without blocking user interaction for long.
77+
const MAX_CONTEXT_MENU_MOUNT_RETRIES = 10;
78+
const CONTEXT_MENU_MOUNT_RETRY_INTERVAL_MS = 16;
79+
80+
// Bridge used when PopoverReportActionContextMenu is lazy-mounted: lets showContextMenu
81+
// trigger eager mount if the user interacts before the idle-deferred mount runs.
82+
let ensureContextMenuMounted: (() => void) | null = null;
83+
84+
function registerEnsureContextMenuMounted(handler: (() => void) | null) {
85+
ensureContextMenuMounted = handler;
86+
}
87+
7488
/**
7589
* Hide the ReportActionContextMenu modal popover.
7690
* Hides the popover menu with an optional delay
@@ -128,6 +142,20 @@ function hideContextMenu(shouldDelay?: boolean, onHideCallback = () => {}, param
128142
*/
129143
function showContextMenu(showContextMenuParams: ShowContextMenuParams) {
130144
if (!contextMenuRef.current) {
145+
// Popover is lazy-mounted; trigger eager mount and retry until the ref is populated
146+
// so a fast cold-start interaction isn't silently dropped.
147+
ensureContextMenuMounted?.();
148+
let retries = MAX_CONTEXT_MENU_MOUNT_RETRIES;
149+
const attempt = () => {
150+
if (contextMenuRef.current) {
151+
showContextMenu(showContextMenuParams);
152+
return;
153+
}
154+
if (retries-- > 0) {
155+
setTimeout(attempt, CONTEXT_MENU_MOUNT_RETRY_INTERVAL_MS);
156+
}
157+
};
158+
attempt();
131159
return;
132160
}
133161
const show = () => {
@@ -182,5 +210,5 @@ function clearActiveReportAction() {
182210
return contextMenuRef.current.clearActiveReportAction();
183211
}
184212

185-
export {contextMenuRef, showContextMenu, hideContextMenu, isActiveReportAction, clearActiveReportAction, showDeleteModal, hideDeleteModal};
213+
export {contextMenuRef, showContextMenu, hideContextMenu, isActiveReportAction, clearActiveReportAction, showDeleteModal, hideDeleteModal, registerEnsureContextMenuMounted};
186214
export type {ContextMenuType, ReportActionContextMenu, ContextMenuAnchor};
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* Polyfill for requestIdleCallback / cancelIdleCallback.
3+
* https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback
4+
*
5+
* Falls back to setTimeout/clearTimeout where the API is unavailable (e.g. Safari < 16.4).
6+
* The fallback runs the callback on the next tick rather than during true idle time —
7+
* not perfect, but good enough to keep work off the synchronous render path on platforms
8+
* that lack native idle scheduling.
9+
*/
10+
type GlobalWithIdleCallback = typeof globalThis & {
11+
requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number;
12+
cancelIdleCallback?: (handle: number) => void;
13+
};
14+
15+
const g = globalThis as GlobalWithIdleCallback;
16+
17+
if (typeof g.requestIdleCallback !== 'function') {
18+
Object.defineProperty(g, 'requestIdleCallback', {
19+
value(callback: IdleRequestCallback): number {
20+
return setTimeout(() => {
21+
callback({
22+
didTimeout: false,
23+
timeRemaining: () => 0,
24+
});
25+
}, 1) as unknown as number;
26+
},
27+
configurable: true,
28+
writable: true,
29+
});
30+
}
31+
32+
if (typeof g.cancelIdleCallback !== 'function') {
33+
Object.defineProperty(g, 'cancelIdleCallback', {
34+
value(handle: number) {
35+
clearTimeout(handle as unknown as ReturnType<typeof setTimeout>);
36+
},
37+
configurable: true,
38+
writable: true,
39+
});
40+
}

0 commit comments

Comments
 (0)