Skip to content

Add fiber summary tooltip to devtools profiling #18048

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Feb 19, 2020
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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>
);
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/** @flow */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny nit, and I'm happy to do this post-merge- BUT! The new Tooltip and WhatChanged components are only used by views in the "Profiler" tab, so they should be in views/Profiler rather than views/Components (which is where views for the "Components" tab live). I would be happy to sort this out with a git mv after the PR merge though!


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;
}
}
Original file line number Diff line number Diff line change
@@ -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={{
Original file line number Diff line number Diff line change
@@ -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>
);
}

Original file line number Diff line number Diff line change
@@ -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}
Original file line number Diff line number Diff line change
@@ -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>
);
}

Original file line number Diff line number Diff line change
@@ -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}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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>
);
}
Original file line number Diff line number Diff line change
@@ -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: ')';
}
Original file line number Diff line number Diff line change
@@ -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>
);
}