Add fiber summary tooltip to devtools profiling (#18048)

* Add tooltip component

* Separate logic of ProfilerWhatChanged to a component

* Add hovered Fiber info tooltip component

* Add flame graph chart tooltip

* Add commit ranked list tooltip

* Fix flow issues

* Minor improvement in filter

* Fix flickering issue

* Resolved issues on useCallbacks and mouse event listeners

* Fix lints

* Remove unnecessary useCallback
This commit is contained in:
Moji Izadmehr
2020-02-19 09:44:31 -08:00
committed by GitHub
parent 2512c309e3
commit 44e5f5e645
13 changed files with 527 additions and 176 deletions
@@ -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;
}
@@ -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 (
<div className={styles.Component}>
<label className={styles.Label}>Why did this render?</label>
<div className={styles.Item}>
This is the first time the component rendered.
</div>
</div>
);
}
const changes = [];
if (changeDescription.context === true) {
changes.push(
<div key="context" className={styles.Item}>
• Context changed
</div>,
);
} else if (
typeof changeDescription.context === 'object' &&
changeDescription.context !== null &&
changeDescription.context.length !== 0
) {
changes.push(
<div key="context" className={styles.Item}>
• Context changed:
{changeDescription.context.map(key => (
<span key={key} className={styles.Key}>
{key}
</span>
))}
</div>,
);
}
if (changeDescription.didHooksChange) {
changes.push(
<div key="hooks" className={styles.Item}>
• Hooks changed
</div>,
);
}
if (
changeDescription.props !== null &&
changeDescription.props.length !== 0
) {
changes.push(
<div key="props" className={styles.Item}>
• Props changed:
{changeDescription.props.map(key => (
<span key={key} className={styles.Key}>
{key}
</span>
))}
</div>,
);
}
if (
changeDescription.state !== null &&
changeDescription.state.length !== 0
) {
changes.push(
<div key="state" className={styles.Item}>
• State changed:
{changeDescription.state.map(key => (
<span key={key} className={styles.Key}>
{key}
</span>
))}
</div>,
);
}
if (changes.length === 0) {
changes.push(
<div key="nothing" className={styles.Item}>
The parent component rendered.
</div>,
);
}
return (
<div className={styles.Component}>
<label className={styles.Label}>Why did this render?</label>
{changes}
</div>
);
}
@@ -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;
}
@@ -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 (
<div
className={styles.Container}
onMouseMove={onMouseMove}
ref={containerRef}>
<div ref={tooltipRef} className={`${styles.Tooltip} ${tooltipClassName}`}>
{label}
</div>
{children}
</div>
);
}
// 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;
}
}
@@ -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 (
<g className={styles.Group} transform={`translate(${x},${y})`}>
<title>{label}</title>
<rect
width={width}
height={height}
fill={color}
onClick={onClick}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onDoubleClick={onDoubleClick}
className={styles.Rect}
style={{
@@ -7,23 +7,33 @@
* @flow
*/
import React, {forwardRef, useCallback, useContext, useMemo} from 'react';
import React, {
forwardRef,
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 CommitFlamegraphListItem from './CommitFlamegraphListItem';
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 './CommitFlamegraph.css';
import type {TooltipFiberData} from './HoveredFiberInfo';
import type {ChartData, ChartNode} from './FlamegraphChartBuilder';
import type {CommitTree} from './types';
export type ItemData = {|
chartData: ChartData,
hoverFiber: (fiberData: TooltipFiberData | null) => 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<number | null>(null);
const {lineHeight} = useContext(SettingsContext);
const {selectFiber, selectedFiberID} = useContext(ProfilerContext);
@@ -118,6 +129,7 @@ function CommitFlamegraph({chartData, commitTree, height, width}: Props) {
const itemData = useMemo<ItemData>(
() => ({
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 ? (
<HoveredFiberInfo fiberData={hoveredFiberData} />
) : null,
[hoveredFiberData],
);
return (
<FixedSizeList
height={height}
innerElementType={InnerElementType}
itemCount={chartData.depth}
itemData={itemData}
itemSize={lineHeight}
width={width}>
{CommitFlamegraphListItem}
</FixedSizeList>
<Tooltip label={tooltipLabel}>
<FixedSizeList
height={height}
innerElementType={InnerElementType}
itemCount={chartData.depth}
itemData={itemData}
itemSize={lineHeight}
width={width}>
{CommitFlamegraphListItem}
</FixedSizeList>
</Tooltip>
);
}
@@ -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}
@@ -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<number | null>(null);
const {lineHeight} = useContext(SettingsContext);
const {selectedFiberID, selectFiber} = useContext(ProfilerContext);
@@ -100,6 +105,7 @@ function CommitRanked({chartData, commitTree, height, width}: Props) {
const itemData = useMemo<ItemData>(
() => ({
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 ? (
<HoveredFiberInfo fiberData={hoveredFiberData} />
) : null,
[hoveredFiberData],
);
return (
<FixedSizeList
height={height}
innerElementType="svg"
itemCount={chartData.nodes.length}
itemData={itemData}
itemSize={lineHeight}
width={width}>
{CommitRankedListItem}
</FixedSizeList>
<Tooltip label={tooltipLabel}>
<FixedSizeList
height={height}
innerElementType="svg"
itemCount={chartData.nodes.length}
itemData={itemData}
itemSize={lineHeight}
width={width}>
{CommitRankedListItem}
</FixedSizeList>
>
</Tooltip>
);
}
@@ -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}
@@ -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;
}
@@ -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 = (
<Fragment>
<label className={styles.Label}>Rendered at:</label>
<div key={commitIndex} className={styles.CurrentCommit}>
{formatTime(timestamp)}s for {formatDuration(duration)}ms
</div>
</Fragment>
);
break;
}
}
return (
<Fragment>
<div className={styles.Toolbar}>
<div className={styles.Component}>{name}</div>
</div>
<div className={styles.Content}>
<ProfilerWhatChanged fiberID={((id: any): number)} />
{renderDurationInfo || (
<div>Did not render during this profiling session.</div>
)}
</div>
</Fragment>
);
}
@@ -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: ')';
}
@@ -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) {
</Button>
</div>
<div className={styles.Content}>
<WhatChanged
commitIndex={((selectedCommitIndex: any): number)}
fiberID={((selectedFiberID: any): number)}
profilerStore={profilerStore}
rootID={((rootID: any): number)}
/>
<ProfilerWhatChanged fiberID={((selectedFiberID: any): number)} />
{listItems.length > 0 && (
<Fragment>
<label className={styles.Label}>Rendered at</label>: {listItems}
@@ -93,129 +88,3 @@ export default function SidebarSelectedFiberInfo(_: Props) {
</Fragment>
);
}
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 (
<div className={styles.WhatChanged}>
<label className={styles.Label}>Why did this render?</label>
<div className={styles.WhatChangedItem}>
This is the first time the component rendered.
</div>
</div>
);
}
const changes = [];
if (changeDescription.context === true) {
changes.push(
<div key="context" className={styles.WhatChangedItem}>
• Context changed
</div>,
);
} else if (
typeof changeDescription.context === 'object' &&
changeDescription.context !== null &&
changeDescription.context.length !== 0
) {
changes.push(
<div key="context" className={styles.WhatChangedItem}>
• Context changed:
{changeDescription.context.map(key => (
<span key={key} className={styles.WhatChangedKey}>
{key}
</span>
))}
</div>,
);
}
if (changeDescription.didHooksChange) {
changes.push(
<div key="hooks" className={styles.WhatChangedItem}>
• Hooks changed
</div>,
);
}
if (
changeDescription.props !== null &&
changeDescription.props.length !== 0
) {
changes.push(
<div key="props" className={styles.WhatChangedItem}>
• Props changed:
{changeDescription.props.map(key => (
<span key={key} className={styles.WhatChangedKey}>
{key}
</span>
))}
</div>,
);
}
if (
changeDescription.state !== null &&
changeDescription.state.length !== 0
) {
changes.push(
<div key="state" className={styles.WhatChangedItem}>
• State changed:
{changeDescription.state.map(key => (
<span key={key} className={styles.WhatChangedKey}>
{key}
</span>
))}
</div>,
);
}
if (changes.length === 0) {
changes.push(
<div key="nothing" className={styles.WhatChangedItem}>
The parent component rendered.
</div>,
);
}
return (
<div className={styles.WhatChanged}>
<label className={styles.Label}>Why did this render?</label>
{changes}
</div>
);
}