/**
* 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 {Point} from './view-base';
import type {
FlamechartStackFrame,
NativeEvent,
NetworkMeasure,
ReactComponentMeasure,
ReactEventInfo,
ReactMeasure,
ReactMeasureType,
SchedulingEvent,
Snapshot,
SuspenseEvent,
ThrownError,
TimelineData,
UserTimingMark,
} from './types';
import * as React from 'react';
import {
formatDuration,
formatTimestamp,
trimString,
getSchedulingEventLabel,
} from './utils/formatting';
import {getBatchRange} from './utils/getBatchRange';
import useSmartTooltip from './utils/useSmartTooltip';
import styles from './EventTooltip.css';
const MAX_TOOLTIP_TEXT_LENGTH = 60;
type Props = {
canvasRef: {current: HTMLCanvasElement | null},
data: TimelineData,
height: number,
hoveredEvent: ReactEventInfo | null,
origin: Point,
width: number,
};
function getReactMeasureLabel(type: ReactMeasureType): string | null {
switch (type) {
case 'commit':
return 'react commit';
case 'render-idle':
return 'react idle';
case 'render':
return 'react render';
case 'layout-effects':
return 'react layout effects';
case 'passive-effects':
return 'react passive effects';
default:
return null;
}
}
export default function EventTooltip({
canvasRef,
data,
height,
hoveredEvent,
origin,
width,
}: Props): React.Node {
const ref = useSmartTooltip({
canvasRef,
mouseX: origin.x,
mouseY: origin.y,
});
if (hoveredEvent === null) {
return null;
}
const {
componentMeasure,
flamechartStackFrame,
measure,
nativeEvent,
networkMeasure,
schedulingEvent,
snapshot,
suspenseEvent,
thrownError,
userTimingMark,
} = hoveredEvent;
let content = null;
if (componentMeasure !== null) {
content = (
<TooltipReactComponentMeasure componentMeasure={componentMeasure} />
);
} else if (nativeEvent !== null) {
content = <TooltipNativeEvent nativeEvent={nativeEvent} />;
} else if (networkMeasure !== null) {
content = <TooltipNetworkMeasure networkMeasure={networkMeasure} />;
} else if (schedulingEvent !== null) {
content = (
<TooltipSchedulingEvent data={data} schedulingEvent={schedulingEvent} />
);
} else if (snapshot !== null) {
content = (
<TooltipSnapshot height={height} snapshot={snapshot} width={width} />
);
} else if (suspenseEvent !== null) {
content = <TooltipSuspenseEvent suspenseEvent={suspenseEvent} />;
} else if (measure !== null) {
content = <TooltipReactMeasure data={data} measure={measure} />;
} else if (flamechartStackFrame !== null) {
content = <TooltipFlamechartNode stackFrame={flamechartStackFrame} />;
} else if (userTimingMark !== null) {
content = <TooltipUserTimingMark mark={userTimingMark} />;
} else if (thrownError !== null) {
content = <TooltipThrownError thrownError={thrownError} />;
}
if (content !== null) {
return (
<div className={styles.Tooltip} ref={ref}>
{content}
</div>
);
} else {
return null;
}
}
const TooltipReactComponentMeasure = ({
componentMeasure,
}: {
componentMeasure: ReactComponentMeasure,
}) => {
const {componentName, duration, timestamp, type, warning} = componentMeasure;
let label = componentName;
switch (type) {
case 'render':
label += ' rendered';
break;
case 'layout-effect-mount':
label += ' mounted layout effect';
break;
case 'layout-effect-unmount':
label += ' unmounted layout effect';
break;
case 'passive-effect-mount':
label += ' mounted passive effect';
break;
case 'passive-effect-unmount':
label += ' unmounted passive effect';
break;
}
return (
<>
<div className={styles.TooltipSection}>
{trimString(label, 768)}
<div className={styles.Divider} />
<div className={styles.DetailsGrid}>
<div className={styles.DetailsGridLabel}>Timestamp:</div>
<div>{formatTimestamp(timestamp)}</div>
<div className={styles.DetailsGridLabel}>Duration:</div>
<div>{formatDuration(duration)}</div>
</div>
</div>
{warning !== null && (
<div className={styles.TooltipWarningSection}>
<div className={styles.WarningText}>{warning}</div>
</div>
)}
</>
);
};
const TooltipFlamechartNode = ({
stackFrame,
}: {
stackFrame: FlamechartStackFrame,
}) => {
const {name, timestamp, duration, locationLine, locationColumn} = stackFrame;
return (
<div className={styles.TooltipSection}>
<span className={styles.FlamechartStackFrameName}>{name}</span>
<div className={styles.DetailsGrid}>
<div className={styles.DetailsGridLabel}>Timestamp:</div>
<div>{formatTimestamp(timestamp)}</div>
<div className={styles.DetailsGridLabel}>Duration:</div>
<div>{formatDuration(duration)}</div>
{(locationLine !== undefined || locationColumn !== undefined) && (
<>
<div className={styles.DetailsGridLabel}>Location:</div>
<div>
line {locationLine}, column {locationColumn}
</div>
</>
)}
</div>
</div>
);
};
const TooltipNativeEvent = ({nativeEvent}: {nativeEvent: NativeEvent}) => {
const {duration, timestamp, type, warning} = nativeEvent;
return (
<>
<div className={styles.TooltipSection}>
<span className={styles.NativeEventName}>{trimString(type, 768)}</span>
event
<div className={styles.Divider} />
<div className={styles.DetailsGrid}>
<div className={styles.DetailsGridLabel}>Timestamp:</div>
<div>{formatTimestamp(timestamp)}</div>
<div className={styles.DetailsGridLabel}>Duration:</div>
<div>{formatDuration(duration)}</div>
</div>
</div>
{warning !== null && (
<div className={styles.TooltipWarningSection}>
<div className={styles.WarningText}>{warning}</div>
</div>
)}
</>
);
};
const TooltipNetworkMeasure = ({
networkMeasure,
}: {
networkMeasure: NetworkMeasure,
}) => {
const {
finishTimestamp,
lastReceivedDataTimestamp,
priority,
sendRequestTimestamp,
url,
} = networkMeasure;
let urlToDisplay = url;
if (urlToDisplay.length > MAX_TOOLTIP_TEXT_LENGTH) {
const half = Math.floor(MAX_TOOLTIP_TEXT_LENGTH / 2);
urlToDisplay = url.slice(0, half) + '…' + url.slice(url.length - half);
}
const timestampBegin = sendRequestTimestamp;
const timestampEnd = finishTimestamp || lastReceivedDataTimestamp;
const duration =
timestampEnd > 0
? formatDuration(finishTimestamp - timestampBegin)
: '(incomplete)';
return (
<div className={styles.SingleLineTextSection}>
{duration} <span className={styles.DimText}>{priority}</span>{' '}
{urlToDisplay}
</div>
);
};
const TooltipSchedulingEvent = ({
data,
schedulingEvent,
}: {
data: TimelineData,
schedulingEvent: SchedulingEvent,
}) => {
const label = getSchedulingEventLabel(schedulingEvent);
if (!label) {
if (__DEV__) {
console.warn(
'Unexpected schedulingEvent type "%s"',
schedulingEvent.type,
);
}
return null;
}
let laneLabels = null;
let lanes = null;
switch (schedulingEvent.type) {
case 'schedule-render':
case 'schedule-state-update':
case 'schedule-force-update':
lanes = schedulingEvent.lanes;
laneLabels = lanes.map(
lane => ((data.laneToLabelMap.get(lane): any): string),
);
break;
}
const {componentName, timestamp, warning} = schedulingEvent;
return (
<>
<div className={styles.TooltipSection}>
{componentName && (
<span className={styles.ComponentName}>
{trimString(componentName, 100)}
</span>
)}
{label}
<div className={styles.Divider} />
<div className={styles.DetailsGrid}>
{laneLabels !== null && lanes !== null && (
<>
<div className={styles.DetailsGridLabel}>Lanes:</div>
<div>
{laneLabels.join(', ')} ({lanes.join(', ')})
</div>
</>
)}
<div className={styles.DetailsGridLabel}>Timestamp:</div>
<div>{formatTimestamp(timestamp)}</div>
</div>
</div>
{warning !== null && (
<div className={styles.TooltipWarningSection}>
<div className={styles.WarningText}>{warning}</div>
</div>
)}
</>
);
};
const TooltipSnapshot = ({
height,
snapshot,
width,
}: {
height: number,
snapshot: Snapshot,
width: number,
}) => {
const aspectRatio = snapshot.width / snapshot.height;
// Zoomed in view should not be any bigger than the DevTools viewport.
let safeWidth = snapshot.width;
let safeHeight = snapshot.height;
if (safeWidth > width) {
safeWidth = width;
safeHeight = safeWidth / aspectRatio;
}
if (safeHeight > height) {
safeHeight = height;
safeWidth = safeHeight * aspectRatio;
}
return (
<img
className={styles.Image}
src={snapshot.imageSource}
style={{height: safeHeight, width: safeWidth}}
/>
);
};
const TooltipSuspenseEvent = ({
suspenseEvent,
}: {
suspenseEvent: SuspenseEvent,
}) => {
const {
componentName,
duration,
phase,
promiseName,
resolution,
timestamp,
warning,
} = suspenseEvent;
let label = 'suspended';
if (phase !== null) {
label += ` during ${phase}`;
}
return (
<>
<div className={styles.TooltipSection}>
{componentName && (
<span className={styles.ComponentName}>
{trimString(componentName, 100)}
</span>
)}
{label}
<div className={styles.Divider} />
<div className={styles.DetailsGrid}>
{promiseName !== null && (
<>
<div className={styles.DetailsGridLabel}>Resource:</div>
<div className={styles.DetailsGridLongValue}>{promiseName}</div>
</>
)}
<div className={styles.DetailsGridLabel}>Status:</div>
<div>{resolution}</div>
<div className={styles.DetailsGridLabel}>Timestamp:</div>
<div>{formatTimestamp(timestamp)}</div>
{duration !== null && (
<>
<div className={styles.DetailsGridLabel}>Duration:</div>
<div>{formatDuration(duration)}</div>
</>
)}
</div>
</div>
{warning !== null && (
<div className={styles.TooltipWarningSection}>
<div className={styles.WarningText}>{warning}</div>
</div>
)}
</>
);
};
const TooltipReactMeasure = ({
data,
measure,
}: {
data: TimelineData,
measure: ReactMeasure,
}) => {
const label = getReactMeasureLabel(measure.type);
if (!label) {
if (__DEV__) {
console.warn('Unexpected measure type "%s"', measure.type);
}
return null;
}
const {batchUID, duration, timestamp, lanes} = measure;
const [startTime, stopTime] = getBatchRange(batchUID, data);
const laneLabels = lanes.map(
lane => ((data.laneToLabelMap.get(lane): any): string),
);
return (
<div className={styles.TooltipSection}>
<span className={styles.ReactMeasureLabel}>{label}</span>
<div className={styles.Divider} />
<div className={styles.DetailsGrid}>
<div className={styles.DetailsGridLabel}>Timestamp:</div>
<div>{formatTimestamp(timestamp)}</div>
{measure.type !== 'render-idle' && (
<>
<div className={styles.DetailsGridLabel}>Duration:</div>
<div>{formatDuration(duration)}</div>
</>
)}
<div className={styles.DetailsGridLabel}>Batch duration:</div>
<div>{formatDuration(stopTime - startTime)}</div>
<div className={styles.DetailsGridLabel}>
Lane{lanes.length === 1 ? '' : 's'}:
</div>
<div>
{laneLabels.length > 0
? `${laneLabels.join(', ')} (${lanes.join(', ')})`
: lanes.join(', ')}
</div>
</div>
</div>
);
};
const TooltipUserTimingMark = ({mark}: {mark: UserTimingMark}) => {
const {name, timestamp} = mark;
return (
<div className={styles.TooltipSection}>
<span className={styles.UserTimingLabel}>{name}</span>
<div className={styles.Divider} />
<div className={styles.DetailsGrid}>
<div className={styles.DetailsGridLabel}>Timestamp:</div>
<div>{formatTimestamp(timestamp)}</div>
</div>
</div>
);
};
const TooltipThrownError = ({thrownError}: {thrownError: ThrownError}) => {
const {componentName, message, phase, timestamp} = thrownError;
const label = `threw an error during ${phase}`;
return (
<div className={styles.TooltipSection}>
{componentName && (
<span className={styles.ComponentName}>
{trimString(componentName, 100)}
</span>
)}
<span className={styles.UserTimingLabel}>{label}</span>
<div className={styles.Divider} />
<div className={styles.DetailsGrid}>
<div className={styles.DetailsGridLabel}>Timestamp:</div>
<div>{formatTimestamp(timestamp)}</div>
{message !== '' && (
<>
<div className={styles.DetailsGridLabel}>Error:</div>
<div>{message}</div>
</>
)}
</div>
</div>
);
};