main
js 341 lines 10.4 KB
Raw
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 }