Skip to content

Commit 75b496e

Browse files
authored
Merge pull request Expensify#89180 from callstack-internal/eliran-lazy-load-global-modals
perf: lazy-load rare-trigger global modals to shrink ManualAppStartup
2 parents 4a6fd66 + e0a21b4 commit 75b496e

2 files changed

Lines changed: 65 additions & 21 deletions

File tree

src/GlobalModals.tsx

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1-
import React, {Suspense, useEffect, useState} from 'react';
1+
import React, {startTransition, useEffect, useState} from 'react';
22
import DelegateNoAccessModalProvider from './components/DelegateNoAccessModalProvider';
33
import EmojiPicker from './components/EmojiPicker/EmojiPicker';
44
import GrowlNotification from './components/GrowlNotification';
5-
import ProactiveAppReviewModalManager from './components/ProactiveAppReviewModalManager';
6-
import ScreenShareRequestModal from './components/ScreenShareRequestModal';
7-
import UpdateAppModal from './components/UpdateAppModal';
5+
import LazyModalSlot from './components/LazyModalSlot';
86
import * as EmojiPickerAction from './libs/actions/EmojiPickerAction';
97
import {growlRef} from './libs/Growl';
108
import * as ReportActionContextMenu from './pages/inbox/report/ContextMenu/ReportActionContextMenu';
119

1210
const LazyPopoverReportActionContextMenu = React.lazy(() => import('./pages/inbox/report/ContextMenu/PopoverReportActionContextMenu'));
11+
const LazyUpdateAppModal = React.lazy(() => import('./components/UpdateAppModal'));
12+
const LazyScreenShareRequestModal = React.lazy(() => import('./components/ScreenShareRequestModal'));
13+
const LazyProactiveAppReviewModalManager = React.lazy(() => import('./components/ProactiveAppReviewModalManager'));
1314

1415
// Maximum time (ms) the context menu mount can stay deferred before requestIdleCallback forces it to run,
1516
// guaranteeing mount even if the main thread never becomes idle.
@@ -20,40 +21,60 @@ const IDLE_CALLBACK_TIMEOUT_MS = 2000;
2021
*/
2122
function GlobalModals() {
2223
const [shouldRenderContextMenu, setShouldRenderContextMenu] = useState(false);
24+
const [shouldRenderDeferredModals, setShouldRenderDeferredModals] = useState(false);
2325

26+
// Defer loading the context menu and rare-condition modals until after startup to avoid
27+
// pulling in their dependencies (ContextMenuActions, ReportUtils, ModifiedExpenseMessage,
28+
// ProactiveAppReviewModal, etc.) and their useOnyx subscriptions during the ManualAppStartup span.
2429
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});
30+
const id = requestIdleCallback(
31+
() => {
32+
startTransition(() => {
33+
setShouldRenderContextMenu(true);
34+
setShouldRenderDeferredModals(true);
35+
});
36+
},
37+
{timeout: IDLE_CALLBACK_TIMEOUT_MS},
38+
);
39+
return () => cancelIdleCallback(id);
40+
}, []);
2941

30-
// Allow showContextMenu() to force eager mount if the user interacts before the idle callback fires.
42+
// Allow showContextMenu() to force eager mount if the user interacts before the idle callback fires.
43+
useEffect(() => {
3144
ReportActionContextMenu.registerEnsureContextMenuMounted(() => setShouldRenderContextMenu(true));
32-
33-
return () => {
34-
cancelIdleCallback(id);
35-
ReportActionContextMenu.registerEnsureContextMenuMounted(null);
36-
};
45+
return () => ReportActionContextMenu.registerEnsureContextMenuMounted(null);
3746
}, []);
3847

3948
return (
4049
<>
41-
<UpdateAppModal />
42-
{/* Those below are only available to the authenticated user. */}
4350
<GrowlNotification ref={growlRef} />
4451
<DelegateNoAccessModalProvider>
4552
{shouldRenderContextMenu && (
46-
<Suspense fallback={null}>
53+
<LazyModalSlot>
4754
{/* eslint-disable-next-line react-hooks/refs -- module-level createRef, safe to pass as ref prop */}
4855
<LazyPopoverReportActionContextMenu ref={ReportActionContextMenu.contextMenuRef} />
49-
</Suspense>
56+
</LazyModalSlot>
5057
)}
5158
</DelegateNoAccessModalProvider>
5259
{/* eslint-disable-next-line react-hooks/refs -- module-level createRef, safe to pass as ref prop */}
5360
<EmojiPicker ref={EmojiPickerAction.emojiPickerRef} />
54-
{/* Proactive app review modal shown when user has completed a trigger action */}
55-
<ProactiveAppReviewModalManager />
56-
<ScreenShareRequestModal />
61+
{shouldRenderDeferredModals && (
62+
<>
63+
{/* Order matters: BaseModal hardcodes zIndex: 1 on every modal, so DOM source order
64+
determines stacking when modals coincide. UpdateAppModal is last so the forced-update
65+
prompt sits on top if it ever overlaps with the others. */}
66+
<LazyModalSlot>
67+
{/* Proactive app review modal shown when user has completed a trigger action */}
68+
<LazyProactiveAppReviewModalManager />
69+
</LazyModalSlot>
70+
<LazyModalSlot>
71+
<LazyScreenShareRequestModal />
72+
</LazyModalSlot>
73+
<LazyModalSlot>
74+
<LazyUpdateAppModal />
75+
</LazyModalSlot>
76+
</>
77+
)}
5778
</>
5879
);
5980
}

src/components/LazyModalSlot.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import React, {Suspense} from 'react';
2+
import {ErrorBoundary as ReactErrorBoundary} from 'react-error-boundary';
3+
import Log from '@libs/Log';
4+
5+
const logModalChunkFailure = (error: Error, info: {componentStack?: string | null}) =>
6+
Log.alert(`[GlobalModals] lazy chunk failure - ${error.message}`, {componentStack: info.componentStack ?? undefined}, false);
7+
8+
/**
9+
* Wraps a lazy-loaded child in its own ErrorBoundary + Suspense pair so a chunk-load failure
10+
* (or unrelated load latency) in one slot cannot tear down sibling slots or surrounding components.
11+
*/
12+
function LazyModalSlot({children}: {children: React.ReactNode}) {
13+
return (
14+
<ReactErrorBoundary
15+
fallback={null}
16+
onError={logModalChunkFailure}
17+
>
18+
<Suspense fallback={null}>{children}</Suspense>
19+
</ReactErrorBoundary>
20+
);
21+
}
22+
23+
export default LazyModalSlot;

0 commit comments

Comments
 (0)