main
js 624 lines 20.5 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 * as React from 'react';
11 import {
12 Fragment,
13 Suspense,
14 startTransition,
15 useCallback,
16 useContext,
17 useEffect,
18 useMemo,
19 useRef,
20 useState,
21 } from 'react';
22 import AutoSizer from 'react-virtualized-auto-sizer';
23 import {FixedSizeList} from 'react-window';
24 import {TreeDispatcherContext, TreeStateContext} from './TreeContext';
25 import Icon from '../Icon';
26 import {SettingsContext} from '../Settings/SettingsContext';
27 import {BridgeContext, StoreContext, OptionsContext} from '../context';
28 import ComponentsTreeElement from './Element';
29 import InspectHostNodesToggle from './InspectHostNodesToggle';
30 import OwnersStack from './OwnersStack';
31 import ComponentSearchInput from './ComponentSearchInput';
32 import SettingsModalContextToggle from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContextToggle';
33 import TreeFocusedContext from './TreeFocusedContext';
34 import {useHighlightHostInstance, useSubscription} from '../hooks';
35 import {clearErrorsAndWarnings as clearErrorsAndWarningsAPI} from 'react-devtools-shared/src/backendAPI';
36 import styles from './Tree.css';
37 import ButtonIcon from '../ButtonIcon';
38 import Button from '../Button';
39 import {logEvent} from 'react-devtools-shared/src/Logger';
40 import {useExtensionComponentsPanelVisibility} from 'react-devtools-shared/src/frontend/hooks/useExtensionComponentsPanelVisibility';
41 import {ElementTypeActivity} from 'react-devtools-shared/src/frontend/types';
42 import {useChangeOwnerAction} from './OwnersListContext';
43 import {useChangeActivitySliceAction} from '../SuspenseTab/ActivityList';
44 import ActivitySlice from './ActivitySlice';
45
46 // Indent for each node at level N, compared to node at level N - 1.
47 const INDENTATION_SIZE = 10;
48
49 function calculateElementOffset(elementDepth: number): number {
50 return elementDepth * INDENTATION_SIZE;
51 }
52
53 export type ItemData = {
54 isNavigatingWithKeyboard: boolean,
55 onElementMouseEnter: (id: number) => void,
56 treeFocused: boolean,
57 calculateElementOffset: (depth: number) => number,
58 };
59
60 function calculateInitialScrollOffset(
61 inspectedElementIndex: number | null,
62 elementHeight: number,
63 ): number | void {
64 if (inspectedElementIndex === null) {
65 return undefined;
66 }
67
68 if (inspectedElementIndex < 3) {
69 return undefined;
70 }
71
72 // Make 3 elements on top of the inspected one visible
73 return (inspectedElementIndex - 3) * elementHeight;
74 }
75
76 export default function Tree(): React.Node {
77 const dispatch = useContext(TreeDispatcherContext);
78 const {
79 activityID,
80 numElements,
81 ownerID,
82 searchIndex,
83 searchResults,
84 inspectedElementID,
85 inspectedElementIndex,
86 } = useContext(TreeStateContext);
87 const bridge = useContext(BridgeContext);
88 const store = useContext(StoreContext);
89 const {hideSettings} = useContext(OptionsContext);
90 const {lineHeight} = useContext(SettingsContext);
91
92 const [isNavigatingWithKeyboard, setIsNavigatingWithKeyboard] =
93 useState(false);
94 const {highlightHostInstance, clearHighlightHostInstance} =
95 useHighlightHostInstance();
96 const [treeFocused, setTreeFocused] = useState<boolean>(false);
97 const componentsPanelVisible = useExtensionComponentsPanelVisibility(bridge);
98
99 const treeRef = useRef<HTMLDivElement | null>(null);
100 const focusTargetRef = useRef<HTMLDivElement | null>(null);
101 const listDOMElementRef = useRef<Element | null>(null);
102 const setListDOMElementRef = useCallback((listDOMElement: Element) => {
103 listDOMElementRef.current = listDOMElement;
104
105 // Controls the initial horizontal offset of the Tree if the element was pre-selected. For example, via Elements panel in browser DevTools.
106 // Initial vertical offset is controlled via initialScrollOffset prop of the FixedSizeList component.
107 if (
108 !componentsPanelVisible ||
109 inspectedElementIndex == null ||
110 listDOMElement == null
111 ) {
112 return;
113 }
114
115 const element = store.getElementAtIndex(inspectedElementIndex);
116 if (element == null) {
117 return;
118 }
119
120 const viewportLeft = listDOMElement.scrollLeft;
121 const viewportRight = viewportLeft + listDOMElement.clientWidth;
122 const elementLeft = calculateElementOffset(element.depth);
123 // Because of virtualization, this element might not be rendered yet; we can't look up its width.
124 // Assuming that it may take up to the half of the viewport.
125 const elementRight = elementLeft + listDOMElement.clientWidth / 2;
126
127 const isElementFullyVisible =
128 elementLeft >= viewportLeft && elementRight <= viewportRight;
129
130 if (!isElementFullyVisible) {
131 const horizontalDelta =
132 Math.min(0, elementLeft - viewportLeft) +
133 Math.max(0, elementRight - viewportRight);
134
135 // $FlowExpectedError[incompatible-type] Flow doesn't support instant as an option for behavior.
136 listDOMElement.scrollBy({
137 left: horizontalDelta,
138 behavior: 'instant',
139 });
140 }
141 }, []);
142
143 useEffect(() => {
144 if (!componentsPanelVisible || inspectedElementIndex == null) {
145 return;
146 }
147
148 const listDOMElement = listDOMElementRef.current;
149 if (listDOMElement == null) {
150 return;
151 }
152
153 const viewportHeight = listDOMElement.clientHeight;
154 const viewportLeft = listDOMElement.scrollLeft;
155 const viewportRight = viewportLeft + listDOMElement.clientWidth;
156 const viewportTop = listDOMElement.scrollTop;
157 const viewportBottom = viewportTop + viewportHeight;
158
159 const element = store.getElementAtIndex(inspectedElementIndex);
160 if (element == null) {
161 return;
162 }
163 const elementLeft = calculateElementOffset(element.depth);
164 // Because of virtualization, this element might not be rendered yet; we can't look up its width.
165 // Assuming that it may take up to the half of the viewport.
166 const elementRight = elementLeft + listDOMElement.clientWidth / 2;
167 const elementTop = inspectedElementIndex * lineHeight;
168 const elementBottom = elementTop + lineHeight;
169
170 const isElementFullyVisible =
171 elementTop >= viewportTop &&
172 elementBottom <= viewportBottom &&
173 elementLeft >= viewportLeft &&
174 elementRight <= viewportRight;
175
176 if (!isElementFullyVisible) {
177 const verticalDelta =
178 Math.min(0, elementTop - viewportTop) +
179 Math.max(0, elementBottom - viewportBottom);
180 const horizontalDelta =
181 Math.min(0, elementLeft - viewportLeft) +
182 Math.max(0, elementRight - viewportRight);
183
184 // $FlowExpectedError[incompatible-type] Flow doesn't support instant as an option for behavior.
185 listDOMElement.scrollBy({
186 top: verticalDelta,
187 left: horizontalDelta,
188 behavior: treeFocused && ownerID == null ? 'smooth' : 'instant',
189 });
190 }
191 }, [inspectedElementIndex, componentsPanelVisible, lineHeight]);
192
193 // Picking an element in the inspector should put focus into the tree.
194 // If possible, navigation works right after picking a node.
195 // NOTE: This is not guaranteed to work, because browser extension panels are hosted inside an iframe.
196 useEffect(() => {
197 function handleStopInspectingHost(didSelectNode: boolean) {
198 if (didSelectNode && focusTargetRef.current !== null) {
199 focusTargetRef.current.focus();
200 logEvent({
201 event_name: 'select-element',
202 metadata: {source: 'inspector'},
203 });
204 }
205 }
206 bridge.addListener('stopInspectingHost', handleStopInspectingHost);
207 return () =>
208 bridge.removeListener('stopInspectingHost', handleStopInspectingHost);
209 }, [bridge]);
210
211 // Navigate the tree with up/down arrow keys.
212 useEffect(() => {
213 if (treeRef.current === null) {
214 return () => {};
215 }
216
217 const handleKeyDown = (event: KeyboardEvent) => {
218 if ((event as any).target.tagName === 'INPUT' || event.defaultPrevented) {
219 return;
220 }
221
222 let element;
223 switch (event.key) {
224 case 'ArrowDown':
225 event.preventDefault();
226 if (event.altKey) {
227 dispatch({type: 'SELECT_NEXT_SIBLING_IN_TREE'});
228 } else {
229 dispatch({type: 'SELECT_NEXT_ELEMENT_IN_TREE'});
230 }
231 break;
232 case 'ArrowLeft':
233 event.preventDefault();
234 element =
235 inspectedElementID !== null
236 ? store.getElementByID(inspectedElementID)
237 : null;
238 if (element !== null) {
239 if (event.altKey) {
240 // $FlowFixMe[invalid-compare]
241 if (element.ownerID !== null) {
242 dispatch({type: 'SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE'});
243 }
244 } else {
245 if (element.children.length > 0 && !element.isCollapsed) {
246 store.toggleIsCollapsed(element.id, true);
247 } else {
248 dispatch({type: 'SELECT_PARENT_ELEMENT_IN_TREE'});
249 }
250 }
251 }
252 break;
253 case 'ArrowRight':
254 event.preventDefault();
255 element =
256 inspectedElementID !== null
257 ? store.getElementByID(inspectedElementID)
258 : null;
259 if (element !== null) {
260 if (event.altKey) {
261 dispatch({type: 'SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE'});
262 } else {
263 if (element.children.length > 0 && element.isCollapsed) {
264 store.toggleIsCollapsed(element.id, false);
265 } else {
266 dispatch({type: 'SELECT_CHILD_ELEMENT_IN_TREE'});
267 }
268 }
269 }
270 break;
271 case 'ArrowUp':
272 event.preventDefault();
273 if (event.altKey) {
274 dispatch({type: 'SELECT_PREVIOUS_SIBLING_IN_TREE'});
275 } else {
276 dispatch({type: 'SELECT_PREVIOUS_ELEMENT_IN_TREE'});
277 }
278 break;
279 default:
280 return;
281 }
282 setIsNavigatingWithKeyboard(true);
283 };
284
285 // We used to listen to at the document level for this event.
286 // That allowed us to listen to up/down arrow key events while another section
287 // of DevTools (like the search input) was focused.
288 // This was a minor UX positive.
289 //
290 // (We had to use ownerDocument rather than document for this, because the
291 // DevTools extension renders the Components and Profiler tabs into portals.)
292 //
293 // This approach caused a problem though: it meant that a react-devtools-inline
294 // instance could steal (and prevent/block) keyboard events from other JavaScript
295 // on the page– which could even include other react-devtools-inline instances.
296 // This is a potential major UX negative.
297 //
298 // Given the above trade offs, we now listen on the root of the Tree itself.
299 const container = treeRef.current;
300 container.addEventListener('keydown', handleKeyDown);
301
302 return () => {
303 container.removeEventListener('keydown', handleKeyDown);
304 };
305 }, [dispatch, inspectedElementID, store]);
306
307 // Focus management.
308 const handleBlur = useCallback(() => setTreeFocused(false), []);
309 const handleFocus = useCallback(() => setTreeFocused(true), []);
310
311 const changeActivitySliceAction = useChangeActivitySliceAction();
312 const changeOwnerAction = useChangeOwnerAction();
313 const handleKeyPress = useCallback(
314 (event: $FlowFixMe) => {
315 switch (event.key) {
316 case 'Enter':
317 case ' ':
318 if (inspectedElementID !== null) {
319 const inspectedElement = store.getElementByID(inspectedElementID);
320 startTransition(() => {
321 if (
322 inspectedElement !== null &&
323 inspectedElement.type === ElementTypeActivity
324 ) {
325 changeActivitySliceAction(inspectedElementID);
326 } else {
327 changeOwnerAction(inspectedElementID);
328 }
329 });
330 }
331 break;
332 default:
333 break;
334 }
335 },
336 [dispatch, inspectedElementID],
337 );
338
339 // If we switch the selected element while using the keyboard,
340 // start highlighting it in the DOM instead of the last hovered node.
341 const searchRef = useRef({searchIndex, searchResults});
342 useEffect(() => {
343 let didSelectNewSearchResult = false;
344 if (
345 searchRef.current.searchIndex !== searchIndex ||
346 searchRef.current.searchResults !== searchResults
347 ) {
348 searchRef.current.searchIndex = searchIndex;
349 searchRef.current.searchResults = searchResults;
350 didSelectNewSearchResult = true;
351 }
352 if (isNavigatingWithKeyboard || didSelectNewSearchResult) {
353 if (inspectedElementID !== null) {
354 highlightHostInstance(inspectedElementID);
355 } else {
356 clearHighlightHostInstance();
357 }
358 }
359 }, [
360 bridge,
361 isNavigatingWithKeyboard,
362 highlightHostInstance,
363 searchIndex,
364 searchResults,
365 inspectedElementID,
366 ]);
367
368 // Highlight last hovered element.
369 const handleElementMouseEnter = useCallback(
370 (id: $FlowFixMe) => {
371 // Ignore hover while we're navigating with keyboard.
372 // This avoids flicker from the hovered nodes under the mouse.
373 if (!isNavigatingWithKeyboard) {
374 highlightHostInstance(id);
375 }
376 },
377 [isNavigatingWithKeyboard, highlightHostInstance],
378 );
379
380 const handleMouseMove = useCallback(() => {
381 // We started using the mouse again.
382 // This will enable hover styles in individual rows.
383 setIsNavigatingWithKeyboard(false);
384 }, []);
385
386 const handleMouseLeave = clearHighlightHostInstance;
387
388 // The synthetic onMouseLeave on the tree div only fires within the document,
389 // so we need a native listener on the document itself.
390 useEffect(() => {
391 const container = focusTargetRef.current;
392 if (container == null) {
393 return;
394 }
395 const ownerDocument = container.ownerDocument;
396 ownerDocument.addEventListener('mouseleave', clearHighlightHostInstance);
397 return () => {
398 ownerDocument.removeEventListener(
399 'mouseleave',
400 clearHighlightHostInstance,
401 );
402 };
403 }, [clearHighlightHostInstance]);
404
405 // Let react-window know to re-render any time the underlying tree data changes.
406 // This includes the owner context, since it controls a filtered view of the tree.
407 const itemData = useMemo<ItemData>(
408 () => ({
409 isNavigatingWithKeyboard,
410 onElementMouseEnter: handleElementMouseEnter,
411 treeFocused,
412 calculateElementOffset,
413 }),
414 [
415 isNavigatingWithKeyboard,
416 handleElementMouseEnter,
417 treeFocused,
418 calculateElementOffset,
419 ],
420 );
421
422 const itemKey = useCallback(
423 (index: number) => store.getElementIDAtIndex(index),
424 [store],
425 );
426
427 const handlePreviousErrorOrWarningClick = React.useCallback(() => {
428 dispatch({type: 'SELECT_PREVIOUS_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE'});
429 }, []);
430
431 const handleNextErrorOrWarningClick = React.useCallback(() => {
432 dispatch({type: 'SELECT_NEXT_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE'});
433 }, []);
434
435 const errorsOrWarningsSubscription = useMemo(
436 () => ({
437 getCurrentValue: () => ({
438 errors: store.componentWithErrorCount,
439 warnings: store.componentWithWarningCount,
440 }),
441 subscribe: (callback: Function) => {
442 store.addListener('mutated', callback);
443 return () => store.removeListener('mutated', callback);
444 },
445 }),
446 [store],
447 );
448 const {errors, warnings} = useSubscription(errorsOrWarningsSubscription);
449
450 const clearErrorsAndWarnings = () => {
451 clearErrorsAndWarningsAPI({bridge, store});
452 };
453
454 const zeroElementsNotice = (
455 <div className={styles.ZeroElementsNotice}>
456 <p>Loading React Element Tree...</p>
457 <p>
458 If this seems stuck, please follow the{' '}
459 <a
460 className={styles.Link}
461 href="https://github.com/facebook/react/blob/main/packages/react-devtools/README.md#the-react-tab-shows-no-components"
462 target="_blank">
463 troubleshooting instructions
464 </a>
465 .
466 </p>
467 </div>
468 );
469
470 return (
471 <TreeFocusedContext.Provider value={treeFocused}>
472 <div className={styles.Tree} ref={treeRef}>
473 <div className={styles.SearchInput}>
474 {store.supportsClickToInspect && (
475 <Fragment>
476 <InspectHostNodesToggle />
477 <div className={styles.VRule} />
478 </Fragment>
479 )}
480 <Suspense fallback={<Loading />}>
481 {ownerID !== null ? (
482 <OwnersStack />
483 ) : activityID !== null ? (
484 <ActivitySlice />
485 ) : (
486 <ComponentSearchInput />
487 )}
488 </Suspense>
489 {ownerID === null && (errors > 0 || warnings > 0) && (
490 <React.Fragment>
491 <div className={styles.VRule} />
492 {errors > 0 && (
493 <div className={styles.IconAndCount}>
494 <Icon className={styles.ErrorIcon} type="error" />
495 {errors}
496 </div>
497 )}
498 {warnings > 0 && (
499 <div className={styles.IconAndCount}>
500 <Icon className={styles.WarningIcon} type="warning" />
501 {warnings}
502 </div>
503 )}
504 <Button
505 onClick={handlePreviousErrorOrWarningClick}
506 title="Scroll to previous error or warning">
507 <ButtonIcon type="up" />
508 </Button>
509 <Button
510 onClick={handleNextErrorOrWarningClick}
511 title="Scroll to next error or warning">
512 <ButtonIcon type="down" />
513 </Button>
514 <Button
515 onClick={clearErrorsAndWarnings}
516 title="Clear all errors and warnings">
517 <ButtonIcon type="clear" />
518 </Button>
519 </React.Fragment>
520 )}
521 {!hideSettings && (
522 <Fragment>
523 <div className={styles.VRule} />
524 <SettingsModalContextToggle />
525 </Fragment>
526 )}
527 </div>
528 {numElements === 0 ? (
529 zeroElementsNotice
530 ) : (
531 <div
532 className={styles.AutoSizerWrapper}
533 onBlur={handleBlur}
534 onFocus={handleFocus}
535 onKeyPress={handleKeyPress}
536 onMouseMove={handleMouseMove}
537 onMouseLeave={handleMouseLeave}
538 ref={focusTargetRef}
539 tabIndex={0}>
540 <AutoSizer>
541 {({height, width}) => (
542 <FixedSizeList
543 className={styles.List}
544 height={height}
545 initialScrollOffset={calculateInitialScrollOffset(
546 inspectedElementIndex,
547 lineHeight,
548 )}
549 innerElementType={InnerElementType}
550 itemCount={numElements}
551 itemData={itemData}
552 itemKey={itemKey}
553 itemSize={lineHeight}
554 outerRef={setListDOMElementRef}
555 overscanCount={10}
556 width={width}>
557 {ComponentsTreeElement}
558 </FixedSizeList>
559 )}
560 </AutoSizer>
561 </div>
562 )}
563 </div>
564 </TreeFocusedContext.Provider>
565 );
566 }
567
568 // $FlowFixMe[missing-local-annot]
569 function InnerElementType({children, style}) {
570 const store = useContext(StoreContext);
571
572 const {height} = style;
573 const maxDepth = store.getMaximumRecordedDepth();
574 // Maximum possible indentation plus some arbitrary offset for the node content.
575 const width = calculateElementOffset(maxDepth) + 500;
576
577 return (
578 <div className={styles.InnerElementType} style={{height, width}}>
579 {children}
580
581 <VerticalDelimiter />
582 </div>
583 );
584 }
585
586 function VerticalDelimiter() {
587 const store = useContext(StoreContext);
588 const {ownerID, inspectedElementIndex} = useContext(TreeStateContext);
589 const {lineHeight} = useContext(SettingsContext);
590
591 if (ownerID != null || inspectedElementIndex == null) {
592 return null;
593 }
594
595 const element = store.getElementAtIndex(inspectedElementIndex);
596 if (element == null) {
597 return null;
598 }
599 const indexOfLowestDescendant =
600 store.getIndexOfLowestDescendantElement(element);
601 if (indexOfLowestDescendant == null) {
602 return null;
603 }
604
605 const delimiterLeft = calculateElementOffset(element.depth) + 12;
606 const delimiterTop = (inspectedElementIndex + 1) * lineHeight;
607 const delimiterHeight =
608 (indexOfLowestDescendant + 1) * lineHeight - delimiterTop;
609
610 return (
611 <div
612 className={styles.VerticalDelimiter}
613 style={{
614 left: delimiterLeft,
615 top: delimiterTop,
616 height: delimiterHeight,
617 }}
618 />
619 );
620 }
621
622 function Loading() {
623 return <div className={styles.Loading}>Loading...</div>;
624 }