|
1 | 1 | import React, {startTransition, Suspense, useEffect, useState} from 'react'; |
| 2 | +import {ErrorBoundary as ReactErrorBoundary} from 'react-error-boundary'; |
2 | 3 | import DelegateNoAccessModalProvider from './components/DelegateNoAccessModalProvider'; |
3 | 4 | import EmojiPicker from './components/EmojiPicker/EmojiPicker'; |
4 | 5 | import GrowlNotification from './components/GrowlNotification'; |
5 | 6 | import * as EmojiPickerAction from './libs/actions/EmojiPickerAction'; |
6 | 7 | import {growlRef} from './libs/Growl'; |
| 8 | +import Log from './libs/Log'; |
7 | 9 | import * as ReportActionContextMenu from './pages/inbox/report/ContextMenu/ReportActionContextMenu'; |
8 | 10 |
|
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')); |
21 | 15 |
|
22 | 16 | // Maximum time (ms) the context menu mount can stay deferred before requestIdleCallback forces it to run, |
23 | 17 | // guaranteeing mount even if the main thread never becomes idle. |
24 | 18 | const IDLE_CALLBACK_TIMEOUT_MS = 2000; |
25 | 19 |
|
| 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 | + |
26 | 38 | /** |
27 | 39 | * Renders global modals and overlays that are mounted once at the top level. |
28 | 40 | */ |
@@ -57,31 +69,29 @@ function GlobalModals() { |
57 | 69 | <GrowlNotification ref={growlRef} /> |
58 | 70 | <DelegateNoAccessModalProvider> |
59 | 71 | {shouldRenderContextMenu && ( |
60 | | - <Suspense fallback={null}> |
| 72 | + <LazyModalSlot> |
61 | 73 | {/* eslint-disable-next-line react-hooks/refs -- module-level createRef, safe to pass as ref prop */} |
62 | 74 | <LazyPopoverReportActionContextMenu ref={ReportActionContextMenu.contextMenuRef} /> |
63 | | - </Suspense> |
| 75 | + </LazyModalSlot> |
64 | 76 | )} |
65 | 77 | </DelegateNoAccessModalProvider> |
66 | 78 | {/* eslint-disable-next-line react-hooks/refs -- module-level createRef, safe to pass as ref prop */} |
67 | 79 | <EmojiPicker ref={EmojiPickerAction.emojiPickerRef} /> |
68 | 80 | {shouldRenderDeferredModals && ( |
69 | 81 | <> |
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 |
73 | 83 | determines stacking when modals coincide. UpdateAppModal is last so the forced-update |
74 | 84 | prompt sits on top if it ever overlaps with the others. */} |
75 | | - <Suspense fallback={null}> |
| 85 | + <LazyModalSlot> |
76 | 86 | {/* Proactive app review modal shown when user has completed a trigger action */} |
77 | 87 | <LazyProactiveAppReviewModalManager /> |
78 | | - </Suspense> |
79 | | - <Suspense fallback={null}> |
| 88 | + </LazyModalSlot> |
| 89 | + <LazyModalSlot> |
80 | 90 | <LazyScreenShareRequestModal /> |
81 | | - </Suspense> |
82 | | - <Suspense fallback={null}> |
| 91 | + </LazyModalSlot> |
| 92 | + <LazyModalSlot> |
83 | 93 | <LazyUpdateAppModal /> |
84 | | - </Suspense> |
| 94 | + </LazyModalSlot> |
85 | 95 | </> |
86 | 96 | )} |
87 | 97 | </> |
|
0 commit comments