| 1 | /** |
| 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. |
| 3 | * |
| 4 | * This source code is licensed under the MIT license found in the |
| 5 | * LICENSE file in the root directory of this source tree. |
| 6 | * |
| 7 | * @flow |
| 8 | */ |
| 9 | |
| 10 | // Reach styles need to come before any component styles. |
| 11 | // This makes overriding the styles simpler. |
| 12 | import '@reach/menu-button/styles.css'; |
| 13 | import '@reach/tooltip/styles.css'; |
| 14 | |
| 15 | import * as React from 'react'; |
| 16 | import {useCallback, useEffect, useLayoutEffect, useMemo, useRef} from 'react'; |
| 17 | import Store from '../store'; |
| 18 | import { |
| 19 | BridgeContext, |
| 20 | ContextMenuContext, |
| 21 | StoreContext, |
| 22 | OptionsContext, |
| 23 | } from './context'; |
| 24 | import Components from './Components/Components'; |
| 25 | import Profiler from './Profiler/Profiler'; |
| 26 | import SuspenseTab from './SuspenseTab/SuspenseTab'; |
| 27 | import TabBar from './TabBar'; |
| 28 | import EditorPane from './Editor/EditorPane'; |
| 29 | import InspectedElementPane from './InspectedElement/InspectedElementPane'; |
| 30 | import {SettingsContextController} from './Settings/SettingsContext'; |
| 31 | import {TreeContextController} from './Components/TreeContext'; |
| 32 | import ViewElementSourceContext from './Components/ViewElementSourceContext'; |
| 33 | import FetchFileWithCachingContext from './Components/FetchFileWithCachingContext'; |
| 34 | import {InspectedElementContextController} from './Components/InspectedElementContext'; |
| 35 | import HookNamesModuleLoaderContext from 'react-devtools-shared/src/devtools/views/Components/HookNamesModuleLoaderContext'; |
| 36 | import {ProfilerContextController} from './Profiler/ProfilerContext'; |
| 37 | import {SuspenseTreeContextController} from './SuspenseTab/SuspenseTreeContext'; |
| 38 | import {ModalDialogContextController} from './ModalDialog'; |
| 39 | import ReactLogo from './ReactLogo'; |
| 40 | import UnsupportedBridgeProtocolDialog from './UnsupportedBridgeProtocolDialog'; |
| 41 | import UnsupportedVersionDialog from './UnsupportedVersionDialog'; |
| 42 | import WarnIfLegacyBackendDetected from './WarnIfLegacyBackendDetected'; |
| 43 | import {useLocalStorage} from './hooks'; |
| 44 | import ThemeProvider from './ThemeProvider'; |
| 45 | import {LOCAL_STORAGE_DEFAULT_TAB_KEY} from '../../constants'; |
| 46 | import {logEvent} from '../../Logger'; |
| 47 | |
| 48 | import styles from './DevTools.css'; |
| 49 | |
| 50 | import './root.css'; |
| 51 | |
| 52 | import type {FetchFileWithCaching} from './Components/FetchFileWithCachingContext'; |
| 53 | import type {HookNamesModuleLoaderFunction} from 'react-devtools-shared/src/devtools/views/Components/HookNamesModuleLoaderContext'; |
| 54 | import type {FrontendBridge} from 'react-devtools-shared/src/bridge'; |
| 55 | import type {BrowserTheme} from 'react-devtools-shared/src/frontend/types'; |
| 56 | import type {ReactFunctionLocation, ReactCallSite} from 'shared/ReactTypes'; |
| 57 | import type {SourceSelection} from './Editor/EditorPane'; |
| 58 | |
| 59 | export type TabID = 'components' | 'profiler' | 'suspense'; |
| 60 | |
| 61 | export type ViewElementSource = ( |
| 62 | source: ReactFunctionLocation | ReactCallSite, |
| 63 | symbolicatedSource: ReactFunctionLocation | ReactCallSite | null, |
| 64 | ) => void; |
| 65 | export type ViewAttributeSource = ( |
| 66 | id: number, |
| 67 | path: Array<string | number>, |
| 68 | ) => void; |
| 69 | export type CanViewElementSource = ( |
| 70 | source: ReactFunctionLocation | ReactCallSite, |
| 71 | symbolicatedSource: ReactFunctionLocation | ReactCallSite | null, |
| 72 | ) => boolean; |
| 73 | |
| 74 | export type Props = { |
| 75 | bridge: FrontendBridge, |
| 76 | browserTheme?: BrowserTheme, |
| 77 | canViewElementSourceFunction?: ?CanViewElementSource, |
| 78 | defaultTab?: TabID, |
| 79 | enabledInspectedElementContextMenu?: boolean, |
| 80 | showTabBar?: boolean, |
| 81 | store: Store, |
| 82 | warnIfLegacyBackendDetected?: boolean, |
| 83 | warnIfUnsupportedVersionDetected?: boolean, |
| 84 | viewAttributeSourceFunction?: ?ViewAttributeSource, |
| 85 | viewElementSourceFunction?: ?ViewElementSource, |
| 86 | readOnly?: boolean, |
| 87 | hideSettings?: boolean, |
| 88 | hideToggleErrorAction?: boolean, |
| 89 | hideToggleSuspenseAction?: boolean, |
| 90 | hideLogAction?: boolean, |
| 91 | hideViewSourceAction?: boolean, |
| 92 | |
| 93 | // This property is used only by the web extension target. |
| 94 | // The built-in tab UI is hidden in that case, in favor of the browser's own panel tabs. |
| 95 | // This is done to save space within the app. |
| 96 | // Because of this, the extension needs to be able to change which tab is active/rendered. |
| 97 | overrideTab?: TabID, |
| 98 | |
| 99 | // To avoid potential multi-root trickiness, the web extension uses portals to render tabs. |
| 100 | // The root <DevTools> app is rendered in the top-level extension window, |
| 101 | // but individual tabs (e.g. Components, Profiling) can be rendered into portals within their browser panels. |
| 102 | componentsPortalContainer?: Element, |
| 103 | inspectedElementPortalContainer?: Element, |
| 104 | profilerPortalContainer?: Element, |
| 105 | suspensePortalContainer?: Element, |
| 106 | editorPortalContainer?: Element, |
| 107 | |
| 108 | currentSelectedSource?: null | SourceSelection, |
| 109 | |
| 110 | // Loads and parses source maps for function components |
| 111 | // and extracts hook "names" based on the variables the hook return values get assigned to. |
| 112 | // Not every DevTools build can load source maps, so this property is optional. |
| 113 | fetchFileWithCaching?: ?FetchFileWithCaching, |
| 114 | // TODO (Webpack 5) Hopefully we can remove this prop after the Webpack 5 migration. |
| 115 | hookNamesModuleLoaderFunction?: ?HookNamesModuleLoaderFunction, |
| 116 | }; |
| 117 | |
| 118 | const componentsTab = { |
| 119 | id: 'components' as TabID, |
| 120 | icon: 'components', |
| 121 | label: 'Components', |
| 122 | title: 'React Components', |
| 123 | }; |
| 124 | const profilerTab = { |
| 125 | id: 'profiler' as TabID, |
| 126 | icon: 'profiler', |
| 127 | label: 'Profiler', |
| 128 | title: 'React Profiler', |
| 129 | }; |
| 130 | const suspenseTab = { |
| 131 | id: 'suspense' as TabID, |
| 132 | icon: 'suspense', |
| 133 | label: 'Suspense', |
| 134 | title: 'React Suspense', |
| 135 | }; |
| 136 | |
| 137 | const tabs = [componentsTab, profilerTab, suspenseTab]; |
| 138 | |
| 139 | export default function DevTools({ |
| 140 | bridge, |
| 141 | browserTheme = 'light', |
| 142 | canViewElementSourceFunction, |
| 143 | componentsPortalContainer, |
| 144 | editorPortalContainer, |
| 145 | inspectedElementPortalContainer, |
| 146 | profilerPortalContainer, |
| 147 | suspensePortalContainer, |
| 148 | currentSelectedSource, |
| 149 | defaultTab = 'components', |
| 150 | enabledInspectedElementContextMenu = false, |
| 151 | fetchFileWithCaching, |
| 152 | hookNamesModuleLoaderFunction, |
| 153 | overrideTab, |
| 154 | showTabBar = false, |
| 155 | store, |
| 156 | warnIfLegacyBackendDetected = false, |
| 157 | warnIfUnsupportedVersionDetected = false, |
| 158 | viewAttributeSourceFunction, |
| 159 | viewElementSourceFunction, |
| 160 | readOnly, |
| 161 | hideSettings, |
| 162 | hideToggleErrorAction, |
| 163 | hideToggleSuspenseAction, |
| 164 | hideLogAction, |
| 165 | hideViewSourceAction, |
| 166 | }: Props): React.Node { |
| 167 | const [currentTab, setTab] = useLocalStorage<TabID>( |
| 168 | LOCAL_STORAGE_DEFAULT_TAB_KEY, |
| 169 | defaultTab, |
| 170 | ); |
| 171 | |
| 172 | let tab = currentTab; |
| 173 | |
| 174 | if (overrideTab != null) { |
| 175 | tab = overrideTab; |
| 176 | } |
| 177 | |
| 178 | const selectTab = useCallback( |
| 179 | (tabId: TabID) => { |
| 180 | // We show the TabBar when DevTools is NOT rendered as a browser extension. |
| 181 | // In this case, we want to capture when people select tabs with the TabBar. |
| 182 | // When DevTools is rendered as an extension, we capture this event when |
| 183 | // the browser devtools panel changes. |
| 184 | if (showTabBar === true) { |
| 185 | if (tabId === 'components') { |
| 186 | logEvent({event_name: 'selected-components-tab'}); |
| 187 | } else if (tabId === 'suspense') { |
| 188 | logEvent({event_name: 'selected-suspense-tab'}); |
| 189 | } else { |
| 190 | logEvent({event_name: 'selected-profiler-tab'}); |
| 191 | } |
| 192 | } |
| 193 | setTab(tabId); |
| 194 | }, |
| 195 | [setTab, showTabBar], |
| 196 | ); |
| 197 | |
| 198 | const options = useMemo( |
| 199 | () => ({ |
| 200 | readOnly: readOnly || false, |
| 201 | hideSettings: hideSettings || false, |
| 202 | hideToggleErrorAction: hideToggleErrorAction || false, |
| 203 | hideToggleSuspenseAction: hideToggleSuspenseAction || false, |
| 204 | hideLogAction: hideLogAction || false, |
| 205 | hideViewSourceAction: hideViewSourceAction || false, |
| 206 | }), |
| 207 | [ |
| 208 | readOnly, |
| 209 | hideSettings, |
| 210 | hideToggleErrorAction, |
| 211 | hideToggleSuspenseAction, |
| 212 | hideLogAction, |
| 213 | hideViewSourceAction, |
| 214 | ], |
| 215 | ); |
| 216 | |
| 217 | const viewElementSource = useMemo( |
| 218 | () => ({ |
| 219 | canViewElementSourceFunction: canViewElementSourceFunction || null, |
| 220 | viewElementSourceFunction: viewElementSourceFunction || null, |
| 221 | }), |
| 222 | [canViewElementSourceFunction, viewElementSourceFunction], |
| 223 | ); |
| 224 | |
| 225 | const contextMenu = useMemo( |
| 226 | () => ({ |
| 227 | isEnabledForInspectedElement: enabledInspectedElementContextMenu, |
| 228 | viewAttributeSourceFunction: viewAttributeSourceFunction || null, |
| 229 | }), |
| 230 | [enabledInspectedElementContextMenu, viewAttributeSourceFunction], |
| 231 | ); |
| 232 | |
| 233 | const devToolsRef = useRef<HTMLElement | null>(null); |
| 234 | |
| 235 | useEffect(() => { |
| 236 | if (!showTabBar) { |
| 237 | return; |
| 238 | } |
| 239 | |
| 240 | const div = devToolsRef.current; |
| 241 | if (div === null) { |
| 242 | return; |
| 243 | } |
| 244 | |
| 245 | const ownerWindow = div.ownerDocument.defaultView; |
| 246 | const handleKeyDown = (event: KeyboardEvent) => { |
| 247 | if (event.ctrlKey || event.metaKey) { |
| 248 | switch (event.key) { |
| 249 | case '1': |
| 250 | selectTab(tabs[0].id); |
| 251 | event.preventDefault(); |
| 252 | event.stopPropagation(); |
| 253 | break; |
| 254 | case '2': |
| 255 | selectTab(tabs[1].id); |
| 256 | event.preventDefault(); |
| 257 | event.stopPropagation(); |
| 258 | break; |
| 259 | case '3': |
| 260 | if (tabs.length > 2) { |
| 261 | selectTab(tabs[2].id); |
| 262 | event.preventDefault(); |
| 263 | event.stopPropagation(); |
| 264 | } |
| 265 | break; |
| 266 | } |
| 267 | } |
| 268 | }; |
| 269 | ownerWindow.addEventListener('keydown', handleKeyDown); |
| 270 | return () => { |
| 271 | ownerWindow.removeEventListener('keydown', handleKeyDown); |
| 272 | }; |
| 273 | }, [showTabBar]); |
| 274 | |
| 275 | useLayoutEffect(() => { |
| 276 | return () => { |
| 277 | // Shut the Bridge down synchronously (during unmount). |
| 278 | bridge.shutdown(); |
| 279 | }; |
| 280 | }, [bridge]); |
| 281 | |
| 282 | useEffect(() => { |
| 283 | logEvent({event_name: 'loaded-dev-tools'}); |
| 284 | }, []); |
| 285 | |
| 286 | return ( |
| 287 | <BridgeContext.Provider value={bridge}> |
| 288 | <StoreContext.Provider value={store}> |
| 289 | <OptionsContext.Provider value={options}> |
| 290 | <ContextMenuContext.Provider value={contextMenu}> |
| 291 | <ModalDialogContextController> |
| 292 | <SettingsContextController |
| 293 | browserTheme={browserTheme} |
| 294 | componentsPortalContainer={componentsPortalContainer} |
| 295 | profilerPortalContainer={profilerPortalContainer}> |
| 296 | <ViewElementSourceContext.Provider value={viewElementSource}> |
| 297 | <HookNamesModuleLoaderContext.Provider |
| 298 | value={hookNamesModuleLoaderFunction || null}> |
| 299 | <FetchFileWithCachingContext.Provider |
| 300 | value={fetchFileWithCaching || null}> |
| 301 | <TreeContextController> |
| 302 | <ProfilerContextController> |
| 303 | <InspectedElementContextController> |
| 304 | <SuspenseTreeContextController> |
| 305 | <ThemeProvider> |
| 306 | <div |
| 307 | className={styles.DevTools} |
| 308 | ref={devToolsRef} |
| 309 | data-react-devtools-portal-root={true}> |
| 310 | {showTabBar && ( |
| 311 | <div className={styles.TabBar}> |
| 312 | <ReactLogo /> |
| 313 | <span className={styles.DevToolsVersion}> |
| 314 | {process.env.DEVTOOLS_VERSION} |
| 315 | </span> |
| 316 | <div className={styles.Spacer} /> |
| 317 | <TabBar |
| 318 | currentTab={tab} |
| 319 | id="DevTools" |
| 320 | selectTab={selectTab} |
| 321 | tabs={tabs} |
| 322 | type="navigation" |
| 323 | /> |
| 324 | </div> |
| 325 | )} |
| 326 | <div |
| 327 | className={styles.TabContent} |
| 328 | hidden={tab !== 'components'}> |
| 329 | <Components |
| 330 | portalContainer={ |
| 331 | componentsPortalContainer |
| 332 | } |
| 333 | /> |
| 334 | </div> |
| 335 | <div |
| 336 | className={styles.TabContent} |
| 337 | hidden={tab !== 'profiler'}> |
| 338 | <Profiler |
| 339 | portalContainer={profilerPortalContainer} |
| 340 | /> |
| 341 | </div> |
| 342 | <div |
| 343 | className={styles.TabContent} |
| 344 | hidden={tab !== 'suspense'}> |
| 345 | <SuspenseTab |
| 346 | portalContainer={suspensePortalContainer} |
| 347 | /> |
| 348 | </div> |
| 349 | </div> |
| 350 | {editorPortalContainer ? ( |
| 351 | <EditorPane |
| 352 | selectedSource={currentSelectedSource} |
| 353 | portalContainer={editorPortalContainer} |
| 354 | /> |
| 355 | ) : null} |
| 356 | {inspectedElementPortalContainer ? ( |
| 357 | <InspectedElementPane |
| 358 | selectedSource={currentSelectedSource} |
| 359 | portalContainer={ |
| 360 | inspectedElementPortalContainer |
| 361 | } |
| 362 | /> |
| 363 | ) : null} |
| 364 | </ThemeProvider> |
| 365 | </SuspenseTreeContextController> |
| 366 | </InspectedElementContextController> |
| 367 | </ProfilerContextController> |
| 368 | </TreeContextController> |
| 369 | </FetchFileWithCachingContext.Provider> |
| 370 | </HookNamesModuleLoaderContext.Provider> |
| 371 | </ViewElementSourceContext.Provider> |
| 372 | </SettingsContextController> |
| 373 | <UnsupportedBridgeProtocolDialog /> |
| 374 | {warnIfLegacyBackendDetected && <WarnIfLegacyBackendDetected />} |
| 375 | {warnIfUnsupportedVersionDetected && <UnsupportedVersionDialog />} |
| 376 | </ModalDialogContextController> |
| 377 | </ContextMenuContext.Provider> |
| 378 | </OptionsContext.Provider> |
| 379 | </StoreContext.Provider> |
| 380 | </BridgeContext.Provider> |
| 381 | ); |
| 382 | } |