diff --git a/packages/react-devtools-shared/src/devtools/views/Components/ProfilerWhatChanged.css b/packages/react-devtools-shared/src/devtools/views/Components/ProfilerWhatChanged.css new file mode 100644 index 0000000000000..1e0dd8e597015 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/ProfilerWhatChanged.css @@ -0,0 +1,30 @@ +.Component { + margin-bottom: 1rem; +} + +.Item { + margin-top: 0.25rem; +} + +.Key { + font-family: var(--font-family-monospace); + font-size: var(--font-size-monospace-small); + line-height: 1; +} + +.Key:first-of-type::before { + content: ' ('; +} + +.Key::after { + content: ', '; +} + +.Key:last-of-type::after { + content: ')'; +} + +.Label { + font-weight: bold; + margin-bottom: 0.5rem; +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/ProfilerWhatChanged.js b/packages/react-devtools-shared/src/devtools/views/Components/ProfilerWhatChanged.js new file mode 100644 index 0000000000000..75846da1d11e6 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/ProfilerWhatChanged.js @@ -0,0 +1,138 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 React, {useContext} from 'react'; +import {ProfilerContext} from '../Profiler/ProfilerContext'; +import {StoreContext} from '../context'; + +import styles from './ProfilerWhatChanged.css'; + +type ProfilerWhatChangedProps = {| + fiberID: number, +|}; + +export default function ProfilerWhatChanged({ + fiberID, +}: ProfilerWhatChangedProps) { + const {profilerStore} = useContext(StoreContext); + const {rootID, selectedCommitIndex} = useContext(ProfilerContext); + + // TRICKY + // Handle edge case where no commit is selected because of a min-duration filter update. + // If the commit index is null, suspending for data below would throw an error. + // TODO (ProfilerContext) This check should not be necessary. + if (selectedCommitIndex === null) { + return null; + } + + const {changeDescriptions} = profilerStore.getCommitData( + ((rootID: any): number), + selectedCommitIndex, + ); + + if (changeDescriptions === null) { + return null; + } + + const changeDescription = changeDescriptions.get(fiberID); + if (changeDescription == null) { + return null; + } + + if (changeDescription.isFirstMount) { + return ( +
+ +
+ This is the first time the component rendered. +
+
+ ); + } + + const changes = []; + + if (changeDescription.context === true) { + changes.push( +
+ • Context changed +
, + ); + } else if ( + typeof changeDescription.context === 'object' && + changeDescription.context !== null && + changeDescription.context.length !== 0 + ) { + changes.push( +
+ • Context changed: + {changeDescription.context.map(key => ( + + {key} + + ))} +
, + ); + } + + if (changeDescription.didHooksChange) { + changes.push( +
+ • Hooks changed +
, + ); + } + + if ( + changeDescription.props !== null && + changeDescription.props.length !== 0 + ) { + changes.push( +
+ • Props changed: + {changeDescription.props.map(key => ( + + {key} + + ))} +
, + ); + } + + if ( + changeDescription.state !== null && + changeDescription.state.length !== 0 + ) { + changes.push( +
+ • State changed: + {changeDescription.state.map(key => ( + + {key} + + ))} +
, + ); + } + + if (changes.length === 0) { + changes.push( +
+ The parent component rendered. +
, + ); + } + + return ( +
+ + {changes} +
+ ); +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Tooltip.css b/packages/react-devtools-shared/src/devtools/views/Components/Tooltip.css new file mode 100644 index 0000000000000..2d2bbaf34ea38 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/Tooltip.css @@ -0,0 +1,24 @@ +.Tooltip { + position: absolute; + pointer-events: none; + border: none; + border-radius: 0.25rem; + padding: 0.25rem 0.5rem; + font-family: var(--font-family-sans); + font-size: 12px; + background-color: var(--color-tooltip-background); + color: var(--color-tooltip-text); + opacity: 1; + /* Make sure this is above the DevTools, which are above the Overlay */ + z-index: 10000002; +} + +.Tooltip.hidden { + opacity: 0; +} + + +.Container { + width: -moz-max-content; + width: -webkit-max-content; +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Tooltip.js b/packages/react-devtools-shared/src/devtools/views/Components/Tooltip.js new file mode 100644 index 0000000000000..5be379ee668da --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/Tooltip.js @@ -0,0 +1,106 @@ +/** @flow */ + +import React, {useRef} from 'react'; + +import styles from './Tooltip.css'; + +const initialTooltipState = {height: 0, mouseX: 0, mouseY: 0, width: 0}; + +export default function Tooltip({children, label}: any) { + const containerRef = useRef(null); + const tooltipRef = useRef(null); + + // update the position of the tooltip based on current mouse position + const updateTooltipPosition = (event: SyntheticMouseEvent<*>) => { + const element = tooltipRef.current; + if (element != null) { + // first find the mouse position + const mousePosition = getMousePosition(containerRef.current, event); + // use the mouse position to find the position of tooltip + const {left, top} = getTooltipPosition(element, mousePosition); + // update tooltip position + element.style.left = left; + element.style.top = top; + } + }; + + const onMouseMove = (event: SyntheticMouseEvent<*>) => { + updateTooltipPosition(event); + }; + + const tooltipClassName = label === null ? styles.hidden : ''; + + return ( +
+
+ {label} +
+ {children} +
+ ); +} + +// Method used to find the position of the tooltip based on current mouse position +function getTooltipPosition(element, mousePosition) { + const {height, mouseX, mouseY, width} = mousePosition; + const TOOLTIP_OFFSET_X = 5; + const TOOLTIP_OFFSET_Y = 15; + let top = 0; + let left = 0; + + // Let's check the vertical position. + if (mouseY + TOOLTIP_OFFSET_Y + element.offsetHeight >= height) { + // The tooltip doesn't fit below the mouse cursor (which is our + // default strategy). Therefore we try to position it either above the + // mouse cursor or finally aligned with the window's top edge. + if (mouseY - TOOLTIP_OFFSET_Y - element.offsetHeight > 0) { + // We position the tooltip above the mouse cursor if it fits there. + top = `${mouseY - element.offsetHeight - TOOLTIP_OFFSET_Y}px`; + } else { + // Otherwise we align the tooltip with the window's top edge. + top = '0px'; + } + } else { + top = `${mouseY + TOOLTIP_OFFSET_Y}px`; + } + + // Now let's check the horizontal position. + if (mouseX + TOOLTIP_OFFSET_X + element.offsetWidth >= width) { + // The tooltip doesn't fit at the right of the mouse cursor (which is + // our default strategy). Therefore we try to position it either at the + // left of the mouse cursor or finally aligned with the window's left + // edge. + if (mouseX - TOOLTIP_OFFSET_X - element.offsetWidth > 0) { + // We position the tooltip at the left of the mouse cursor if it fits + // there. + left = `${mouseX - element.offsetWidth - TOOLTIP_OFFSET_X}px`; + } else { + // Otherwise, align the tooltip with the window's left edge. + left = '0px'; + } + } else { + left = `${mouseX + TOOLTIP_OFFSET_X * 2}px`; + } + + return {left, top}; +} + +// method used to find the current mouse position inside the container +function getMousePosition( + relativeContainer, + mouseEvent: SyntheticMouseEvent<*>, +) { + if (relativeContainer !== null) { + const {height, top, width} = relativeContainer.getBoundingClientRect(); + + const mouseX = mouseEvent.clientX; + const mouseY = mouseEvent.clientY - top; + + return {height, mouseX, mouseY, width}; + } else { + return initialTooltipState; + } +} diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/ChartNode.js b/packages/react-devtools-shared/src/devtools/views/Profiler/ChartNode.js index 4a85d18fe8478..3f7c7b4a1f732 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/ChartNode.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/ChartNode.js @@ -18,6 +18,8 @@ type Props = {| label: string, onClick: (event: SyntheticMouseEvent<*>) => mixed, onDoubleClick?: (event: SyntheticMouseEvent<*>) => mixed, + onMouseEnter: (event: SyntheticMouseEvent<*>) => mixed, + onMouseLeave: (event: SyntheticMouseEvent<*>) => mixed, placeLabelAboveNode?: boolean, textStyle?: Object, width: number, @@ -33,6 +35,8 @@ export default function ChartNode({ isDimmed = false, label, onClick, + onMouseEnter, + onMouseLeave, onDoubleClick, textStyle, width, @@ -41,12 +45,13 @@ export default function ChartNode({ }: Props) { return ( - {label} void, scaleX: (value: number, fallbackValue: number) => number, selectedChartNode: ChartNode | null, selectedChartNodeIndex: number, @@ -91,6 +101,7 @@ type Props = {| |}; function CommitFlamegraph({chartData, commitTree, height, width}: Props) { + const [hoveredFiberData, hoverFiber] = useState(null); const {lineHeight} = useContext(SettingsContext); const {selectFiber, selectedFiberID} = useContext(ProfilerContext); @@ -118,6 +129,7 @@ function CommitFlamegraph({chartData, commitTree, height, width}: Props) { const itemData = useMemo( () => ({ chartData, + hoverFiber, scaleX: scale( 0, selectedChartNode !== null @@ -131,19 +143,37 @@ function CommitFlamegraph({chartData, commitTree, height, width}: Props) { selectFiber, width, }), - [chartData, selectedChartNode, selectedChartNodeIndex, selectFiber, width], + [ + chartData, + hoverFiber, + selectedChartNode, + selectedChartNodeIndex, + selectFiber, + width, + ], + ); + + // Tooltip used to show summary of fiber info on hover + const tooltipLabel = useMemo( + () => + hoveredFiberData !== null ? ( + + ) : null, + [hoveredFiberData], ); return ( - - {CommitFlamegraphListItem} - + + + {CommitFlamegraphListItem} + + ); } diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitFlamegraphListItem.js b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitFlamegraphListItem.js index 66d2db36962c4..bf511d879326e 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitFlamegraphListItem.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitFlamegraphListItem.js @@ -14,6 +14,7 @@ import {getGradientColor} from './utils'; import ChartNode from './ChartNode'; import {SettingsContext} from '../Settings/SettingsContext'; +import type {ChartNode as ChartNodeType} from './FlamegraphChartBuilder'; import type {ItemData} from './CommitFlamegraph'; type Props = { @@ -26,6 +27,7 @@ type Props = { function CommitFlamegraphListItem({data, index, style}: Props) { const { chartData, + hoverFiber, scaleX, selectedChartNode, selectedChartNodeIndex, @@ -35,6 +37,7 @@ function CommitFlamegraphListItem({data, index, style}: Props) { const {renderPathNodes, maxSelfDuration, rows} = chartData; const {lineHeight} = useContext(SettingsContext); + const handleClick = useCallback( (event: SyntheticMouseEvent<*>, id: number, name: string) => { event.stopPropagation(); @@ -43,6 +46,15 @@ function CommitFlamegraphListItem({data, index, style}: Props) { [selectFiber], ); + const handleMouseEnter = (nodeData: ChartNodeType) => { + const {id, name} = nodeData; + hoverFiber({id, name}); + }; + + const handleMouseLeave = () => { + hoverFiber(null); + }; + // List items are absolutely positioned using the CSS "top" attribute. // The "left" value will always be 0. // Since height is fixed, and width is based on the node's duration, @@ -104,6 +116,8 @@ function CommitFlamegraphListItem({data, index, style}: Props) { key={id} label={label} onClick={event => handleClick(event, id, name)} + onMouseEnter={() => handleMouseEnter(chartNode)} + onMouseLeave={handleMouseLeave} textStyle={{color: textColor}} width={nodeWidth} x={nodeOffset - selectedNodeOffset} diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitRanked.js b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitRanked.js index f230f67359920..89bd0d8e82198 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitRanked.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitRanked.js @@ -7,23 +7,27 @@ * @flow */ -import React, {useCallback, useContext, useMemo} from 'react'; +import React, {useCallback, useContext, useMemo, useState} from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import {FixedSizeList} from 'react-window'; import {ProfilerContext} from './ProfilerContext'; import NoCommitData from './NoCommitData'; import CommitRankedListItem from './CommitRankedListItem'; +import HoveredFiberInfo from './HoveredFiberInfo'; import {scale} from './utils'; import {StoreContext} from '../context'; import {SettingsContext} from '../Settings/SettingsContext'; +import Tooltip from '../Components/Tooltip'; import styles from './CommitRanked.css'; +import type {TooltipFiberData} from './HoveredFiberInfo'; import type {ChartData} from './RankedChartBuilder'; import type {CommitTree} from './types'; export type ItemData = {| chartData: ChartData, + hoverFiber: (fiberData: TooltipFiberData | null) => void, scaleX: (value: number, fallbackValue: number) => number, selectedFiberID: number | null, selectedFiberIndex: number, @@ -89,6 +93,7 @@ type Props = {| |}; function CommitRanked({chartData, commitTree, height, width}: Props) { + const [hoveredFiberData, hoverFiber] = useState(null); const {lineHeight} = useContext(SettingsContext); const {selectedFiberID, selectFiber} = useContext(ProfilerContext); @@ -100,6 +105,7 @@ function CommitRanked({chartData, commitTree, height, width}: Props) { const itemData = useMemo( () => ({ chartData, + hoverFiber, scaleX: scale(0, chartData.nodes[selectedFiberIndex].value, 0, width), selectedFiberID, selectedFiberIndex, @@ -109,16 +115,28 @@ function CommitRanked({chartData, commitTree, height, width}: Props) { [chartData, selectedFiberID, selectedFiberIndex, selectFiber, width], ); + // Tooltip used to show summary of fiber info on hover + const tooltipLabel = useMemo( + () => + hoveredFiberData !== null ? ( + + ) : null, + [hoveredFiberData], + ); + return ( - - {CommitRankedListItem} - + + + {CommitRankedListItem} + + > + ); } diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitRankedListItem.js b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitRankedListItem.js index 8c46e7de2730b..80f6dbade6e29 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitRankedListItem.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitRankedListItem.js @@ -24,7 +24,14 @@ type Props = { }; function CommitRankedListItem({data, index, style}: Props) { - const {chartData, scaleX, selectedFiberIndex, selectFiber, width} = data; + const { + chartData, + hoverFiber, + scaleX, + selectedFiberIndex, + selectFiber, + width, + } = data; const node = chartData.nodes[index]; @@ -33,11 +40,21 @@ function CommitRankedListItem({data, index, style}: Props) { const handleClick = useCallback( event => { event.stopPropagation(); - selectFiber(node.id, node.name); + const {id, name} = node; + selectFiber(id, name); }, [node, selectFiber], ); + const handleMouseEnter = () => { + const {id, name} = node; + hoverFiber({id, name}); + }; + + const handleMouseLeave = () => { + hoverFiber(null); + }; + // List items are absolutely positioned using the CSS "top" attribute. // The "left" value will always be 0. // Since height is fixed, and width is based on the node's duration, @@ -52,6 +69,8 @@ function CommitRankedListItem({data, index, style}: Props) { key={node.id} label={node.label} onClick={handleClick} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} width={Math.max(minBarWidth, scaleX(node.value, width))} x={0} y={top} diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/HoveredFiberInfo.css b/packages/react-devtools-shared/src/devtools/views/Profiler/HoveredFiberInfo.css new file mode 100644 index 0000000000000..14afb8d719e05 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/HoveredFiberInfo.css @@ -0,0 +1,38 @@ +.Toolbar { + height: 2.25rem; + padding: 0 0.5rem; + flex: 0 0 auto; + display: flex; + align-items: center; + border-bottom: 1px solid var(--color-border); +} + +.Content { + padding: 0.5rem; + user-select: none; + overflow-y: auto; +} + +.Component { + flex: 1; + font-weight: bold; + font-family: var(--font-family-monospace); + font-size: var(--font-size-monospace-normal); + white-space: nowrap; + overflow-x: hidden; + text-overflow: ellipsis; +} + +.Label { + font-weight: bold; + margin-bottom: 0.5rem; +} + +.CurrentCommit { + display: block; + width: 100%; + text-align: left; + background: none; + border: none; + padding: 0.25rem 0.5rem; +} diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/HoveredFiberInfo.js b/packages/react-devtools-shared/src/devtools/views/Profiler/HoveredFiberInfo.js new file mode 100644 index 0000000000000..d6b102cd5f2de --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/HoveredFiberInfo.js @@ -0,0 +1,77 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 React, {Fragment, useContext} from 'react'; +import {ProfilerContext} from './ProfilerContext'; +import {formatDuration, formatTime} from './utils'; +import ProfilerWhatChanged from '../Components/ProfilerWhatChanged'; +import {StoreContext} from '../context'; + +import styles from './HoveredFiberInfo.css'; + +import type {ChartNode} from './FlamegraphChartBuilder'; + +export type TooltipFiberData = {| + id: number, + name: string, +|}; + +export type Props = { + fiberData: ChartNode, +}; + +export default function HoveredFiberInfo({fiberData}: Props) { + const {profilerStore} = useContext(StoreContext); + const {rootID, selectedCommitIndex} = useContext(ProfilerContext); + + const {id, name} = fiberData; + const {profilingCache} = profilerStore; + + const commitIndices = profilingCache.getFiberCommits({ + fiberID: ((id: any): number), + rootID: ((rootID: any): number), + }); + + let renderDurationInfo; + let i = 0; + for (i = 0; i < commitIndices.length; i++) { + const commitIndex = commitIndices[i]; + if (selectedCommitIndex === commitIndex) { + const {duration, timestamp} = profilerStore.getCommitData( + ((rootID: any): number), + commitIndex, + ); + + renderDurationInfo = ( + + +
+ {formatTime(timestamp)}s for {formatDuration(duration)}ms +
+
+ ); + + break; + } + } + + return ( + +
+
{name}
+
+
+ + {renderDurationInfo || ( +
Did not render during this profiling session.
+ )} +
+
+ ); +} diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarSelectedFiberInfo.css b/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarSelectedFiberInfo.css index 2ea9526d9653a..de19c10391653 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarSelectedFiberInfo.css +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarSelectedFiberInfo.css @@ -42,6 +42,7 @@ padding: 0.25rem 0.5rem; color: var(--color-text); } + .Commit:focus, .Commit:hover { outline: none; @@ -52,25 +53,7 @@ background-color: var(--color-background-selected); color: var(--color-text-selected); } + .CurrentCommit:focus { outline: none; } - -.WhatChangedItem { - margin-top: 0.25rem; -} - -.WhatChangedKey { - font-family: var(--font-family-monospace); - font-size: var(--font-size-monospace-small); - line-height: 1; -} -.WhatChangedKey:first-of-type::before { - content: ' ('; -} -.WhatChangedKey::after { - content: ', '; -} -.WhatChangedKey:last-of-type::after { - content: ')'; -} diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarSelectedFiberInfo.js b/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarSelectedFiberInfo.js index 595d0e0e17439..0cef165762a1e 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarSelectedFiberInfo.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarSelectedFiberInfo.js @@ -8,7 +8,7 @@ */ import React, {Fragment, useContext} from 'react'; -import ProfilerStore from 'react-devtools-shared/src/devtools/ProfilerStore'; +import ProfilerWhatChanged from '../Components/ProfilerWhatChanged'; import {ProfilerContext} from './ProfilerContext'; import {formatDuration, formatTime} from './utils'; import {StoreContext} from '../context'; @@ -75,12 +75,7 @@ export default function SidebarSelectedFiberInfo(_: Props) {
- + {listItems.length > 0 && ( : {listItems} @@ -93,129 +88,3 @@ export default function SidebarSelectedFiberInfo(_: Props) { ); } - -type WhatChangedProps = {| - commitIndex: number | null, - fiberID: number, - profilerStore: ProfilerStore, - rootID: number, -|}; - -function WhatChanged({ - commitIndex, - fiberID, - profilerStore, - rootID, -}: WhatChangedProps) { - // TRICKY - // Handle edge case where no commit is selected because of a min-duration filter update. - // If the commit index is null, suspending for data below would throw an error. - // TODO (ProfilerContext) This check should not be necessary. - if (commitIndex === null) { - return null; - } - - const {changeDescriptions} = profilerStore.getCommitData( - ((rootID: any): number), - commitIndex, - ); - if (changeDescriptions === null) { - return null; - } - - const changeDescription = changeDescriptions.get(fiberID); - if (changeDescription == null) { - return null; - } - - if (changeDescription.isFirstMount) { - return ( -
- -
- This is the first time the component rendered. -
-
- ); - } - - const changes = []; - - if (changeDescription.context === true) { - changes.push( -
- • Context changed -
, - ); - } else if ( - typeof changeDescription.context === 'object' && - changeDescription.context !== null && - changeDescription.context.length !== 0 - ) { - changes.push( -
- • Context changed: - {changeDescription.context.map(key => ( - - {key} - - ))} -
, - ); - } - - if (changeDescription.didHooksChange) { - changes.push( -
- • Hooks changed -
, - ); - } - - if ( - changeDescription.props !== null && - changeDescription.props.length !== 0 - ) { - changes.push( -
- • Props changed: - {changeDescription.props.map(key => ( - - {key} - - ))} -
, - ); - } - - if ( - changeDescription.state !== null && - changeDescription.state.length !== 0 - ) { - changes.push( -
- • State changed: - {changeDescription.state.map(key => ( - - {key} - - ))} -
, - ); - } - - if (changes.length === 0) { - changes.push( -
- The parent component rendered. -
, - ); - } - - return ( -
- - {changes} -
- ); -}