Skip to content

Commit 2e649a2

Browse files
committed
fix: add lazy modal error wrapper
1 parent b8e3a98 commit 2e649a2

1 file changed

Lines changed: 33 additions & 23 deletions

File tree

src/GlobalModals.tsx

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,40 @@
11
import React, {startTransition, Suspense, useEffect, useState} from 'react';
2+
import {ErrorBoundary as ReactErrorBoundary} from 'react-error-boundary';
23
import DelegateNoAccessModalProvider from './components/DelegateNoAccessModalProvider';
34
import EmojiPicker from './components/EmojiPicker/EmojiPicker';
45
import GrowlNotification from './components/GrowlNotification';
56
import * as EmojiPickerAction from './libs/actions/EmojiPickerAction';
67
import {growlRef} from './libs/Growl';
8+
import Log from './libs/Log';
79
import * as ReportActionContextMenu from './pages/inbox/report/ContextMenu/ReportActionContextMenu';
810

9-
// React.lazy with a no-op fallback if the chunk fails to load (e.g. stale cache, network blip).
10-
// These modals are non-critical and surface only on rare conditions, so a chunk-load failure must
11-
// not throw to the nearest error boundary and crash the app.
12-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
13-
function lazyWithNullFallback<T extends React.ComponentType<any>>(factory: () => Promise<{default: T}>): React.LazyExoticComponent<T> {
14-
return React.lazy(() => factory().catch(() => ({default: (() => null) as unknown as T})));
15-
}
16-
17-
const LazyPopoverReportActionContextMenu = lazyWithNullFallback(() => import('./pages/inbox/report/ContextMenu/PopoverReportActionContextMenu'));
18-
const LazyUpdateAppModal = lazyWithNullFallback(() => import('./components/UpdateAppModal'));
19-
const LazyScreenShareRequestModal = lazyWithNullFallback(() => import('./components/ScreenShareRequestModal'));
20-
const LazyProactiveAppReviewModalManager = lazyWithNullFallback(() => import('./components/ProactiveAppReviewModalManager'));
11+
const LazyPopoverReportActionContextMenu = React.lazy(() => import('./pages/inbox/report/ContextMenu/PopoverReportActionContextMenu'));
12+
const LazyUpdateAppModal = React.lazy(() => import('./components/UpdateAppModal'));
13+
const LazyScreenShareRequestModal = React.lazy(() => import('./components/ScreenShareRequestModal'));
14+
const LazyProactiveAppReviewModalManager = React.lazy(() => import('./components/ProactiveAppReviewModalManager'));
2115

2216
// Maximum time (ms) the context menu mount can stay deferred before requestIdleCallback forces it to run,
2317
// guaranteeing mount even if the main thread never becomes idle.
2418
const IDLE_CALLBACK_TIMEOUT_MS = 2000;
2519

20+
const logModalChunkFailure = (error: Error, info: {componentStack?: string | null}) =>
21+
Log.alert(`[GlobalModals] lazy chunk failure - ${error.message}`, {componentStack: info.componentStack ?? undefined}, false);
22+
23+
/**
24+
* Wraps a lazy modal in its own ErrorBoundary + Suspense pair so a chunk-load failure
25+
* (or unrelated load latency) in one modal cannot tear down sibling modals or the rest of GlobalModals.
26+
*/
27+
function LazyModalSlot({children}: {children: React.ReactNode}) {
28+
return (
29+
<ReactErrorBoundary
30+
fallback={null}
31+
onError={logModalChunkFailure}
32+
>
33+
<Suspense fallback={null}>{children}</Suspense>
34+
</ReactErrorBoundary>
35+
);
36+
}
37+
2638
/**
2739
* Renders global modals and overlays that are mounted once at the top level.
2840
*/
@@ -57,31 +69,29 @@ function GlobalModals() {
5769
<GrowlNotification ref={growlRef} />
5870
<DelegateNoAccessModalProvider>
5971
{shouldRenderContextMenu && (
60-
<Suspense fallback={null}>
72+
<LazyModalSlot>
6173
{/* eslint-disable-next-line react-hooks/refs -- module-level createRef, safe to pass as ref prop */}
6274
<LazyPopoverReportActionContextMenu ref={ReportActionContextMenu.contextMenuRef} />
63-
</Suspense>
75+
</LazyModalSlot>
6476
)}
6577
</DelegateNoAccessModalProvider>
6678
{/* eslint-disable-next-line react-hooks/refs -- module-level createRef, safe to pass as ref prop */}
6779
<EmojiPicker ref={EmojiPickerAction.emojiPickerRef} />
6880
{shouldRenderDeferredModals && (
6981
<>
70-
{/* Each modal lives in its own Suspense boundary so an unrelated chunk's load latency
71-
or failure cannot delay or suppress the others (e.g. an incoming screen-share request).
72-
Order matters: BaseModal hardcodes zIndex: 1 on every modal, so DOM source order
82+
{/* Order matters: BaseModal hardcodes zIndex: 1 on every modal, so DOM source order
7383
determines stacking when modals coincide. UpdateAppModal is last so the forced-update
7484
prompt sits on top if it ever overlaps with the others. */}
75-
<Suspense fallback={null}>
85+
<LazyModalSlot>
7686
{/* Proactive app review modal shown when user has completed a trigger action */}
7787
<LazyProactiveAppReviewModalManager />
78-
</Suspense>
79-
<Suspense fallback={null}>
88+
</LazyModalSlot>
89+
<LazyModalSlot>
8090
<LazyScreenShareRequestModal />
81-
</Suspense>
82-
<Suspense fallback={null}>
91+
</LazyModalSlot>
92+
<LazyModalSlot>
8393
<LazyUpdateAppModal />
84-
</Suspense>
94+
</LazyModalSlot>
8595
</>
8696
)}
8797
</>

0 commit comments

Comments
 (0)