diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index 3895217053df1..97c3adc88f032 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -195,6 +195,10 @@ export default class Store extends EventEmitter<{ // Only used in browser extension for synchronization with built-in Elements panel. _lastSelectedHostInstanceElementId: Element['id'] | null = null; + // Maximum recorded node depth during the lifetime of this Store. + // Can only increase: not guaranteed to return maximal value for currently recorded elements. + _maximumRecordedDepth = 0; + constructor(bridge: FrontendBridge, config?: Config) { super(); @@ -698,6 +702,50 @@ export default class Store extends EventEmitter<{ return index; } + isDescendantOf(parentId: number, descendantId: number): boolean { + if (descendantId === 0) { + return false; + } + + const descendant = this.getElementByID(descendantId); + if (descendant === null) { + return false; + } + + if (descendant.parentID === parentId) { + return true; + } + + const parent = this.getElementByID(parentId); + if (!parent || parent.depth >= descendant.depth) { + return false; + } + + return this.isDescendantOf(parentId, descendant.parentID); + } + + /** + * Returns index of the lowest descendant element, if available. + * May not be the deepest element, the lowest is used in a sense of bottom-most from UI Tree representation perspective. + */ + getIndexOfLowestDescendantElement(element: Element): number | null { + let current: null | Element = element; + while (current !== null) { + if (current.isCollapsed || current.children.length === 0) { + if (current === element) { + return null; + } + + return this.getIndexOfElementID(current.id); + } else { + const lastChildID = current.children[current.children.length - 1]; + current = this.getElementByID(lastChildID); + } + } + + return null; + } + getOwnersListForElement(ownerID: number): Array { const list: Array = []; const element = this._idToElement.get(ownerID); @@ -1089,9 +1137,15 @@ export default class Store extends EventEmitter<{ compiledWithForget, } = parseElementDisplayNameFromBackend(displayName, type); + const elementDepth = parentElement.depth + 1; + this._maximumRecordedDepth = Math.max( + this._maximumRecordedDepth, + elementDepth, + ); + const element: Element = { children: [], - depth: parentElement.depth + 1, + depth: elementDepth, displayName: displayNameWithoutHOCs, hocDisplayNames, id, @@ -1536,6 +1590,14 @@ export default class Store extends EventEmitter<{ } }; + /** + * Maximum recorded node depth during the lifetime of this Store. + * Can only increase: not guaranteed to return maximal value for currently recorded elements. + */ + getMaximumRecordedDepth(): number { + return this._maximumRecordedDepth; + } + updateHookSettings: (settings: $ReadOnly) => void = settings => { this._hookSettings = settings; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Components.js b/packages/react-devtools-shared/src/devtools/views/Components/Components.js index b7ce607685051..1d99317ba0909 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Components.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/Components.js @@ -176,7 +176,7 @@ function Components(_: {}) { const LOCAL_STORAGE_KEY = 'React::DevTools::createResizeReducer'; const VERTICAL_MODE_MAX_WIDTH = 600; -const MINIMUM_SIZE = 50; +const MINIMUM_SIZE = 100; function initResizeState(): ResizeState { let horizontalPercentage = 0.65; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Element.css b/packages/react-devtools-shared/src/devtools/views/Components/Element.css index b11e321e2e6d5..c25eddbdbb42c 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Element.css +++ b/packages/react-devtools-shared/src/devtools/views/Components/Element.css @@ -1,7 +1,9 @@ .Element, +.HoveredElement, .InactiveSelectedElement, -.SelectedElement, -.HoveredElement { +.HighlightedElement, +.InactiveHighlightedElement, +.SelectedElement { color: var(--color-component-name); } .HoveredElement { @@ -10,8 +12,15 @@ .InactiveSelectedElement { background-color: var(--color-background-inactive); } +.HighlightedElement { + background-color: var(--color-selected-tree-highlight-active); +} +.InactiveHighlightedElement { + background-color: var(--color-selected-tree-highlight-inactive); +} .Wrapper { + position: relative; padding: 0 0.25rem; white-space: pre; height: var(--line-height-data); diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Element.js b/packages/react-devtools-shared/src/devtools/views/Components/Element.js index 71e0ebfbe9cbe..c3ddf1da07518 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Element.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/Element.js @@ -45,10 +45,6 @@ export default function Element({data, index, style}: Props): React.Node { const [isHovered, setIsHovered] = useState(false); - const {isNavigatingWithKeyboard, onElementMouseEnter, treeFocused} = data; - const id = element === null ? null : element.id; - const isSelected = inspectedElementID === id; - const errorsAndWarningsSubscription = useMemo( () => ({ getCurrentValue: () => @@ -68,6 +64,15 @@ export default function Element({data, index, style}: Props): React.Node { }>(errorsAndWarningsSubscription); const changeOwnerAction = useChangeOwnerAction(); + + // Handle elements that are removed from the tree while an async render is in progress. + if (element == null) { + console.warn(` Could not find element at index ${index}`); + + // This return needs to happen after hooks, since hooks can't be conditional. + return null; + } + const handleDoubleClick = () => { if (id !== null) { changeOwnerAction(id); @@ -107,15 +112,8 @@ export default function Element({data, index, style}: Props): React.Node { event.preventDefault(); }; - // Handle elements that are removed from the tree while an async render is in progress. - if (element == null) { - console.warn(` Could not find element at index ${index}`); - - // This return needs to happen after hooks, since hooks can't be conditional. - return null; - } - const { + id, depth, displayName, hocDisplayNames, @@ -123,6 +121,19 @@ export default function Element({data, index, style}: Props): React.Node { key, compiledWithForget, } = element; + const { + isNavigatingWithKeyboard, + onElementMouseEnter, + treeFocused, + calculateElementOffset, + } = data; + + const isSelected = inspectedElementID === id; + const isDescendantOfSelected = + inspectedElementID !== null && + !isSelected && + store.isDescendantOf(inspectedElementID, id); + const elementOffset = calculateElementOffset(depth); // Only show strict mode non-compliance badges for top level elements. // Showing an inline badge for every element in the tree would be noisy. @@ -135,6 +146,10 @@ export default function Element({data, index, style}: Props): React.Node { : styles.InactiveSelectedElement; } else if (isHovered && !isNavigatingWithKeyboard) { className = styles.HoveredElement; + } else if (isDescendantOfSelected) { + className = treeFocused + ? styles.HighlightedElement + : styles.InactiveHighlightedElement; } return ( @@ -144,17 +159,13 @@ export default function Element({data, index, style}: Props): React.Node { onMouseLeave={handleMouseLeave} onMouseDown={handleClick} onDoubleClick={handleDoubleClick} - style={style} - data-testname="ComponentTreeListItem" - data-depth={depth}> + style={{ + ...style, + paddingLeft: elementOffset, + }} + data-testname="ComponentTreeListItem"> {/* This wrapper is used by Tree for measurement purposes. */} -
+
{ownerID === null && ( )} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/SelectedTreeHighlight.css b/packages/react-devtools-shared/src/devtools/views/Components/SelectedTreeHighlight.css deleted file mode 100644 index 19b64a8ef4702..0000000000000 --- a/packages/react-devtools-shared/src/devtools/views/Components/SelectedTreeHighlight.css +++ /dev/null @@ -1,16 +0,0 @@ -.Active, -.Inactive { - position: absolute; - left: 0; - width: 100%; - z-index: 0; - pointer-events: none; -} - -.Active { - background-color: var(--color-selected-tree-highlight-active); -} - -.Inactive { - background-color: var(--color-selected-tree-highlight-inactive); -} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/SelectedTreeHighlight.js b/packages/react-devtools-shared/src/devtools/views/Components/SelectedTreeHighlight.js deleted file mode 100644 index 16035a13d65f9..0000000000000 --- a/packages/react-devtools-shared/src/devtools/views/Components/SelectedTreeHighlight.js +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -import type {Element} from 'react-devtools-shared/src/frontend/types'; - -import * as React from 'react'; -import {useContext, useMemo} from 'react'; -import {TreeStateContext} from './TreeContext'; -import {SettingsContext} from '../Settings/SettingsContext'; -import TreeFocusedContext from './TreeFocusedContext'; -import {StoreContext} from '../context'; -import {useSubscription} from '../hooks'; - -import styles from './SelectedTreeHighlight.css'; - -type Data = { - startIndex: number, - stopIndex: number, -}; - -export default function SelectedTreeHighlight(_: {}): React.Node { - const {lineHeight} = useContext(SettingsContext); - const store = useContext(StoreContext); - const treeFocused = useContext(TreeFocusedContext); - const {ownerID, inspectedElementID} = useContext(TreeStateContext); - - const subscription = useMemo( - () => ({ - getCurrentValue: () => { - if ( - inspectedElementID === null || - store.isInsideCollapsedSubTree(inspectedElementID) - ) { - return null; - } - - const element = store.getElementByID(inspectedElementID); - if ( - element === null || - element.isCollapsed || - element.children.length === 0 - ) { - return null; - } - - const startIndex = store.getIndexOfElementID(element.children[0]); - if (startIndex === null) { - return null; - } - - let stopIndex = null; - let current: null | Element = element; - while (current !== null) { - if (current.isCollapsed || current.children.length === 0) { - // We've found the last/deepest descendant. - stopIndex = store.getIndexOfElementID(current.id); - current = null; - } else { - const lastChildID = current.children[current.children.length - 1]; - current = store.getElementByID(lastChildID); - } - } - - if (stopIndex === null) { - return null; - } - - return { - startIndex, - stopIndex, - }; - }, - subscribe: (callback: Function) => { - store.addListener('mutated', callback); - return () => { - store.removeListener('mutated', callback); - }; - }, - }), - [inspectedElementID, store], - ); - const data = useSubscription(subscription); - - if (ownerID !== null) { - return null; - } - - if (data === null) { - return null; - } - - const {startIndex, stopIndex} = data; - - return ( -
- ); -} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Tree.css b/packages/react-devtools-shared/src/devtools/views/Components/Tree.css index bf18f1d2e6019..a65cda45aac9f 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Tree.css +++ b/packages/react-devtools-shared/src/devtools/views/Components/Tree.css @@ -5,17 +5,16 @@ display: flex; flex-direction: column; border-top: 1px solid var(--color-border); - - /* Default size will be adjusted by Tree after scrolling */ - --indentation-size: 12px; } -.List { - overflow-x: hidden !important; +.InnerElementType { + position: relative; } -.InnerElementType { - overflow-x: hidden; +.VerticalDelimiter { + position: absolute; + width: 0.025rem; + background: #b0b0b0; } .SearchInput { @@ -97,4 +96,4 @@ .Link { color: var(--color-button-active); -} \ No newline at end of file +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Tree.js b/packages/react-devtools-shared/src/devtools/views/Components/Tree.js index 67cf50a07411c..4e2d0f3551fee 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Tree.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/Tree.js @@ -29,7 +29,6 @@ import InspectHostNodesToggle from './InspectHostNodesToggle'; import OwnersStack from './OwnersStack'; import ComponentSearchInput from './ComponentSearchInput'; import SettingsModalContextToggle from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContextToggle'; -import SelectedTreeHighlight from './SelectedTreeHighlight'; import TreeFocusedContext from './TreeFocusedContext'; import {useHighlightHostInstance, useSubscription} from '../hooks'; import {clearErrorsAndWarnings as clearErrorsAndWarningsAPI} from 'react-devtools-shared/src/backendAPI'; @@ -40,14 +39,18 @@ import {logEvent} from 'react-devtools-shared/src/Logger'; import {useExtensionComponentsPanelVisibility} from 'react-devtools-shared/src/frontend/hooks/useExtensionComponentsPanelVisibility'; import {useChangeOwnerAction} from './OwnersListContext'; -// Never indent more than this number of pixels (even if we have the room). -const MAX_INDENTATION_SIZE = 12; -const MIN_INDENTATION_SIZE = 4; +// Indent for each node at level N, compared to node at level N - 1. +const INDENTATION_SIZE = 10; + +function calculateElementOffset(elementDepth: number): number { + return elementDepth * INDENTATION_SIZE; +} export type ItemData = { isNavigatingWithKeyboard: boolean, onElementMouseEnter: (id: number) => void, treeFocused: boolean, + calculateElementOffset: (depth: number) => number, }; function calculateInitialScrollOffset( @@ -91,16 +94,56 @@ export default function Tree(): React.Node { const treeRef = useRef(null); const focusTargetRef = useRef(null); const listRef = useRef(null); + const listDOMElementRef = useRef(null); useEffect(() => { - if (!componentsPanelVisible) { + if (!componentsPanelVisible || inspectedElementIndex == null) { + return; + } + + const listDOMElement = listDOMElementRef.current; + if (listDOMElement == null) { return; } - if (listRef.current != null && inspectedElementIndex !== null) { - listRef.current.scrollToItem(inspectedElementIndex, 'smart'); + const viewportHeight = listDOMElement.clientHeight; + const viewportLeft = listDOMElement.scrollLeft; + const viewportRight = viewportLeft + listDOMElement.clientWidth; + const viewportTop = listDOMElement.scrollTop; + const viewportBottom = viewportTop + viewportHeight; + + const element = store.getElementAtIndex(inspectedElementIndex); + if (element == null) { + return; + } + const elementLeft = calculateElementOffset(element.depth); + // Because of virtualization, this element might not be rendered yet; we can't look up its width. + // Assuming that it may take up to the half of the vieport. + const elementRight = elementLeft + listDOMElement.clientWidth / 2; + const elementTop = inspectedElementIndex * lineHeight; + const elementBottom = elementTop + lineHeight; + + const isElementFullyVisible = + elementTop >= viewportTop && + elementBottom <= viewportBottom && + elementLeft >= viewportLeft && + elementRight <= viewportRight; + + if (!isElementFullyVisible) { + const verticalDelta = + Math.min(0, elementTop - viewportTop) + + Math.max(0, elementBottom - viewportBottom); + const horizontalDelta = + Math.min(0, elementLeft - viewportLeft) + + Math.max(0, elementRight - viewportRight); + + listDOMElement.scrollBy({ + top: verticalDelta, + left: horizontalDelta, + behavior: treeFocused && ownerID == null ? 'smooth' : 'instant', + }); } - }, [inspectedElementIndex, componentsPanelVisible]); + }, [inspectedElementIndex, componentsPanelVisible, lineHeight]); // Picking an element in the inspector should put focus into the tree. // If possible, navigation works right after picking a node. @@ -292,8 +335,14 @@ export default function Tree(): React.Node { isNavigatingWithKeyboard, onElementMouseEnter: handleElementMouseEnter, treeFocused, + calculateElementOffset, }), - [isNavigatingWithKeyboard, handleElementMouseEnter, treeFocused], + [ + isNavigatingWithKeyboard, + handleElementMouseEnter, + treeFocused, + calculateElementOffset, + ], ); const itemKey = useCallback( @@ -423,6 +472,8 @@ export default function Tree(): React.Node { itemKey={itemKey} itemSize={lineHeight} ref={listRef} + outerRef={listDOMElementRef} + overscanCount={10} width={width}> {Element} @@ -435,154 +486,57 @@ export default function Tree(): React.Node { ); } -// Indentation size can be adjusted but child width is fixed. -// We need to adjust indentations so the widest child can fit without overflowing. -// Sometimes the widest child is also the deepest in the tree: -// ┏----------------------┓ -// ┆ ┆ -// ┆ •••• ┆ -// ┆ •••••••• ┆ -// ┗----------------------┛ -// -// But this is not always the case. -// Even with the above example, a change in indentation may change the overall widest child: -// ┏----------------------┓ -// ┆ ┆ -// ┆ •• ┆ -// ┆ •••• ┆ -// ┗----------------------┛ -// -// In extreme cases this difference can be important: -// ┏----------------------┓ -// ┆ ┆ -// ┆ •• ┆ -// ┆ •••• ┆ -// ┆ •••••• ┆ -// ┆ •••••••• ┆ -// ┗----------------------┛ -// -// In the above example, the current indentation is fine, -// but if we naively assumed that the widest element is also the deepest element, -// we would end up compressing the indentation unnecessarily: -// ┏----------------------┓ -// ┆ ┆ -// ┆ • ┆ -// ┆ •• ┆ -// ┆ ••• ┆ -// ┆ •••• ┆ -// ┗----------------------┛ -// -// The way we deal with this is to compute the max indentation size that can fit each child, -// given the child's fixed width and depth within the tree. -// Then we take the smallest of these indentation sizes... -function updateIndentationSizeVar( - innerDiv: HTMLDivElement, - cachedChildWidths: WeakMap, - indentationSizeRef: {current: number}, - prevListWidthRef: {current: number}, -): void { - const list = ((innerDiv.parentElement: any): HTMLDivElement); - const listWidth = list.clientWidth; - - // Skip measurements when the Components panel is hidden. - if (listWidth === 0) { - return; - } - - // Reset the max indentation size if the width of the tree has increased. - if (listWidth > prevListWidthRef.current) { - indentationSizeRef.current = MAX_INDENTATION_SIZE; - } - prevListWidthRef.current = listWidth; - - let indentationSize: number = indentationSizeRef.current; - - // eslint-disable-next-line no-for-of-loops/no-for-of-loops - for (const child of innerDiv.children) { - const depth = parseInt(child.getAttribute('data-depth'), 10) || 0; - - let childWidth: number = 0; - - const cachedChildWidth = cachedChildWidths.get(child); - if (cachedChildWidth != null) { - childWidth = cachedChildWidth; - } else { - const {firstElementChild} = child; - - // Skip over e.g. the guideline element - if (firstElementChild != null) { - childWidth = firstElementChild.clientWidth; - cachedChildWidths.set(child, childWidth); - } - } - - const remainingWidth = Math.max(0, listWidth - childWidth); +// $FlowFixMe[missing-local-annot] +function InnerElementType({children, style}) { + const store = useContext(StoreContext); - indentationSize = Math.min(indentationSize, remainingWidth / depth); - } + const {height} = style; + const maxDepth = store.getMaximumRecordedDepth(); + // Maximum possible indentation plus some arbitrary offset for the node content. + const width = calculateElementOffset(maxDepth) + 500; - indentationSize = Math.max(indentationSize, MIN_INDENTATION_SIZE); - indentationSizeRef.current = indentationSize; + return ( +
+ {children} - list.style.setProperty('--indentation-size', `${indentationSize}px`); + +
+ ); } -// $FlowFixMe[missing-local-annot] -function InnerElementType({children, style}) { - const {ownerID} = useContext(TreeStateContext); +function VerticalDelimiter() { + const store = useContext(StoreContext); + const {ownerID, inspectedElementIndex} = useContext(TreeStateContext); + const {lineHeight} = useContext(SettingsContext); - const cachedChildWidths = useMemo>( - () => new WeakMap(), - [], - ); + if (ownerID != null || inspectedElementIndex == null) { + return null; + } - // This ref tracks the current indentation size. - // We decrease indentation to fit wider/deeper trees. - // We intentionally do not increase it again afterward, to avoid the perception of content "jumping" - // e.g. clicking to toggle/collapse a row might otherwise jump horizontally beneath your cursor, - // e.g. scrolling a wide row off screen could cause narrower rows to jump to the right some. - // - // There are two exceptions for this: - // 1. The first is when the width of the tree increases. - // The user may have resized the window specifically to make more room for DevTools. - // In either case, this should reset our max indentation size logic. - // 2. The second is when the user enters or exits an owner tree. - const indentationSizeRef = useRef(MAX_INDENTATION_SIZE); - const prevListWidthRef = useRef(0); - const prevOwnerIDRef = useRef(ownerID); - const divRef = useRef(null); - - // We shouldn't retain this width across different conceptual trees though, - // so when the user opens the "owners tree" view, we should discard the previous width. - if (ownerID !== prevOwnerIDRef.current) { - prevOwnerIDRef.current = ownerID; - indentationSizeRef.current = MAX_INDENTATION_SIZE; + const element = store.getElementAtIndex(inspectedElementIndex); + if (element == null) { + return null; + } + const indexOfLowestDescendant = + store.getIndexOfLowestDescendantElement(element); + if (indexOfLowestDescendant == null) { + return null; } - // When we render new content, measure to see if we need to shrink indentation to fit it. - useEffect(() => { - if (divRef.current !== null) { - updateIndentationSizeVar( - divRef.current, - cachedChildWidths, - indentationSizeRef, - prevListWidthRef, - ); - } - }); + const delimiterLeft = calculateElementOffset(element.depth) + 12; + const delimiterTop = (inspectedElementIndex + 1) * lineHeight; + const delimiterHeight = + (indexOfLowestDescendant + 1) * lineHeight - delimiterTop; - // This style override enables the background color to fill the full visible width, - // when combined with the CSS tweaks in Element. - // A lot of options were considered; this seemed the one that requires the least code. - // See https://github.com/bvaughn/react-devtools-experimental/issues/9 return (
- - {children} -
+ className={styles.VerticalDelimiter} + style={{ + left: delimiterLeft, + top: delimiterTop, + height: delimiterHeight, + }} + /> ); }