| 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 | import type {SourceMappedLocation} from 'react-devtools-shared/src/symbolicateSource'; |
| 11 | |
| 12 | import * as React from 'react'; |
| 13 | import {useCallback, useContext, useSyncExternalStore} from 'react'; |
| 14 | import {TreeStateContext} from './TreeContext'; |
| 15 | import {BridgeContext, StoreContext, OptionsContext} from '../context'; |
| 16 | import Button from '../Button'; |
| 17 | import ButtonIcon from '../ButtonIcon'; |
| 18 | import Icon from '../Icon'; |
| 19 | import Toggle from '../Toggle'; |
| 20 | import { |
| 21 | ElementTypeSuspense, |
| 22 | ElementTypeRoot, |
| 23 | } from 'react-devtools-shared/src/frontend/types'; |
| 24 | import InspectedElementView from './InspectedElementView'; |
| 25 | import {InspectedElementContext} from './InspectedElementContext'; |
| 26 | import {getAlwaysOpenInEditor} from '../../../utils'; |
| 27 | import {LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR} from '../../../constants'; |
| 28 | import FetchFileWithCachingContext from './FetchFileWithCachingContext'; |
| 29 | import {symbolicateSourceWithCache} from 'react-devtools-shared/src/symbolicateSource'; |
| 30 | import OpenInEditorButton from './OpenInEditorButton'; |
| 31 | import InspectedElementViewSourceButton from './InspectedElementViewSourceButton'; |
| 32 | import useEditorURL from '../useEditorURL'; |
| 33 | |
| 34 | import styles from './InspectedElement.css'; |
| 35 | import Tooltip from './reach-ui/tooltip'; |
| 36 | |
| 37 | export type Props = { |
| 38 | actionButtons?: React.Node, |
| 39 | /** fallback to show when no element is inspected */ |
| 40 | fallbackEmpty: React.Node, |
| 41 | }; |
| 42 | |
| 43 | // TODO Make edits and deletes also use transition API! |
| 44 | |
| 45 | const noSourcePromise = Promise.resolve(null); |
| 46 | |
| 47 | export default function InspectedElementWrapper({ |
| 48 | actionButtons, |
| 49 | fallbackEmpty, |
| 50 | }: Props): React.Node { |
| 51 | const {inspectedElementID} = useContext(TreeStateContext); |
| 52 | const bridge = useContext(BridgeContext); |
| 53 | const store = useContext(StoreContext); |
| 54 | const { |
| 55 | hideToggleErrorAction, |
| 56 | hideToggleSuspenseAction, |
| 57 | hideLogAction, |
| 58 | hideViewSourceAction, |
| 59 | } = useContext(OptionsContext); |
| 60 | |
| 61 | const {hookNames, inspectedElement, parseHookNames, toggleParseHookNames} = |
| 62 | useContext(InspectedElementContext); |
| 63 | |
| 64 | const fetchFileWithCaching = useContext(FetchFileWithCachingContext); |
| 65 | |
| 66 | const source = |
| 67 | inspectedElement == null |
| 68 | ? null |
| 69 | : inspectedElement.source != null |
| 70 | ? inspectedElement.source |
| 71 | : inspectedElement.stack != null && inspectedElement.stack.length > 0 |
| 72 | ? inspectedElement.stack[0] |
| 73 | : null; |
| 74 | |
| 75 | const symbolicatedSourcePromise: Promise<SourceMappedLocation | null> = |
| 76 | React.useMemo(() => { |
| 77 | if (fetchFileWithCaching == null) return noSourcePromise; |
| 78 | |
| 79 | if (source == null) return noSourcePromise; |
| 80 | |
| 81 | const [, sourceURL, line, column] = source; |
| 82 | return symbolicateSourceWithCache( |
| 83 | fetchFileWithCaching, |
| 84 | sourceURL, |
| 85 | line, |
| 86 | column, |
| 87 | ); |
| 88 | }, [source]); |
| 89 | |
| 90 | const element = |
| 91 | inspectedElementID !== null |
| 92 | ? store.getElementByID(inspectedElementID) |
| 93 | : null; |
| 94 | |
| 95 | const highlightElement = useCallback(() => { |
| 96 | if (element !== null && inspectedElementID !== null) { |
| 97 | const rendererID = store.getRendererIDForElement(inspectedElementID); |
| 98 | if (rendererID !== null) { |
| 99 | bridge.send('highlightHostInstance', { |
| 100 | displayName: element.displayName, |
| 101 | hideAfterTimeout: true, |
| 102 | id: inspectedElementID, |
| 103 | openBuiltinElementsPanel: true, |
| 104 | rendererID, |
| 105 | scrollIntoView: true, |
| 106 | }); |
| 107 | } |
| 108 | } |
| 109 | }, [bridge, element, inspectedElementID, store]); |
| 110 | |
| 111 | const logElement = useCallback(() => { |
| 112 | if (inspectedElementID !== null) { |
| 113 | const rendererID = store.getRendererIDForElement(inspectedElementID); |
| 114 | if (rendererID !== null) { |
| 115 | bridge.send('logElementToConsole', { |
| 116 | id: inspectedElementID, |
| 117 | rendererID, |
| 118 | }); |
| 119 | } |
| 120 | } |
| 121 | }, [bridge, inspectedElementID, store]); |
| 122 | |
| 123 | const isErrored = inspectedElement != null && inspectedElement.isErrored; |
| 124 | |
| 125 | const isSuspended = |
| 126 | element !== null && |
| 127 | element.type === ElementTypeSuspense && |
| 128 | inspectedElement != null && |
| 129 | inspectedElement.isSuspended; |
| 130 | |
| 131 | const canToggleError = |
| 132 | !hideToggleErrorAction && |
| 133 | inspectedElement != null && |
| 134 | inspectedElement.canToggleError; |
| 135 | |
| 136 | const canToggleSuspense = |
| 137 | !hideToggleSuspenseAction && |
| 138 | inspectedElement != null && |
| 139 | inspectedElement.canToggleSuspense; |
| 140 | |
| 141 | const alwaysOpenInEditor = useSyncExternalStore( |
| 142 | useCallback(function subscribe(callback) { |
| 143 | window.addEventListener(LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR, callback); |
| 144 | return function unsubscribe() { |
| 145 | window.removeEventListener( |
| 146 | LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR, |
| 147 | callback, |
| 148 | ); |
| 149 | }; |
| 150 | }, []), |
| 151 | getAlwaysOpenInEditor, |
| 152 | ); |
| 153 | |
| 154 | const editorURL = useEditorURL(); |
| 155 | |
| 156 | const toggleErrored = useCallback(() => { |
| 157 | if (inspectedElement == null) { |
| 158 | return; |
| 159 | } |
| 160 | |
| 161 | const rendererID = store.getRendererIDForElement(inspectedElement.id); |
| 162 | if (rendererID !== null) { |
| 163 | // Toggle error. |
| 164 | // Because triggering an error will always delete the children, we'll |
| 165 | // automatically select the nearest still mounted instance which will be |
| 166 | // the error boundary. |
| 167 | bridge.send('overrideError', { |
| 168 | id: inspectedElement.id, |
| 169 | rendererID, |
| 170 | forceError: !isErrored, |
| 171 | }); |
| 172 | } |
| 173 | }, [bridge, store, isErrored, inspectedElement]); |
| 174 | |
| 175 | // TODO (suspense toggle) Would be nice to eventually use a two setState pattern here as well. |
| 176 | const toggleSuspended = useCallback(() => { |
| 177 | if (inspectedElement == null) { |
| 178 | return; |
| 179 | } |
| 180 | |
| 181 | const rendererID = store.getRendererIDForElement(inspectedElement.id); |
| 182 | if (rendererID !== null) { |
| 183 | // Toggle suspended |
| 184 | // Because suspending or unsuspending always delete the children or fallback, |
| 185 | // we'll automatically select the nearest still mounted instance which will be |
| 186 | // the Suspense boundary. |
| 187 | bridge.send('overrideSuspense', { |
| 188 | id: inspectedElement.id, |
| 189 | rendererID, |
| 190 | forceFallback: !isSuspended, |
| 191 | }); |
| 192 | } |
| 193 | }, [bridge, store, isSuspended, inspectedElement]); |
| 194 | |
| 195 | if (element === null) { |
| 196 | return ( |
| 197 | <div className={styles.InspectedElement}> |
| 198 | <div className={styles.TitleRow} /> |
| 199 | <div className={styles.NoInspectionFallback}>{fallbackEmpty}</div> |
| 200 | </div> |
| 201 | ); |
| 202 | } |
| 203 | |
| 204 | let strictModeBadge = null; |
| 205 | if (element.isStrictModeNonCompliant && element.parentID !== 0) { |
| 206 | strictModeBadge = ( |
| 207 | <Tooltip label="This component is not running in StrictMode. Click to learn more."> |
| 208 | <a |
| 209 | className={styles.StrictModeNonCompliant} |
| 210 | href="https://react.dev/reference/react/StrictMode" |
| 211 | rel="noopener noreferrer" |
| 212 | target="_blank"> |
| 213 | <Icon type="strict-mode-non-compliant" /> |
| 214 | </a> |
| 215 | </Tooltip> |
| 216 | ); |
| 217 | } |
| 218 | |
| 219 | let fullName = element.displayName || ''; |
| 220 | if (element.nameProp !== null) { |
| 221 | fullName += ' "' + element.nameProp + '"'; |
| 222 | } |
| 223 | if (element.type === ElementTypeRoot) { |
| 224 | // The root only has "suspended by" and it represents the things that block |
| 225 | // Initial Paint. |
| 226 | fullName = 'Initial Paint'; |
| 227 | } |
| 228 | |
| 229 | return ( |
| 230 | <div |
| 231 | className={styles.InspectedElement} |
| 232 | key={inspectedElementID /* Force reset when selected Element changes */}> |
| 233 | <div className={styles.TitleRow} data-testname="InspectedElement-Title"> |
| 234 | {strictModeBadge} |
| 235 | |
| 236 | {element.key && ( |
| 237 | <> |
| 238 | <div className={styles.Key} title={`key "${element.key}"`}> |
| 239 | {element.key} |
| 240 | </div> |
| 241 | <div className={styles.KeyArrow} /> |
| 242 | </> |
| 243 | )} |
| 244 | |
| 245 | <div className={styles.SelectedComponentName}> |
| 246 | <div |
| 247 | className={ |
| 248 | element.isStrictModeNonCompliant && element.parentID !== 0 |
| 249 | ? `${styles.ComponentName} ${styles.StrictModeNonCompliantComponentName}` |
| 250 | : styles.ComponentName |
| 251 | } |
| 252 | title={fullName}> |
| 253 | {fullName} |
| 254 | </div> |
| 255 | </div> |
| 256 | |
| 257 | {!alwaysOpenInEditor && |
| 258 | !!editorURL && |
| 259 | source != null && |
| 260 | symbolicatedSourcePromise != null && ( |
| 261 | <OpenInEditorButton |
| 262 | editorURL={editorURL} |
| 263 | source={source} |
| 264 | symbolicatedSourcePromise={symbolicatedSourcePromise} |
| 265 | /> |
| 266 | )} |
| 267 | |
| 268 | {canToggleError && ( |
| 269 | <Toggle |
| 270 | isChecked={isErrored} |
| 271 | onChange={toggleErrored} |
| 272 | title={ |
| 273 | isErrored |
| 274 | ? 'Clear the forced error' |
| 275 | : 'Force the selected component into an errored state' |
| 276 | }> |
| 277 | <ButtonIcon type="error" /> |
| 278 | </Toggle> |
| 279 | )} |
| 280 | {canToggleSuspense || isSuspended ? ( |
| 281 | <Toggle |
| 282 | isChecked={isSuspended} |
| 283 | isDisabled={!canToggleSuspense} |
| 284 | onChange={toggleSuspended} |
| 285 | title={ |
| 286 | isSuspended |
| 287 | ? canToggleSuspense |
| 288 | ? 'Unsuspend the selected component' |
| 289 | : 'This boundary is still suspended' |
| 290 | : 'Suspend the selected component' |
| 291 | }> |
| 292 | <ButtonIcon type="suspend" /> |
| 293 | </Toggle> |
| 294 | ) : null} |
| 295 | {store.supportsInspectMatchingDOMElement && ( |
| 296 | <Button |
| 297 | onClick={highlightElement} |
| 298 | title="Inspect the matching DOM element"> |
| 299 | <ButtonIcon type="view-dom" /> |
| 300 | </Button> |
| 301 | )} |
| 302 | {!hideLogAction && ( |
| 303 | <Button |
| 304 | onClick={logElement} |
| 305 | title="Log this component data to the console"> |
| 306 | <ButtonIcon type="log-data" /> |
| 307 | </Button> |
| 308 | )} |
| 309 | |
| 310 | {!hideViewSourceAction && ( |
| 311 | <InspectedElementViewSourceButton |
| 312 | source={source} |
| 313 | symbolicatedSourcePromise={symbolicatedSourcePromise} |
| 314 | /> |
| 315 | )} |
| 316 | |
| 317 | {actionButtons && ( |
| 318 | <> |
| 319 | <div className={styles.VRule} /> |
| 320 | {actionButtons} |
| 321 | </> |
| 322 | )} |
| 323 | </div> |
| 324 | |
| 325 | {inspectedElement === null && ( |
| 326 | <div className={styles.Loading}>Loading...</div> |
| 327 | )} |
| 328 | |
| 329 | {inspectedElement !== null && ( |
| 330 | <InspectedElementView |
| 331 | element={element} |
| 332 | hookNames={hookNames} |
| 333 | inspectedElement={inspectedElement} |
| 334 | parseHookNames={parseHookNames} |
| 335 | toggleParseHookNames={toggleParseHookNames} |
| 336 | symbolicatedSourcePromise={symbolicatedSourcePromise} |
| 337 | /> |
| 338 | )} |
| 339 | </div> |
| 340 | ); |
| 341 | } |