Scheduling profiler UX changes (#21990)

This commit is contained in:
Brian Vaughn
2021-08-02 09:23:48 -04:00
committed by GitHub
parent b537247678
commit 27bf6f9a83
40 changed files with 2010 additions and 1260 deletions
+187 -94
View File
@@ -31,8 +31,9 @@ import {copy} from 'clipboard-js';
import prettyMilliseconds from 'pretty-ms';
import {
BackgroundColorView,
HorizontalPanAndZoomView,
ResizableSplitView,
ResizableView,
Surface,
VerticalScrollView,
View,
@@ -45,8 +46,9 @@ import {
import {
FlamechartView,
NativeEventsView,
ReactEventsView,
ReactMeasuresView,
SchedulingEventsView,
SuspenseEventsView,
TimeAxisMarkersView,
UserTimingMarksView,
} from './content-views';
@@ -128,7 +130,8 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) {
const surfaceRef = useRef(new Surface());
const userTimingMarksViewRef = useRef(null);
const nativeEventsViewRef = useRef(null);
const reactEventsViewRef = useRef(null);
const schedulingEventsViewRef = useRef(null);
const suspenseEventsViewRef = useRef(null);
const reactMeasuresViewRef = useRef(null);
const flamechartViewRef = useRef(null);
const syncedHorizontalPanAndZoomViewsRef = useRef<HorizontalPanAndZoomView[]>(
@@ -152,21 +155,53 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) {
);
};
// Top content
function createViewHelper(
view: View,
resizeLabel: string = '',
shouldScrollVertically: boolean = false,
shouldResizeVertically: boolean = false,
): View {
let verticalScrollView = null;
if (shouldScrollVertically) {
verticalScrollView = new VerticalScrollView(
surface,
defaultFrame,
view,
);
}
const topContentStack = new View(
surface,
defaultFrame,
verticallyStackedLayout,
);
const horizontalPanAndZoomView = new HorizontalPanAndZoomView(
surface,
defaultFrame,
verticalScrollView !== null ? verticalScrollView : view,
data.duration,
syncAllHorizontalPanAndZoomViewStates,
);
syncedHorizontalPanAndZoomViewsRef.current.push(horizontalPanAndZoomView);
let viewToReturn = horizontalPanAndZoomView;
if (shouldResizeVertically) {
viewToReturn = new ResizableView(
surface,
defaultFrame,
horizontalPanAndZoomView,
canvasRef,
resizeLabel,
);
}
return viewToReturn;
}
const axisMarkersView = new TimeAxisMarkersView(
surface,
defaultFrame,
data.duration,
);
topContentStack.addSubview(axisMarkersView);
const axisMarkersViewWrapper = createViewHelper(axisMarkersView);
let userTimingMarksViewWrapper = null;
if (data.otherUserTimingMarks.length > 0) {
const userTimingMarksView = new UserTimingMarksView(
surface,
@@ -175,29 +210,38 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) {
data.duration,
);
userTimingMarksViewRef.current = userTimingMarksView;
topContentStack.addSubview(userTimingMarksView);
userTimingMarksViewWrapper = createViewHelper(userTimingMarksView);
}
const nativeEventsView = new NativeEventsView(surface, defaultFrame, data);
nativeEventsViewRef.current = nativeEventsView;
topContentStack.addSubview(nativeEventsView);
const nativeEventsViewWrapper = createViewHelper(
nativeEventsView,
'events',
true,
true,
);
const reactEventsView = new ReactEventsView(surface, defaultFrame, data);
reactEventsViewRef.current = reactEventsView;
topContentStack.addSubview(reactEventsView);
const topContentHorizontalPanAndZoomView = new HorizontalPanAndZoomView(
const schedulingEventsView = new SchedulingEventsView(
surface,
defaultFrame,
topContentStack,
data.duration,
syncAllHorizontalPanAndZoomViewStates,
);
syncedHorizontalPanAndZoomViewsRef.current.push(
topContentHorizontalPanAndZoomView,
data,
);
schedulingEventsViewRef.current = schedulingEventsView;
const schedulingEventsViewWrapper = createViewHelper(schedulingEventsView);
// Resizable content
const suspenseEventsView = new SuspenseEventsView(
surface,
defaultFrame,
data,
);
suspenseEventsViewRef.current = suspenseEventsView;
const suspenseEventsViewWrapper = createViewHelper(
suspenseEventsView,
'suspense',
true,
true,
);
const reactMeasuresView = new ReactMeasuresView(
surface,
@@ -205,20 +249,11 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) {
data,
);
reactMeasuresViewRef.current = reactMeasuresView;
const reactMeasuresVerticalScrollView = new VerticalScrollView(
surface,
defaultFrame,
const reactMeasuresViewWrapper = createViewHelper(
reactMeasuresView,
);
const reactMeasuresHorizontalPanAndZoomView = new HorizontalPanAndZoomView(
surface,
defaultFrame,
reactMeasuresVerticalScrollView,
data.duration,
syncAllHorizontalPanAndZoomViewStates,
);
syncedHorizontalPanAndZoomViewsRef.current.push(
reactMeasuresHorizontalPanAndZoomView,
'react',
true,
true,
);
const flamechartView = new FlamechartView(
@@ -228,30 +263,15 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) {
data.duration,
);
flamechartViewRef.current = flamechartView;
const flamechartVerticalScrollView = new VerticalScrollView(
surface,
defaultFrame,
const flamechartViewWrapper = createViewHelper(
flamechartView,
);
const flamechartHorizontalPanAndZoomView = new HorizontalPanAndZoomView(
surface,
defaultFrame,
flamechartVerticalScrollView,
data.duration,
syncAllHorizontalPanAndZoomViewStates,
);
syncedHorizontalPanAndZoomViewsRef.current.push(
flamechartHorizontalPanAndZoomView,
);
const resizableContentStack = new ResizableSplitView(
surface,
defaultFrame,
reactMeasuresHorizontalPanAndZoomView,
flamechartHorizontalPanAndZoomView,
canvasRef,
'flamechart',
true,
true,
);
// Root view contains all of the sub views defined above.
// The order we add them below determines their vertical position.
const rootView = new View(
surface,
defaultFrame,
@@ -260,8 +280,18 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) {
lastViewTakesUpRemainingSpaceLayout,
),
);
rootView.addSubview(topContentHorizontalPanAndZoomView);
rootView.addSubview(resizableContentStack);
rootView.addSubview(axisMarkersViewWrapper);
if (userTimingMarksViewWrapper !== null) {
rootView.addSubview(userTimingMarksViewWrapper);
}
rootView.addSubview(nativeEventsViewWrapper);
rootView.addSubview(schedulingEventsViewWrapper);
rootView.addSubview(suspenseEventsViewWrapper);
rootView.addSubview(reactMeasuresViewWrapper);
rootView.addSubview(flamechartViewWrapper);
// If subviews are less than the available height, fill remaining height with a solid color.
rootView.addSubview(new BackgroundColorView(surface, defaultFrame));
surfaceRef.current.rootView = rootView;
}, [data]);
@@ -278,6 +308,39 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) {
return;
}
// Wheel events should always hide the current toolltip.
switch (interaction.type) {
case 'wheel-control':
case 'wheel-meta':
case 'wheel-plain':
case 'wheel-shift':
setHoveredEvent(prevHoverEvent => {
if (prevHoverEvent === null) {
return prevHoverEvent;
} else if (
prevHoverEvent.flamechartStackFrame !== null ||
prevHoverEvent.measure !== null ||
prevHoverEvent.nativeEvent !== null ||
prevHoverEvent.schedulingEvent !== null ||
prevHoverEvent.suspenseEvent !== null ||
prevHoverEvent.userTimingMark !== null
) {
return {
data: prevHoverEvent.data,
flamechartStackFrame: null,
measure: null,
nativeEvent: null,
schedulingEvent: null,
suspenseEvent: null,
userTimingMark: null,
};
} else {
return prevHoverEvent;
}
});
break;
}
const surface = surfaceRef.current;
surface.handleInteraction(interaction);
@@ -310,12 +373,13 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) {
userTimingMarksView.onHover = userTimingMark => {
if (!hoveredEvent || hoveredEvent.userTimingMark !== userTimingMark) {
setHoveredEvent({
userTimingMark,
nativeEvent: null,
reactEvent: null,
data,
flamechartStackFrame: null,
measure: null,
data,
nativeEvent: null,
schedulingEvent: null,
suspenseEvent: null,
userTimingMark,
});
}
};
@@ -326,28 +390,47 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) {
nativeEventsView.onHover = nativeEvent => {
if (!hoveredEvent || hoveredEvent.nativeEvent !== nativeEvent) {
setHoveredEvent({
userTimingMark: null,
nativeEvent,
reactEvent: null,
data,
flamechartStackFrame: null,
measure: null,
data,
nativeEvent,
schedulingEvent: null,
suspenseEvent: null,
userTimingMark: null,
});
}
};
}
const {current: reactEventsView} = reactEventsViewRef;
if (reactEventsView) {
reactEventsView.onHover = reactEvent => {
if (!hoveredEvent || hoveredEvent.reactEvent !== reactEvent) {
const {current: schedulingEventsView} = schedulingEventsViewRef;
if (schedulingEventsView) {
schedulingEventsView.onHover = schedulingEvent => {
if (!hoveredEvent || hoveredEvent.schedulingEvent !== schedulingEvent) {
setHoveredEvent({
userTimingMark: null,
nativeEvent: null,
reactEvent,
data,
flamechartStackFrame: null,
measure: null,
nativeEvent: null,
schedulingEvent,
suspenseEvent: null,
userTimingMark: null,
});
}
};
}
const {current: suspenseEventsView} = suspenseEventsViewRef;
if (suspenseEventsView) {
suspenseEventsView.onHover = suspenseEvent => {
if (!hoveredEvent || hoveredEvent.suspenseEvent !== suspenseEvent) {
setHoveredEvent({
data,
flamechartStackFrame: null,
measure: null,
nativeEvent: null,
schedulingEvent: null,
suspenseEvent,
userTimingMark: null,
});
}
};
@@ -358,12 +441,13 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) {
reactMeasuresView.onHover = measure => {
if (!hoveredEvent || hoveredEvent.measure !== measure) {
setHoveredEvent({
userTimingMark: null,
nativeEvent: null,
reactEvent: null,
data,
flamechartStackFrame: null,
measure,
data,
nativeEvent: null,
schedulingEvent: null,
suspenseEvent: null,
userTimingMark: null,
});
}
};
@@ -377,12 +461,13 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) {
hoveredEvent.flamechartStackFrame !== flamechartStackFrame
) {
setHoveredEvent({
userTimingMark: null,
nativeEvent: null,
reactEvent: null,
data,
flamechartStackFrame,
measure: null,
data,
nativeEvent: null,
schedulingEvent: null,
suspenseEvent: null,
userTimingMark: null,
});
}
});
@@ -407,10 +492,17 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) {
);
}
const {current: reactEventsView} = reactEventsViewRef;
if (reactEventsView) {
reactEventsView.setHoveredEvent(
hoveredEvent ? hoveredEvent.reactEvent : null,
const {current: schedulingEventsView} = schedulingEventsViewRef;
if (schedulingEventsView) {
schedulingEventsView.setHoveredEvent(
hoveredEvent ? hoveredEvent.schedulingEvent : null,
);
}
const {current: suspenseEventsView} = suspenseEventsViewRef;
if (suspenseEventsView) {
suspenseEventsView.setHoveredEvent(
hoveredEvent ? hoveredEvent.suspenseEvent : null,
);
}
@@ -443,24 +535,25 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) {
return null;
}
const {
reactEvent,
flamechartStackFrame,
measure,
schedulingEvent,
suspenseEvent,
} = contextData.hoveredEvent;
return (
<Fragment>
{reactEvent !== null && (
{schedulingEvent !== null && (
<ContextMenuItem
onClick={() => copy(reactEvent.componentName)}
onClick={() => copy(schedulingEvent.componentName)}
title="Copy component name">
Copy component name
</ContextMenuItem>
)}
{reactEvent !== null && reactEvent.componentStack && (
{suspenseEvent !== null && (
<ContextMenuItem
onClick={() => copy(reactEvent.componentStack)}
title="Copy component stack">
Copy component stack
onClick={() => copy(suspenseEvent.componentName)}
title="Copy component name">
Copy component name
</ContextMenuItem>
)}
{measure !== null && (
@@ -1,6 +1,10 @@
.Tooltip {
position: fixed;
display: inline-block;
}
.TooltipSection,
.TooltipWarningSection {
display: block;
border-radius: 0.125rem;
max-width: 300px;
padding: 0.25rem;
@@ -12,11 +16,15 @@
color: var(--color-tooltip-text);
font-size: 11px;
}
.TooltipWarningSection {
margin-top: 0.25rem;
background-color: var(--color-warning-background);
}
.Divider {
height: 1px;
background-color: #aaa;
margin: 0.5rem 0;
margin: 0.25rem 0;
}
.DetailsGrid {
@@ -40,7 +48,6 @@
.FlamechartStackFrameName {
word-break: break-word;
margin-left: 0.4rem;
}
.ComponentName {
@@ -49,32 +56,20 @@
margin-right: 0.25rem;
}
.ComponentStack {
overflow: hidden;
max-width: 35em;
max-height: 10em;
margin: 0;
font-size: 0.9em;
line-height: 1.5;
-webkit-mask-image: linear-gradient(
180deg,
var(--color-tooltip-background),
var(--color-tooltip-background) 5em,
transparent
);
mask-image: linear-gradient(
180deg,
var(--color-tooltip-background),
var(--color-tooltip-background) 5em,
transparent
);
white-space: pre;
}
.ReactMeasureLabel {
margin-left: 0.4rem;
}
.UserTimingLabel {
word-break: break-word;
}
.NativeEventName {
font-weight: bold;
word-break: break-word;
margin-right: 0.25rem;
}
.InfoText,
.WarningText {
color: var(--color-warning-text-color);
}
+186 -158
View File
@@ -11,18 +11,18 @@ import type {Point} from './view-base';
import type {
FlamechartStackFrame,
NativeEvent,
ReactEvent,
ReactHoverContextInfo,
ReactMeasure,
ReactProfilerData,
Return,
SchedulingEvent,
SuspenseEvent,
UserTimingMark,
} from './types';
import * as React from 'react';
import {Fragment, useRef} from 'react';
import prettyMilliseconds from 'pretty-ms';
import {COLORS} from './content-views/constants';
import {useRef} from 'react';
import {formatDuration, formatTimestamp, trimString} from './utils/formatting';
import {getBatchRange} from './utils/getBatchRange';
import useSmartTooltip from './utils/useSmartTooltip';
import styles from './EventTooltip.css';
@@ -34,22 +34,7 @@ type Props = {|
origin: Point,
|};
function formatTimestamp(ms) {
return ms.toLocaleString(undefined, {minimumFractionDigits: 2}) + 'ms';
}
function formatDuration(ms) {
return prettyMilliseconds(ms, {millisecondsDecimalDigits: 3});
}
function trimmedString(string: string, length: number): string {
if (string.length > length) {
return `${string.substr(0, length - 1)}…`;
}
return string;
}
function getReactEventLabel(event: ReactEvent): string | null {
function getSchedulingEventLabel(event: SchedulingEvent): string | null {
switch (event.type) {
case 'schedule-render':
return 'render scheduled';
@@ -57,30 +42,6 @@ function getReactEventLabel(event: ReactEvent): string | null {
return 'state update scheduled';
case 'schedule-force-update':
return 'force update scheduled';
case 'suspense-suspend':
return 'suspended';
case 'suspense-resolved':
return 'suspense resolved';
case 'suspense-rejected':
return 'suspense rejected';
default:
return null;
}
}
function getReactEventColor(event: ReactEvent): string | null {
switch (event.type) {
case 'schedule-render':
return COLORS.REACT_SCHEDULE_HOVER;
case 'schedule-state-update':
case 'schedule-force-update':
return event.isCascading
? COLORS.REACT_SCHEDULE_CASCADING_HOVER
: COLORS.REACT_SCHEDULE_HOVER;
case 'suspense-suspend':
case 'suspense-resolved':
case 'suspense-rejected':
return COLORS.REACT_SUSPEND_HOVER;
default:
return null;
}
@@ -89,15 +50,15 @@ function getReactEventColor(event: ReactEvent): string | null {
function getReactMeasureLabel(type): string | null {
switch (type) {
case 'commit':
return 'commit';
return 'react commit';
case 'render-idle':
return 'idle';
return 'react idle';
case 'render':
return 'render';
return 'react render';
case 'layout-effects':
return 'layout effects';
return 'react layout effects';
case 'passive-effects':
return 'passive effects';
return 'react passive effects';
default:
return null;
}
@@ -120,10 +81,11 @@ export default function EventTooltip({
}
const {
nativeEvent,
reactEvent,
measure,
flamechartStackFrame,
measure,
nativeEvent,
schedulingEvent,
suspenseEvent,
userTimingMark,
} = hoveredEvent;
@@ -131,9 +93,19 @@ export default function EventTooltip({
return (
<TooltipNativeEvent nativeEvent={nativeEvent} tooltipRef={tooltipRef} />
);
} else if (reactEvent !== null) {
} else if (schedulingEvent !== null) {
return (
<TooltipReactEvent reactEvent={reactEvent} tooltipRef={tooltipRef} />
<TooltipSchedulingEvent
schedulingEvent={schedulingEvent}
tooltipRef={tooltipRef}
/>
);
} else if (suspenseEvent !== null) {
return (
<TooltipSuspenseEvent
suspenseEvent={suspenseEvent}
tooltipRef={tooltipRef}
/>
);
} else if (measure !== null) {
return (
@@ -158,16 +130,6 @@ export default function EventTooltip({
return null;
}
function formatComponentStack(componentStack: string): string {
const lines = componentStack.split('\n').map(line => line.trim());
lines.shift();
if (lines.length > 5) {
return lines.slice(0, 5).join('\n') + '\n...';
}
return lines.join('\n');
}
const TooltipFlamechartNode = ({
stackFrame,
tooltipRef,
@@ -175,35 +137,25 @@ const TooltipFlamechartNode = ({
stackFrame: FlamechartStackFrame,
tooltipRef: Return<typeof useRef>,
}) => {
const {
name,
timestamp,
duration,
scriptUrl,
locationLine,
locationColumn,
} = stackFrame;
const {name, timestamp, duration, locationLine, locationColumn} = stackFrame;
return (
<div className={styles.Tooltip} ref={tooltipRef}>
{formatDuration(duration)}
<span className={styles.FlamechartStackFrameName}>{name}</span>
<div className={styles.DetailsGrid}>
<div className={styles.DetailsGridLabel}>Timestamp:</div>
<div>{formatTimestamp(timestamp)}</div>
{scriptUrl && (
<>
<div className={styles.DetailsGridLabel}>Script URL:</div>
<div className={styles.DetailsGridURL}>{scriptUrl}</div>
</>
)}
{(locationLine !== undefined || locationColumn !== undefined) && (
<>
<div className={styles.DetailsGridLabel}>Location:</div>
<div>
line {locationLine}, column {locationColumn}
</div>
</>
)}
<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>
</div>
);
@@ -216,75 +168,142 @@ const TooltipNativeEvent = ({
nativeEvent: NativeEvent,
tooltipRef: Return<typeof useRef>,
}) => {
const {duration, timestamp, type, warnings} = nativeEvent;
const warningElements = [];
if (warnings !== null) {
warnings.forEach((warning, index) => {
warningElements.push(
<Fragment key={index}>
<div className={styles.DetailsGridLabel}>Warning:</div>
<div>{warning}</div>
</Fragment>,
);
});
}
const {duration, timestamp, type, warning} = nativeEvent;
return (
<div className={styles.Tooltip} ref={tooltipRef}>
<span className={styles.ComponentName}>{trimmedString(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>
{warningElements}
<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>
)}
</div>
);
};
const TooltipReactEvent = ({
reactEvent,
const TooltipSchedulingEvent = ({
schedulingEvent,
tooltipRef,
}: {
reactEvent: ReactEvent,
schedulingEvent: SchedulingEvent,
tooltipRef: Return<typeof useRef>,
}) => {
const label = getReactEventLabel(reactEvent);
const color = getReactEventColor(reactEvent);
if (!label || !color) {
const label = getSchedulingEventLabel(schedulingEvent);
if (!label) {
if (__DEV__) {
console.warn('Unexpected reactEvent type "%s"', reactEvent.type);
console.warn(
'Unexpected schedulingEvent type "%s"',
schedulingEvent.type,
);
}
return null;
}
const {componentName, componentStack, timestamp} = reactEvent;
let laneLabels = null;
let lanes = null;
switch (schedulingEvent.type) {
case 'schedule-render':
case 'schedule-state-update':
case 'schedule-force-update':
laneLabels = schedulingEvent.laneLabels;
lanes = schedulingEvent.lanes;
break;
}
const {componentName, timestamp, warning} = schedulingEvent;
return (
<div className={styles.Tooltip} ref={tooltipRef}>
{componentName && (
<span className={styles.ComponentName} style={{color}}>
{trimmedString(componentName, 768)}
</span>
)}
{label}
<div className={styles.Divider} />
<div className={styles.DetailsGrid}>
<div className={styles.DetailsGridLabel}>Timestamp:</div>
<div>{formatTimestamp(timestamp)}</div>
{componentStack && (
<Fragment>
<div className={styles.DetailsGridLabel}>Component stack:</div>
<pre className={styles.ComponentStack}>
{formatComponentStack(componentStack)}
</pre>
</Fragment>
<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>
)}
</div>
);
};
const TooltipSuspenseEvent = ({
suspenseEvent,
tooltipRef,
}: {
suspenseEvent: SuspenseEvent,
tooltipRef: Return<typeof useRef>,
}) => {
const {
componentName,
duration,
phase,
resolution,
timestamp,
warning,
} = suspenseEvent;
let label = 'suspended';
if (phase !== null) {
label += ` during ${phase}`;
}
return (
<div className={styles.Tooltip} ref={tooltipRef}>
<div className={styles.TooltipSection}>
{componentName && (
<span className={styles.ComponentName}>
{trimString(componentName, 100)}
</span>
)}
{label}
<div className={styles.Divider} />
<div className={styles.DetailsGrid}>
<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>
)}
</div>
);
};
@@ -311,21 +330,28 @@ const TooltipReactMeasure = ({
return (
<div className={styles.Tooltip} ref={tooltipRef}>
{formatDuration(duration)}
<span className={styles.ReactMeasureLabel}>{label}</span>
<div className={styles.Divider} />
<div className={styles.DetailsGrid}>
<div className={styles.DetailsGridLabel}>Timestamp:</div>
<div>{formatTimestamp(timestamp)}</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 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>
</div>
@@ -342,11 +368,13 @@ const TooltipUserTimingMark = ({
const {name, timestamp} = mark;
return (
<div className={styles.Tooltip} ref={tooltipRef}>
<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 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>
</div>
);
@@ -41,8 +41,18 @@ export function SchedulingProfiler(_: {||}) {
// The easiest way to guarangee this happens is to recreate the inner Canvas component.
const [key, setKey] = useState<string>(theme);
useLayoutEffect(() => {
updateColorsToMatchTheme();
setKey(deferredTheme);
const pollForTheme = () => {
if (updateColorsToMatchTheme()) {
clearInterval(intervalID);
setKey(deferredTheme);
}
};
const intervalID = setInterval(pollForTheme, 50);
return () => {
clearInterval(intervalID);
};
}, [deferredTheme]);
return (
@@ -21,12 +21,11 @@ import type {
} from '../view-base';
import {
ColorView,
BackgroundColorView,
Surface,
View,
layeredLayout,
rectContainsPoint,
rectEqualToRect,
intersectionOfRects,
rectIntersectsRect,
verticallyStackedLayout,
@@ -36,11 +35,10 @@ import {
positioningScaleFactor,
timestampToPosition,
} from './utils/positioning';
import {drawText} from './utils/text';
import {
COLORS,
FONT_SIZE,
FLAMECHART_FRAME_HEIGHT,
TEXT_PADDING,
COLOR_HOVER_DIM_DELTA,
BORDER_SIZE,
} from './constants';
@@ -71,29 +69,6 @@ function hoverColorForStackFrame(stackFrame: FlamechartStackFrame): string {
return hslaColorToString(color);
}
const cachedFlamechartTextWidths = new Map();
const trimFlamechartText = (
context: CanvasRenderingContext2D,
text: string,
width: number,
) => {
for (let i = text.length - 1; i >= 0; i--) {
const trimmedText = i === text.length - 1 ? text : text.substr(0, i) + '…';
let measuredWidth = cachedFlamechartTextWidths.get(trimmedText);
if (measuredWidth == null) {
measuredWidth = context.measureText(trimmedText).width;
cachedFlamechartTextWidths.set(trimmedText, measuredWidth);
}
if (measuredWidth <= width) {
return trimmedText;
}
}
return null;
};
class FlamechartStackLayerView extends View {
/** Layer to display */
_stackLayer: FlamechartStackLayer;
@@ -161,10 +136,6 @@ class FlamechartStackLayerView extends View {
visibleArea.size.height,
);
context.textAlign = 'left';
context.textBaseline = 'middle';
context.font = `${FONT_SIZE}px sans-serif`;
const scaleFactor = positioningScaleFactor(_intrinsicSize.width, frame);
for (let i = 0; i < _stackLayer.length; i++) {
@@ -201,45 +172,7 @@ class FlamechartStackLayerView extends View {
drawableRect.size.height,
);
if (width > TEXT_PADDING * 2) {
const trimmedName = trimFlamechartText(
context,
name,
width - TEXT_PADDING * 2 + (x < 0 ? x : 0),
);
if (trimmedName !== null) {
context.fillStyle = COLORS.TEXT_COLOR;
// Prevent text from being drawn outside `viewableArea`
const textOverflowsViewableArea = !rectEqualToRect(
drawableRect,
nodeRect,
);
if (textOverflowsViewableArea) {
context.save();
context.beginPath();
context.rect(
drawableRect.origin.x,
drawableRect.origin.y,
drawableRect.size.width,
drawableRect.size.height,
);
context.closePath();
context.clip();
}
context.fillText(
trimmedName,
nodeRect.origin.x + TEXT_PADDING - (x < 0 ? x : 0),
nodeRect.origin.y + FLAMECHART_FRAME_HEIGHT / 2,
);
if (textOverflowsViewableArea) {
context.restore();
}
}
}
drawText(name, context, nodeRect, drawableRect, width);
}
}
@@ -262,13 +195,17 @@ class FlamechartStackLayerView extends View {
const flamechartStackFrame = _stackLayer[currentIndex];
const {timestamp, duration} = flamechartStackFrame;
const width = durationToWidth(duration, scaleFactor);
const x = Math.floor(timestampToPosition(timestamp, scaleFactor, frame));
if (x <= location.x && x + width >= location.x) {
this.currentCursor = 'pointer';
viewRefs.hoveredView = this;
_onHover(flamechartStackFrame);
return;
const width = durationToWidth(duration, scaleFactor);
// Don't show tooltips for nodes that are too small to render at this zoom level.
if (Math.floor(width - BORDER_SIZE) >= 1) {
if (x <= location.x && x + width >= location.x) {
this.currentCursor = 'context-menu';
viewRefs.hoveredView = this;
_onHover(flamechartStackFrame);
return;
}
}
if (x > location.x) {
@@ -336,10 +273,8 @@ export class FlamechartView extends View {
return rowView;
});
// Add a plain background view to prevent gaps from appearing between
// flamechartRowViews.
const colorView = new ColorView(surface, frame, COLORS.BACKGROUND);
this.addSubview(colorView);
// Add a plain background view to prevent gaps from appearing between flamechartRowViews.
this.addSubview(new BackgroundColorView(surface, frame));
this.addSubview(this._verticalStackView);
}
@@ -359,7 +294,12 @@ export class FlamechartView extends View {
desiredSize() {
// Ignore the wishes of the background color view
return this._verticalStackView.desiredSize();
const intrinsicSize = this._verticalStackView.desiredSize();
return {
...intrinsicSize,
// Collapsed by default
maxInitialHeight: 0,
};
}
/**
@@ -22,6 +22,8 @@ import {
positionToTimestamp,
timestampToPosition,
} from './utils/positioning';
import {drawText} from './utils/text';
import {formatDuration} from '../utils/formatting';
import {
View,
Surface,
@@ -29,40 +31,10 @@ import {
rectIntersectsRect,
intersectionOfRects,
} from '../view-base';
import {
COLORS,
TEXT_PADDING,
NATIVE_EVENT_HEIGHT,
FONT_SIZE,
BORDER_SIZE,
} from './constants';
import {COLORS, NATIVE_EVENT_HEIGHT, BORDER_SIZE} from './constants';
const ROW_WITH_BORDER_HEIGHT = NATIVE_EVENT_HEIGHT + BORDER_SIZE;
// TODO (scheduling profiler) Make this a reusable util
const cachedFlamechartTextWidths = new Map();
const trimFlamechartText = (
context: CanvasRenderingContext2D,
text: string,
width: number,
) => {
for (let i = text.length - 1; i >= 0; i--) {
const trimmedText = i === text.length - 1 ? text : text.substr(0, i) + '…';
let measuredWidth = cachedFlamechartTextWidths.get(trimmedText);
if (measuredWidth == null) {
measuredWidth = context.measureText(trimmedText).width;
cachedFlamechartTextWidths.set(trimmedText, measuredWidth);
}
if (measuredWidth <= width) {
return trimmedText;
}
}
return null;
};
export class NativeEventsView extends View {
_depthToNativeEvent: Map<number, NativeEvent[]>;
_hoveredEvent: NativeEvent | null = null;
@@ -117,7 +89,7 @@ export class NativeEventsView extends View {
}
/**
* Draw a single `NativeEvent` as a circle in the canvas.
* Draw a single `NativeEvent` as a box/span with text inside of it.
*/
_drawSingleNativeEvent(
context: CanvasRenderingContext2D,
@@ -128,7 +100,7 @@ export class NativeEventsView extends View {
showHoverHighlight: boolean,
) {
const {frame} = this;
const {depth, duration, timestamp, type, warnings} = event;
const {depth, duration, timestamp, type, warning} = event;
baseY += depth * ROW_WITH_BORDER_HEIGHT;
@@ -152,10 +124,10 @@ export class NativeEventsView extends View {
const drawableRect = intersectionOfRects(eventRect, rect);
context.beginPath();
if (warnings !== null) {
if (warning !== null) {
context.fillStyle = showHoverHighlight
? COLORS.NATIVE_EVENT_WARNING_HOVER
: COLORS.NATIVE_EVENT_WARNING;
? COLORS.WARNING_BACKGROUND_HOVER
: COLORS.WARNING_BACKGROUND;
} else {
context.fillStyle = showHoverHighlight
? COLORS.NATIVE_EVENT_HOVER
@@ -168,32 +140,9 @@ export class NativeEventsView extends View {
drawableRect.size.height,
);
// Render event type label
context.textAlign = 'left';
context.textBaseline = 'middle';
context.font = `${FONT_SIZE}px sans-serif`;
const label = `${type} - ${formatDuration(duration)}`;
if (width > TEXT_PADDING * 2) {
const x = Math.floor(timestampToPosition(timestamp, scaleFactor, frame));
const trimmedName = trimFlamechartText(
context,
type,
width - TEXT_PADDING * 2 + (x < 0 ? x : 0),
);
if (trimmedName !== null) {
context.fillStyle =
warnings !== null
? COLORS.NATIVE_EVENT_WARNING_TEXT
: COLORS.TEXT_COLOR;
context.fillText(
trimmedName,
eventRect.origin.x + TEXT_PADDING - (x < 0 ? x : 0),
eventRect.origin.y + NATIVE_EVENT_HEIGHT / 2,
);
}
}
drawText(label, context, eventRect, drawableRect, width);
}
draw(context: CanvasRenderingContext2D) {
@@ -242,12 +191,16 @@ export class NativeEventsView extends View {
},
};
if (rectIntersectsRect(borderFrame, visibleArea)) {
const borderDrawableRect = intersectionOfRects(
borderFrame,
visibleArea,
);
context.fillStyle = COLORS.PRIORITY_BORDER;
context.fillRect(
visibleArea.origin.x,
frame.origin.y + (i + 1) * ROW_WITH_BORDER_HEIGHT - BORDER_SIZE,
visibleArea.size.width,
BORDER_SIZE,
borderDrawableRect.origin.x,
borderDrawableRect.origin.y,
borderDrawableRect.size.width,
borderDrawableRect.size.height,
);
}
}
@@ -285,8 +238,6 @@ export class NativeEventsView extends View {
hoverTimestamp >= timestamp &&
hoverTimestamp <= timestamp + duration
) {
this.currentCursor = 'pointer';
viewRefs.hoveredView = this;
onHover(nativeEvent);
@@ -12,7 +12,7 @@ import type {
Interaction,
MouseMoveInteraction,
Rect,
Size,
SizeWithMaxHeight,
ViewRefs,
} from '../view-base';
@@ -34,6 +34,7 @@ import {COLORS, BORDER_SIZE, REACT_MEASURE_HEIGHT} from './constants';
import {REACT_TOTAL_NUM_LANES} from '../constants';
const REACT_LANE_HEIGHT = REACT_MEASURE_HEIGHT + BORDER_SIZE;
const MAX_ROWS_TO_SHOW_INITIALLY = 5;
function getMeasuresForLane(
allMeasures: ReactMeasure[],
@@ -44,7 +45,7 @@ function getMeasuresForLane(
export class ReactMeasuresView extends View {
_profilerData: ReactProfilerData;
_intrinsicSize: Size;
_intrinsicSize: SizeWithMaxHeight;
_lanesToRender: ReactLane[];
_laneToMeasures: Map<ReactLane, ReactMeasure[]>;
@@ -77,6 +78,7 @@ export class ReactMeasuresView extends View {
this._intrinsicSize = {
width: this._profilerData.duration,
height: this._lanesToRender.length * REACT_LANE_HEIGHT,
maxInitialHeight: MAX_ROWS_TO_SHOW_INITIALLY * REACT_LANE_HEIGHT,
};
}
@@ -132,7 +134,7 @@ export class ReactMeasuresView extends View {
case 'commit':
fillStyle = COLORS.REACT_COMMIT;
hoveredFillStyle = COLORS.REACT_COMMIT_HOVER;
groupSelectedFillStyle = COLORS.REACT_COMMIT_SELECTED;
groupSelectedFillStyle = COLORS.REACT_COMMIT_HOVER;
break;
case 'render-idle':
// We could render idle time as diagonal hashes.
@@ -140,22 +142,22 @@ export class ReactMeasuresView extends View {
// color = context.createPattern(getIdlePattern(), 'repeat');
fillStyle = COLORS.REACT_IDLE;
hoveredFillStyle = COLORS.REACT_IDLE_HOVER;
groupSelectedFillStyle = COLORS.REACT_IDLE_SELECTED;
groupSelectedFillStyle = COLORS.REACT_IDLE_HOVER;
break;
case 'render':
fillStyle = COLORS.REACT_RENDER;
hoveredFillStyle = COLORS.REACT_RENDER_HOVER;
groupSelectedFillStyle = COLORS.REACT_RENDER_SELECTED;
groupSelectedFillStyle = COLORS.REACT_RENDER_HOVER;
break;
case 'layout-effects':
fillStyle = COLORS.REACT_LAYOUT_EFFECTS;
hoveredFillStyle = COLORS.REACT_LAYOUT_EFFECTS_HOVER;
groupSelectedFillStyle = COLORS.REACT_LAYOUT_EFFECTS_SELECTED;
groupSelectedFillStyle = COLORS.REACT_LAYOUT_EFFECTS_HOVER;
break;
case 'passive-effects':
fillStyle = COLORS.REACT_PASSIVE_EFFECTS;
hoveredFillStyle = COLORS.REACT_PASSIVE_EFFECTS_HOVER;
groupSelectedFillStyle = COLORS.REACT_PASSIVE_EFFECTS_SELECTED;
groupSelectedFillStyle = COLORS.REACT_PASSIVE_EFFECTS_HOVER;
break;
default:
throw new Error(`Unexpected measure type "${type}"`);
@@ -306,7 +308,7 @@ export class ReactMeasuresView extends View {
hoverTimestamp >= timestamp &&
hoverTimestamp <= timestamp + duration
) {
this.currentCursor = 'pointer';
this.currentCursor = 'context-menu';
viewRefs.hoveredView = this;
onHover(measure);
return;
@@ -7,7 +7,7 @@
* @flow
*/
import type {ReactEvent, ReactProfilerData} from '../types';
import type {SchedulingEvent, ReactProfilerData} from '../types';
import type {
Interaction,
MouseMoveInteraction,
@@ -39,20 +39,12 @@ import {
const EVENT_ROW_HEIGHT_FIXED =
TOP_ROW_PADDING + REACT_EVENT_DIAMETER + TOP_ROW_PADDING;
function isSuspenseEvent(event: ReactEvent): boolean %checks {
return (
event.type === 'suspense-suspend' ||
event.type === 'suspense-resolved' ||
event.type === 'suspense-rejected'
);
}
export class ReactEventsView extends View {
export class SchedulingEventsView extends View {
_profilerData: ReactProfilerData;
_intrinsicSize: Size;
_hoveredEvent: ReactEvent | null = null;
onHover: ((event: ReactEvent | null) => void) | null = null;
_hoveredEvent: SchedulingEvent | null = null;
onHover: ((event: SchedulingEvent | null) => void) | null = null;
constructor(surface: Surface, frame: Rect, profilerData: ReactProfilerData) {
super(surface, frame);
@@ -68,7 +60,7 @@ export class ReactEventsView extends View {
return this._intrinsicSize;
}
setHoveredEvent(hoveredEvent: ReactEvent | null) {
setHoveredEvent(hoveredEvent: SchedulingEvent | null) {
if (this._hoveredEvent === hoveredEvent) {
return;
}
@@ -77,18 +69,18 @@ export class ReactEventsView extends View {
}
/**
* Draw a single `ReactEvent` as a circle in the canvas.
* Draw a single `SchedulingEvent` as a circle in the canvas.
*/
_drawSingleReactEvent(
_drawSingleSchedulingEvent(
context: CanvasRenderingContext2D,
rect: Rect,
event: ReactEvent,
event: SchedulingEvent,
baseY: number,
scaleFactor: number,
showHoverHighlight: boolean,
) {
const {frame} = this;
const {timestamp, type} = event;
const {timestamp, type, warning} = event;
const x = timestampToPosition(timestamp, scaleFactor, frame);
const radius = REACT_EVENT_DIAMETER / 2;
@@ -105,34 +97,25 @@ export class ReactEventsView extends View {
let fillStyle = null;
switch (type) {
case 'native-event':
return;
case 'schedule-render':
case 'schedule-state-update':
case 'schedule-force-update':
if (event.isCascading) {
fillStyle = showHoverHighlight
? COLORS.REACT_SCHEDULE_CASCADING_HOVER
: COLORS.REACT_SCHEDULE_CASCADING;
} else {
if (warning !== null) {
fillStyle = showHoverHighlight
? COLORS.WARNING_BACKGROUND_HOVER
: COLORS.WARNING_BACKGROUND;
} else {
switch (type) {
case 'schedule-render':
case 'schedule-state-update':
case 'schedule-force-update':
fillStyle = showHoverHighlight
? COLORS.REACT_SCHEDULE_HOVER
: COLORS.REACT_SCHEDULE;
}
break;
case 'suspense-suspend':
case 'suspense-resolved':
case 'suspense-rejected':
fillStyle = showHoverHighlight
? COLORS.REACT_SUSPEND_HOVER
: COLORS.REACT_SUSPEND;
break;
default:
if (__DEV__) {
console.warn('Unexpected event type "%s"', type);
}
break;
break;
default:
if (__DEV__) {
console.warn('Unexpected event type "%s"', type);
}
break;
}
}
if (fillStyle !== null) {
@@ -148,7 +131,7 @@ export class ReactEventsView extends View {
draw(context: CanvasRenderingContext2D) {
const {
frame,
_profilerData: {reactEvents},
_profilerData: {schedulingEvents},
_hoveredEvent,
visibleArea,
} = this;
@@ -168,20 +151,14 @@ export class ReactEventsView extends View {
frame,
);
const highlightedEvents: ReactEvent[] = [];
const highlightedEvents: SchedulingEvent[] = [];
reactEvents.forEach(event => {
if (
event === _hoveredEvent ||
(_hoveredEvent &&
isSuspenseEvent(event) &&
isSuspenseEvent(_hoveredEvent) &&
event.id === _hoveredEvent.id)
) {
schedulingEvents.forEach(event => {
if (event === _hoveredEvent) {
highlightedEvents.push(event);
return;
}
this._drawSingleReactEvent(
this._drawSingleSchedulingEvent(
context,
visibleArea,
event,
@@ -194,7 +171,7 @@ export class ReactEventsView extends View {
// Draw the highlighted items on top so they stand out.
// This is helpful if there are multiple (overlapping) items close to each other.
highlightedEvents.forEach(event => {
this._drawSingleReactEvent(
this._drawSingleSchedulingEvent(
context,
visibleArea,
event,
@@ -244,7 +221,7 @@ export class ReactEventsView extends View {
}
const {
_profilerData: {reactEvents},
_profilerData: {schedulingEvents},
} = this;
const scaleFactor = positioningScaleFactor(
this._intrinsicSize.width,
@@ -258,15 +235,15 @@ export class ReactEventsView extends View {
// Because data ranges may overlap, we want to find the last intersecting item.
// This will always be the one on "top" (the one the user is hovering over).
for (let index = reactEvents.length - 1; index >= 0; index--) {
const event = reactEvents[index];
for (let index = schedulingEvents.length - 1; index >= 0; index--) {
const event = schedulingEvents[index];
const {timestamp} = event;
if (
timestamp - eventTimestampAllowance <= hoverTimestamp &&
hoverTimestamp <= timestamp + eventTimestampAllowance
) {
this.currentCursor = 'pointer';
this.currentCursor = 'context-menu';
viewRefs.hoveredView = this;
onHover(event);
return;
@@ -0,0 +1,368 @@
/**
* 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 type {SuspenseEvent, ReactProfilerData} from '../types';
import type {
Interaction,
MouseMoveInteraction,
Rect,
SizeWithMaxHeight,
ViewRefs,
} from '../view-base';
import {
durationToWidth,
positioningScaleFactor,
positionToTimestamp,
timestampToPosition,
widthToDuration,
} from './utils/positioning';
import {drawText} from './utils/text';
import {formatDuration} from '../utils/formatting';
import {
View,
Surface,
rectContainsPoint,
rectIntersectsRect,
intersectionOfRects,
} from '../view-base';
import {
BORDER_SIZE,
COLORS,
PENDING_SUSPENSE_EVENT_SIZE,
SUSPENSE_EVENT_HEIGHT,
} from './constants';
const ROW_WITH_BORDER_HEIGHT = SUSPENSE_EVENT_HEIGHT + BORDER_SIZE;
const MAX_ROWS_TO_SHOW_INITIALLY = 3;
export class SuspenseEventsView extends View {
_depthToSuspenseEvent: Map<number, SuspenseEvent[]>;
_hoveredEvent: SuspenseEvent | null = null;
_intrinsicSize: SizeWithMaxHeight;
_maxDepth: number = 0;
_profilerData: ReactProfilerData;
onHover: ((event: SuspenseEvent | null) => void) | null = null;
constructor(surface: Surface, frame: Rect, profilerData: ReactProfilerData) {
super(surface, frame);
this._profilerData = profilerData;
this._performPreflightComputations();
}
_performPreflightComputations() {
this._depthToSuspenseEvent = new Map();
const {duration, suspenseEvents} = this._profilerData;
suspenseEvents.forEach(event => {
const depth = event.depth;
this._maxDepth = Math.max(this._maxDepth, depth);
if (!this._depthToSuspenseEvent.has(depth)) {
this._depthToSuspenseEvent.set(depth, [event]);
} else {
// $FlowFixMe This is unnecessary.
this._depthToSuspenseEvent.get(depth).push(event);
}
});
this._intrinsicSize = {
width: duration,
height: (this._maxDepth + 1) * ROW_WITH_BORDER_HEIGHT,
maxInitialHeight: ROW_WITH_BORDER_HEIGHT * MAX_ROWS_TO_SHOW_INITIALLY,
};
}
desiredSize() {
return this._intrinsicSize;
}
setHoveredEvent(hoveredEvent: SuspenseEvent | null) {
if (this._hoveredEvent === hoveredEvent) {
return;
}
this._hoveredEvent = hoveredEvent;
this.setNeedsDisplay();
}
/**
* Draw a single `SuspenseEvent` as a box/span with text inside of it.
*/
_drawSingleSuspenseEvent(
context: CanvasRenderingContext2D,
rect: Rect,
event: SuspenseEvent,
baseY: number,
scaleFactor: number,
showHoverHighlight: boolean,
) {
const {frame} = this;
const {
componentName,
depth,
duration,
phase,
resolution,
timestamp,
warning,
} = event;
baseY += depth * ROW_WITH_BORDER_HEIGHT;
let fillStyle = ((null: any): string);
if (warning !== null) {
fillStyle = showHoverHighlight
? COLORS.WARNING_BACKGROUND_HOVER
: COLORS.WARNING_BACKGROUND;
} else {
switch (resolution) {
case 'rejected':
fillStyle = showHoverHighlight
? COLORS.REACT_SUSPENSE_REJECTED_EVENT_HOVER
: COLORS.REACT_SUSPENSE_REJECTED_EVENT;
break;
case 'resolved':
fillStyle = showHoverHighlight
? COLORS.REACT_SUSPENSE_RESOLVED_EVENT_HOVER
: COLORS.REACT_SUSPENSE_RESOLVED_EVENT;
break;
case 'unresolved':
fillStyle = showHoverHighlight
? COLORS.REACT_SUSPENSE_UNRESOLVED_EVENT_HOVER
: COLORS.REACT_SUSPENSE_UNRESOLVED_EVENT;
break;
}
}
const xStart = timestampToPosition(timestamp, scaleFactor, frame);
// Pending suspense events (ones that never resolved) won't have durations.
// So instead we draw them as diamonds.
if (duration === null) {
const size = PENDING_SUSPENSE_EVENT_SIZE;
const halfSize = size / 2;
baseY += (SUSPENSE_EVENT_HEIGHT - PENDING_SUSPENSE_EVENT_SIZE) / 2;
const y = baseY + halfSize;
const suspenseRect: Rect = {
origin: {
x: xStart - halfSize,
y: baseY,
},
size: {width: size, height: size},
};
if (!rectIntersectsRect(suspenseRect, rect)) {
return; // Not in view
}
const drawableRect = intersectionOfRects(suspenseRect, rect);
// Clip diamonds so they don't overflow if the view has been resized (smaller).
const region = new Path2D();
region.rect(
drawableRect.origin.x,
drawableRect.origin.y,
drawableRect.size.width,
drawableRect.size.height,
);
context.save();
context.clip(region);
context.beginPath();
context.fillStyle = fillStyle;
context.moveTo(xStart, y - halfSize);
context.lineTo(xStart + halfSize, y);
context.lineTo(xStart, y + halfSize);
context.lineTo(xStart - halfSize, y);
context.fill();
context.restore();
} else {
const xStop = timestampToPosition(
timestamp + duration,
scaleFactor,
frame,
);
const eventRect: Rect = {
origin: {
x: xStart,
y: baseY,
},
size: {width: xStop - xStart, height: SUSPENSE_EVENT_HEIGHT},
};
if (!rectIntersectsRect(eventRect, rect)) {
return; // Not in view
}
const width = durationToWidth(duration, scaleFactor);
if (width < 1) {
return; // Too small to render at this zoom level
}
const drawableRect = intersectionOfRects(eventRect, rect);
context.beginPath();
context.fillStyle = fillStyle;
context.fillRect(
drawableRect.origin.x,
drawableRect.origin.y,
drawableRect.size.width,
drawableRect.size.height,
);
let label = 'suspended';
if (componentName != null) {
label = `${componentName} ${label}`;
}
if (phase !== null) {
label += ` during ${phase}`;
}
if (resolution !== 'unresolved') {
label += ` - ${formatDuration(duration)}`;
}
drawText(label, context, eventRect, drawableRect, width);
}
}
draw(context: CanvasRenderingContext2D) {
const {
frame,
_profilerData: {suspenseEvents},
_hoveredEvent,
visibleArea,
} = this;
context.fillStyle = COLORS.PRIORITY_BACKGROUND;
context.fillRect(
visibleArea.origin.x,
visibleArea.origin.y,
visibleArea.size.width,
visibleArea.size.height,
);
// Draw events
const scaleFactor = positioningScaleFactor(
this._intrinsicSize.width,
frame,
);
suspenseEvents.forEach(event => {
this._drawSingleSuspenseEvent(
context,
visibleArea,
event,
frame.origin.y,
scaleFactor,
event === _hoveredEvent,
);
});
// Render bottom borders.
for (let i = 0; i <= this._maxDepth; i++) {
const borderFrame: Rect = {
origin: {
x: frame.origin.x,
y: frame.origin.y + (i + 1) * ROW_WITH_BORDER_HEIGHT - BORDER_SIZE,
},
size: {
width: frame.size.width,
height: BORDER_SIZE,
},
};
if (rectIntersectsRect(borderFrame, visibleArea)) {
const borderDrawableRect = intersectionOfRects(
borderFrame,
visibleArea,
);
context.fillStyle = COLORS.PRIORITY_BORDER;
context.fillRect(
borderDrawableRect.origin.x,
borderDrawableRect.origin.y,
borderDrawableRect.size.width,
borderDrawableRect.size.height,
);
}
}
}
/**
* @private
*/
_handleMouseMove(interaction: MouseMoveInteraction, viewRefs: ViewRefs) {
const {frame, _intrinsicSize, onHover, visibleArea} = this;
if (!onHover) {
return;
}
const {location} = interaction.payload;
if (!rectContainsPoint(location, visibleArea)) {
onHover(null);
return;
}
const scaleFactor = positioningScaleFactor(_intrinsicSize.width, frame);
const hoverTimestamp = positionToTimestamp(location.x, scaleFactor, frame);
const adjustedCanvasMouseY = location.y - frame.origin.y;
const depth = Math.floor(adjustedCanvasMouseY / ROW_WITH_BORDER_HEIGHT);
const suspenseEventsAtDepth = this._depthToSuspenseEvent.get(depth);
if (suspenseEventsAtDepth) {
// Find the event being hovered over.
for (let index = suspenseEventsAtDepth.length - 1; index >= 0; index--) {
const suspenseEvent = suspenseEventsAtDepth[index];
const {duration, timestamp} = suspenseEvent;
if (duration === null) {
const timestampAllowance = widthToDuration(
PENDING_SUSPENSE_EVENT_SIZE / 2,
scaleFactor,
);
if (
timestamp - timestampAllowance <= hoverTimestamp &&
hoverTimestamp <= timestamp + timestampAllowance
) {
this.currentCursor = 'context-menu';
viewRefs.hoveredView = this;
onHover(suspenseEvent);
return;
}
} else if (
hoverTimestamp >= timestamp &&
hoverTimestamp <= timestamp + duration
) {
this.currentCursor = 'context-menu';
viewRefs.hoveredView = this;
onHover(suspenseEvent);
return;
}
}
}
onHover(null);
}
handleInteraction(interaction: Interaction, viewRefs: ViewRefs) {
switch (interaction.type) {
case 'mousemove':
this._handleMouseMove(interaction, viewRefs);
break;
}
}
}
@@ -209,7 +209,7 @@ export class UserTimingMarksView extends View {
frame,
);
const hoverTimestamp = positionToTimestamp(location.x, scaleFactor, frame);
const markTimestampAllowance = widthToDuration(
const timestampAllowance = widthToDuration(
USER_TIMING_MARK_SIZE / 2,
scaleFactor,
);
@@ -221,10 +221,10 @@ export class UserTimingMarksView extends View {
const {timestamp} = mark;
if (
timestamp - markTimestampAllowance <= hoverTimestamp &&
hoverTimestamp <= timestamp + markTimestampAllowance
timestamp - timestampAllowance <= hoverTimestamp &&
hoverTimestamp <= timestamp + timestampAllowance
) {
this.currentCursor = 'pointer';
this.currentCursor = 'context-menu';
viewRefs.hoveredView = this;
onHover(mark);
return;
@@ -15,6 +15,8 @@ export const MARKER_TEXT_PADDING = 8;
export const COLOR_HOVER_DIM_DELTA = 5;
export const TOP_ROW_PADDING = 4;
export const NATIVE_EVENT_HEIGHT = 14;
export const SUSPENSE_EVENT_HEIGHT = 14;
export const PENDING_SUSPENSE_EVENT_SIZE = 8;
export const REACT_EVENT_DIAMETER = 6;
export const USER_TIMING_MARK_SIZE = 8;
export const REACT_MEASURE_HEIGHT = 9;
@@ -43,44 +45,51 @@ export let COLORS = {
BACKGROUND: '',
NATIVE_EVENT: '',
NATIVE_EVENT_HOVER: '',
NATIVE_EVENT_WARNING: '',
NATIVE_EVENT_WARNING_HOVER: '',
NATIVE_EVENT_WARNING_TEXT: '',
PRIORITY_BACKGROUND: '',
PRIORITY_BORDER: '',
PRIORITY_LABEL: '',
USER_TIMING: '',
USER_TIMING_HOVER: '',
REACT_IDLE: '',
REACT_IDLE_SELECTED: '',
REACT_IDLE_HOVER: '',
REACT_RENDER: '',
REACT_RENDER_SELECTED: '',
REACT_RENDER_HOVER: '',
REACT_COMMIT: '',
REACT_COMMIT_SELECTED: '',
REACT_COMMIT_HOVER: '',
REACT_LAYOUT_EFFECTS: '',
REACT_LAYOUT_EFFECTS_SELECTED: '',
REACT_LAYOUT_EFFECTS_HOVER: '',
REACT_PASSIVE_EFFECTS: '',
REACT_PASSIVE_EFFECTS_SELECTED: '',
REACT_PASSIVE_EFFECTS_HOVER: '',
REACT_RESIZE_BAR: '',
REACT_RESIZE_BAR_ACTIVE: '',
REACT_RESIZE_BAR_BORDER: '',
REACT_RESIZE_BAR_DOT: '',
REACT_SCHEDULE: '',
REACT_SCHEDULE_HOVER: '',
REACT_SCHEDULE_CASCADING: '',
REACT_SCHEDULE_CASCADING_HOVER: '',
REACT_SUSPEND: '',
REACT_SUSPEND_HOVER: '',
REACT_SUSPENSE_REJECTED_EVENT: '',
REACT_SUSPENSE_REJECTED_EVENT_HOVER: '',
REACT_SUSPENSE_RESOLVED_EVENT: '',
REACT_SUSPENSE_RESOLVED_EVENT_HOVER: '',
REACT_SUSPENSE_UNRESOLVED_EVENT: '',
REACT_SUSPENSE_UNRESOLVED_EVENT_HOVER: '',
REACT_WORK_BORDER: '',
SCROLL_CARET: '',
TEXT_COLOR: '',
TIME_MARKER_LABEL: '',
WARNING_BACKGROUND: '',
WARNING_BACKGROUND_HOVER: '',
WARNING_TEXT: '',
WARNING_TEXT_INVERED: '',
};
export function updateColorsToMatchTheme(): void {
export function updateColorsToMatchTheme(): boolean {
const computedStyle = getComputedStyle((document.body: any));
// Check to see if styles have been initialized...
if (computedStyle.getPropertyValue('--color-background') == null) {
return false;
}
COLORS = {
BACKGROUND: computedStyle.getPropertyValue('--color-background'),
NATIVE_EVENT: computedStyle.getPropertyValue(
@@ -89,15 +98,6 @@ export function updateColorsToMatchTheme(): void {
NATIVE_EVENT_HOVER: computedStyle.getPropertyValue(
'--color-scheduling-profiler-native-event-hover',
),
NATIVE_EVENT_WARNING: computedStyle.getPropertyValue(
'--color-scheduling-profiler-native-event-warning',
),
NATIVE_EVENT_WARNING_HOVER: computedStyle.getPropertyValue(
'--color-scheduling-profiler-native-event-warning-hover',
),
NATIVE_EVENT_WARNING_TEXT: computedStyle.getPropertyValue(
'--color-scheduling-profiler-native-event-warning-text',
),
PRIORITY_BACKGROUND: computedStyle.getPropertyValue(
'--color-scheduling-profiler-priority-background',
),
@@ -114,73 +114,86 @@ export function updateColorsToMatchTheme(): void {
REACT_IDLE: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-idle',
),
REACT_IDLE_SELECTED: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-idle-selected',
),
REACT_IDLE_HOVER: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-idle-hover',
),
REACT_RENDER: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-render',
),
REACT_RENDER_SELECTED: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-render-selected',
),
REACT_RENDER_HOVER: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-render-hover',
),
REACT_COMMIT: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-commit',
),
REACT_COMMIT_SELECTED: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-commit-selected',
),
REACT_COMMIT_HOVER: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-commit-hover',
),
REACT_LAYOUT_EFFECTS: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-layout-effects',
),
REACT_LAYOUT_EFFECTS_SELECTED: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-layout-effects-selected',
),
REACT_LAYOUT_EFFECTS_HOVER: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-layout-effects-hover',
),
REACT_PASSIVE_EFFECTS: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-passive-effects',
),
REACT_PASSIVE_EFFECTS_SELECTED: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-passive-effects-selected',
),
REACT_PASSIVE_EFFECTS_HOVER: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-passive-effects-hover',
),
REACT_RESIZE_BAR: computedStyle.getPropertyValue('--color-resize-bar'),
REACT_RESIZE_BAR_ACTIVE: computedStyle.getPropertyValue(
'--color-resize-bar-active',
),
REACT_RESIZE_BAR_BORDER: computedStyle.getPropertyValue(
'--color-resize-bar-border',
),
REACT_RESIZE_BAR_DOT: computedStyle.getPropertyValue(
'--color-resize-bar-dot',
),
REACT_SCHEDULE: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-schedule',
),
REACT_SCHEDULE_HOVER: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-schedule-hover',
),
REACT_SCHEDULE_CASCADING: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-schedule-cascading',
REACT_SUSPENSE_REJECTED_EVENT: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-suspense-rejected',
),
REACT_SCHEDULE_CASCADING_HOVER: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-schedule-cascading-hover',
REACT_SUSPENSE_REJECTED_EVENT_HOVER: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-suspense-rejected-hover',
),
REACT_SUSPEND: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-suspend',
REACT_SUSPENSE_RESOLVED_EVENT: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-suspense-resolved',
),
REACT_SUSPEND_HOVER: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-suspend-hover',
REACT_SUSPENSE_RESOLVED_EVENT_HOVER: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-suspense-resolved-hover',
),
REACT_SUSPENSE_UNRESOLVED_EVENT: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-suspense-unresolved',
),
REACT_SUSPENSE_UNRESOLVED_EVENT_HOVER: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-suspense-unresolved-hover',
),
REACT_WORK_BORDER: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-work-border',
),
SCROLL_CARET: computedStyle.getPropertyValue('--color-scroll-caret'),
TEXT_COLOR: computedStyle.getPropertyValue(
'--color-scheduling-profiler-text-color',
),
TIME_MARKER_LABEL: computedStyle.getPropertyValue('--color-text'),
WARNING_BACKGROUND: computedStyle.getPropertyValue(
'--color-warning-background',
),
WARNING_BACKGROUND_HOVER: computedStyle.getPropertyValue(
'--color-warning-background-hover',
),
WARNING_TEXT: computedStyle.getPropertyValue('--color-warning-text-color'),
WARNING_TEXT_INVERED: computedStyle.getPropertyValue(
'--color-warning-text-color-inverted',
),
};
return true;
}
@@ -9,7 +9,8 @@
export * from './FlamechartView';
export * from './NativeEventsView';
export * from './ReactEventsView';
export * from './ReactMeasuresView';
export * from './SchedulingEventsView';
export * from './SuspenseEventsView';
export * from './TimeAxisMarkersView';
export * from './UserTimingMarksView';
@@ -0,0 +1,98 @@
/**
* 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 type {Rect} from '../../view-base';
import {rectEqualToRect} from '../../view-base';
import {COLORS, FONT_SIZE, TEXT_PADDING} from '../constants';
const cachedTextWidths = new Map();
export function trimText(
context: CanvasRenderingContext2D,
text: string,
width: number,
): string | null {
for (let i = text.length - 1; i >= 0; i--) {
const trimmedText = i === text.length - 1 ? text : text.substr(0, i) + '…';
let measuredWidth = cachedTextWidths.get(trimmedText);
if (measuredWidth == null) {
measuredWidth = context.measureText(trimmedText).width;
cachedTextWidths.set(trimmedText, measuredWidth);
}
if (measuredWidth <= width) {
return trimmedText;
}
}
return null;
}
export function drawText(
text: string,
context: CanvasRenderingContext2D,
fullRect: Rect,
drawableRect: Rect,
availableWidth: number,
textAlign: 'left' | 'center' = 'left',
fillStyle: string = COLORS.TEXT_COLOR,
): void {
if (availableWidth > TEXT_PADDING * 2) {
context.textAlign = textAlign;
context.textBaseline = 'middle';
context.font = `${FONT_SIZE}px sans-serif`;
const {x, y} = fullRect.origin;
const trimmedName = trimText(
context,
text,
availableWidth - TEXT_PADDING * 2 + (x < 0 ? x : 0),
);
if (trimmedName !== null) {
context.fillStyle = fillStyle;
// Prevent text from visibly overflowing its container when clipped.
const textOverflowsViewableArea = !rectEqualToRect(
drawableRect,
fullRect,
);
if (textOverflowsViewableArea) {
context.save();
context.beginPath();
context.rect(
drawableRect.origin.x,
drawableRect.origin.y,
drawableRect.size.width,
drawableRect.size.height,
);
context.closePath();
context.clip();
}
let textX;
if (textAlign === 'center') {
textX = x + availableWidth / 2 + TEXT_PADDING - (x < 0 ? x : 0);
} else {
textX = x + TEXT_PADDING - (x < 0 ? x : 0);
}
const textY = y + fullRect.size.height / 2;
context.fillText(trimmedName, textX, textY);
if (textOverflowsViewableArea) {
context.restore();
}
}
}
}
@@ -208,8 +208,9 @@ describe(preprocessData, () => {
measures: [],
nativeEvents: [],
otherUserTimingMarks: [],
reactEvents: [],
schedulingEvents: [],
startTime: 1,
suspenseEvents: [],
});
});
@@ -300,16 +301,17 @@ describe(preprocessData, () => {
],
nativeEvents: [],
otherUserTimingMarks: [],
reactEvents: [
schedulingEvents: [
{
componentStack: '',
laneLabels: [],
lanes: [9],
timestamp: 0.002,
type: 'schedule-render',
warning: null,
},
],
startTime: 1,
suspenseEvents: [],
});
});
@@ -372,16 +374,17 @@ describe(preprocessData, () => {
timestamp: 0.004,
},
],
reactEvents: [
schedulingEvents: [
{
componentStack: '',
laneLabels: ['Sync'],
lanes: [0],
timestamp: 0.005,
type: 'schedule-render',
warning: null,
},
],
startTime: 1,
suspenseEvents: [],
});
});
@@ -507,25 +510,25 @@ describe(preprocessData, () => {
timestamp: 0.004,
},
],
reactEvents: [
schedulingEvents: [
{
componentStack: '',
laneLabels: ['Default'],
lanes: [4],
timestamp: 0.005,
type: 'schedule-render',
warning: null,
},
{
componentName: 'App',
componentStack: '',
isCascading: false,
laneLabels: ['Default'],
lanes: [4],
timestamp: 0.013,
type: 'schedule-state-update',
warning: null,
},
],
startTime: 1,
suspenseEvents: [],
});
});
@@ -20,6 +20,7 @@ import type {
ReactLane,
ReactMeasureType,
ReactProfilerData,
SuspenseEvent,
} from '../types';
import {REACT_TOTAL_NUM_LANES} from '../constants';
@@ -34,13 +35,25 @@ type MeasureStackElement = {|
|};
type ProcessorState = {|
nextRenderShouldGenerateNewBatchID: boolean,
batchUID: BatchUID,
uidCounter: BatchUID,
measureStack: MeasureStackElement[],
nativeEventStack: NativeEvent[],
nextRenderShouldGenerateNewBatchID: boolean,
uidCounter: BatchUID,
unresolvedSuspenseEvents: Map<string, SuspenseEvent>,
|};
let nativeEventStack: Array<NativeEvent> = [];
const NATIVE_EVENT_DURATION_THRESHOLD = 20;
const WARNING_STRINGS = {
LONG_EVENT_HANDLER:
'An event handler scheduled a big update with React. Consider using the Transition API to defer some of this work.',
NESTED_UPDATE:
'A nested update was scheduled during layout. These updates require React to re-render synchronously before the browser can paint.',
SUSPENDD_DURING_UPATE:
'A component suspended during an update which caused a fallback to be shown. ' +
"Consider using the Transition API to avoid hiding components after they've been mounted.",
};
// Exported for tests
export function getLanesFromTransportDecimalBitmask(
@@ -174,44 +187,54 @@ function processTimelineEvent(
const stackTrace = args.data.stackTrace;
if (stackTrace) {
const topFrame = stackTrace[stackTrace.length - 1];
if (topFrame.url.includes('node_modules/react-dom')) {
if (topFrame.url.includes('/react-dom.')) {
// Filter out fake React events dispatched by invokeGuardedCallbackDev.
return;
}
}
}
const timestamp = (ts - currentProfilerData.startTime) / 1000;
const duration = event.dur / 1000;
// Reduce noise from events like DOMActivate, load/unload, etc. which are usually not relevant
if (
type.startsWith('blur') ||
type.startsWith('click') ||
type.startsWith('focus') ||
type.startsWith('mouse') ||
type.startsWith('pointer')
) {
const timestamp = (ts - currentProfilerData.startTime) / 1000;
const duration = event.dur / 1000;
let depth = 0;
let depth = 0;
while (nativeEventStack.length > 0) {
const prevNativeEvent = nativeEventStack[nativeEventStack.length - 1];
const prevStopTime =
prevNativeEvent.timestamp + prevNativeEvent.duration;
while (state.nativeEventStack.length > 0) {
const prevNativeEvent =
state.nativeEventStack[state.nativeEventStack.length - 1];
const prevStopTime =
prevNativeEvent.timestamp + prevNativeEvent.duration;
if (timestamp < prevStopTime) {
depth = prevNativeEvent.depth + 1;
break;
} else {
nativeEventStack.pop();
if (timestamp < prevStopTime) {
depth = prevNativeEvent.depth + 1;
break;
} else {
state.nativeEventStack.pop();
}
}
const nativeEvent = {
depth,
duration,
timestamp,
type,
warning: null,
};
currentProfilerData.nativeEvents.push(nativeEvent);
// Keep track of curent event in case future ones overlap.
// We separate them into different vertical lanes in this case.
state.nativeEventStack.push(nativeEvent);
}
const nativeEvent = {
depth,
duration,
timestamp,
type,
warnings: null,
};
currentProfilerData.nativeEvents.push(nativeEvent);
// Keep track of curent event in case future ones overlap.
// We separate them into different vertical lanes in this case.
nativeEventStack.push(nativeEvent);
}
break;
case 'blink.user_timing':
@@ -219,92 +242,141 @@ function processTimelineEvent(
// React Events - schedule
if (name.startsWith('--schedule-render-')) {
const [
laneBitmaskString,
laneLabels,
...splitComponentStack
] = name.substr(18).split('-');
currentProfilerData.reactEvents.push({
const [laneBitmaskString, laneLabels] = name.substr(18).split('-');
currentProfilerData.schedulingEvents.push({
type: 'schedule-render',
lanes: getLanesFromTransportDecimalBitmask(laneBitmaskString),
laneLabels: laneLabels ? laneLabels.split(',') : [],
componentStack: splitComponentStack.join('-'),
timestamp: startTime,
warning: null,
});
} else if (name.startsWith('--schedule-forced-update-')) {
const [
laneBitmaskString,
laneLabels,
componentName,
...splitComponentStack
] = name.substr(25).split('-');
const isCascading = !!state.measureStack.find(
({type}) => type === 'commit',
);
currentProfilerData.reactEvents.push({
const [laneBitmaskString, laneLabels, componentName] = name
.substr(25)
.split('-');
let warning = null;
if (state.measureStack.find(({type}) => type === 'commit')) {
// TODO (scheduling profiler) Only warn if the subsequent update is longer than some threshold.
warning = WARNING_STRINGS.NESTED_UPDATE;
}
currentProfilerData.schedulingEvents.push({
type: 'schedule-force-update',
lanes: getLanesFromTransportDecimalBitmask(laneBitmaskString),
laneLabels: laneLabels ? laneLabels.split(',') : [],
componentName,
componentStack: splitComponentStack.join('-'),
timestamp: startTime,
isCascading,
warning,
});
} else if (name.startsWith('--schedule-state-update-')) {
const [
laneBitmaskString,
laneLabels,
componentName,
...splitComponentStack
] = name.substr(24).split('-');
const isCascading = !!state.measureStack.find(
({type}) => type === 'commit',
);
currentProfilerData.reactEvents.push({
const [laneBitmaskString, laneLabels, componentName] = name
.substr(24)
.split('-');
let warning = null;
if (state.measureStack.find(({type}) => type === 'commit')) {
// TODO (scheduling profiler) Only warn if the subsequent update is longer than some threshold.
warning = WARNING_STRINGS.NESTED_UPDATE;
}
currentProfilerData.schedulingEvents.push({
type: 'schedule-state-update',
lanes: getLanesFromTransportDecimalBitmask(laneBitmaskString),
laneLabels: laneLabels ? laneLabels.split(',') : [],
componentName,
componentStack: splitComponentStack.join('-'),
timestamp: startTime,
isCascading,
warning,
});
} // eslint-disable-line brace-style
// React Events - suspense
else if (name.startsWith('--suspense-suspend-')) {
const [id, componentName, ...splitComponentStack] = name
.substr(19)
.split('-');
currentProfilerData.reactEvents.push({
type: 'suspense-suspend',
id,
componentName,
componentStack: splitComponentStack.join('-'),
timestamp: startTime,
const [id, componentName, ...rest] = name.substr(19).split('-');
// Older versions of the scheduling profiler data didn't contain phase or lane values.
let phase = null;
let warning = null;
if (rest.length === 3) {
switch (rest[0]) {
case 'mount':
case 'update':
phase = rest[0];
break;
}
if (phase === 'update') {
const laneLabels = rest[2];
// HACK This is a bit gross but the numeric lane value might change between render versions.
if (!laneLabels.includes('Transition')) {
warning = WARNING_STRINGS.SUSPENDD_DURING_UPATE;
}
}
}
const availableDepths = new Array(
state.unresolvedSuspenseEvents.size + 1,
).fill(true);
state.unresolvedSuspenseEvents.forEach(({depth}) => {
availableDepths[depth] = false;
});
let depth = 0;
for (let i = 0; i < availableDepths.length; i++) {
if (availableDepths[i]) {
depth = i;
break;
}
}
// TODO (scheduling profiler) Maybe we should calculate depth in post,
// so unresolved Suspense requests don't take up space.
// We can't know if they'll be resolved or not at this point.
// We'll just give them a default (fake) duration width.
const suspenseEvent = {
componentName,
depth,
duration: null,
id,
phase,
resolution: 'unresolved',
resuspendTimestamps: null,
timestamp: startTime,
type: 'suspense',
warning,
};
currentProfilerData.suspenseEvents.push(suspenseEvent);
state.unresolvedSuspenseEvents.set(id, suspenseEvent);
} else if (name.startsWith('--suspense-resuspend-')) {
const [id] = name.substr(21).split('-');
const suspenseEvent = state.unresolvedSuspenseEvents.get(id);
if (suspenseEvent != null) {
if (suspenseEvent.resuspendTimestamps === null) {
suspenseEvent.resuspendTimestamps = [startTime];
} else {
suspenseEvent.resuspendTimestamps.push(startTime);
}
}
} else if (name.startsWith('--suspense-resolved-')) {
const [id, componentName, ...splitComponentStack] = name
.substr(20)
.split('-');
currentProfilerData.reactEvents.push({
type: 'suspense-resolved',
id,
componentName,
componentStack: splitComponentStack.join('-'),
timestamp: startTime,
});
const [id] = name.substr(20).split('-');
const suspenseEvent = state.unresolvedSuspenseEvents.get(id);
if (suspenseEvent != null) {
state.unresolvedSuspenseEvents.delete(id);
suspenseEvent.duration = startTime - suspenseEvent.timestamp;
suspenseEvent.resolution = 'resolved';
}
} else if (name.startsWith('--suspense-rejected-')) {
const [id, componentName, ...splitComponentStack] = name
.substr(20)
.split('-');
currentProfilerData.reactEvents.push({
type: 'suspense-rejected',
id,
componentName,
componentStack: splitComponentStack.join('-'),
timestamp: startTime,
});
const [id] = name.substr(20).split('-');
const suspenseEvent = state.unresolvedSuspenseEvents.get(id);
if (suspenseEvent != null) {
state.unresolvedSuspenseEvents.delete(id);
suspenseEvent.duration = startTime - suspenseEvent.timestamp;
suspenseEvent.resolution = 'rejected';
}
} // eslint-disable-line brace-style
// React Measures - render
@@ -335,17 +407,14 @@ function processTimelineEvent(
state,
);
for (let i = 0; i < nativeEventStack.length; i++) {
const nativeEvent = nativeEventStack[i];
for (let i = 0; i < state.nativeEventStack.length; i++) {
const nativeEvent = state.nativeEventStack[i];
const stopTime = nativeEvent.timestamp + nativeEvent.duration;
if (stopTime > startTime) {
const warning =
'An event handler scheduled a synchronous update with React.';
if (nativeEvent.warnings === null) {
nativeEvent.warnings = new Set([warning]);
} else {
nativeEvent.warnings.add(warning);
}
if (
stopTime > startTime &&
nativeEvent.duration > NATIVE_EVENT_DURATION_THRESHOLD
) {
nativeEvent.warning = WARNING_STRINGS.LONG_EVENT_HANDLER;
}
}
} else if (
@@ -509,18 +578,17 @@ function preprocessFlamechart(rawData: TimelineEvent[]): Flamechart {
export default function preprocessData(
timeline: TimelineEvent[],
): ReactProfilerData {
nativeEventStack = [];
const flamechart = preprocessFlamechart(timeline);
const profilerData: ReactProfilerData = {
startTime: 0,
duration: 0,
nativeEvents: [],
reactEvents: [],
measures: [],
flamechart,
measures: [],
nativeEvents: [],
otherUserTimingMarks: [],
schedulingEvents: [],
startTime: 0,
suspenseEvents: [],
};
// Sort `timeline`. JSON Array Format trace events need not be ordered. See:
@@ -551,9 +619,11 @@ export default function preprocessData(
const state: ProcessorState = {
batchUID: 0,
uidCounter: 0,
nextRenderShouldGenerateNewBatchID: true,
measureStack: [],
nativeEventStack: [],
nextRenderShouldGenerateNewBatchID: true,
uidCounter: 0,
unresolvedSuspenseEvents: new Map(),
};
timeline.forEach(event => processTimelineEvent(event, profilerData, state));
+25 -34
View File
@@ -26,13 +26,13 @@ export type NativeEvent = {|
+duration: Milliseconds,
+timestamp: Milliseconds,
+type: string,
warnings: Set<string> | null,
warning: string | null,
|};
type BaseReactEvent = {|
+componentName?: string,
+componentStack?: string,
+timestamp: Milliseconds,
warning: string | null,
|};
type BaseReactScheduleEvent = {|
@@ -42,44 +42,33 @@ type BaseReactScheduleEvent = {|
|};
export type ReactScheduleRenderEvent = {|
...BaseReactScheduleEvent,
type: 'schedule-render',
+type: 'schedule-render',
|};
export type ReactScheduleStateUpdateEvent = {|
...BaseReactScheduleEvent,
type: 'schedule-state-update',
isCascading: boolean,
+type: 'schedule-state-update',
|};
export type ReactScheduleForceUpdateEvent = {|
...BaseReactScheduleEvent,
type: 'schedule-force-update',
isCascading: boolean,
+type: 'schedule-force-update',
|};
type BaseReactSuspenseEvent = {|
export type SuspenseEvent = {|
...BaseReactEvent,
id: string,
|};
export type ReactSuspenseSuspendEvent = {|
...BaseReactSuspenseEvent,
type: 'suspense-suspend',
|};
export type ReactSuspenseResolvedEvent = {|
...BaseReactSuspenseEvent,
type: 'suspense-resolved',
|};
export type ReactSuspenseRejectedEvent = {|
...BaseReactSuspenseEvent,
type: 'suspense-rejected',
depth: number,
duration: number | null,
+id: string,
+phase: 'mount' | 'update' | null,
resolution: 'rejected' | 'resolved' | 'unresolved',
resuspendTimestamps: Array<number> | null,
+type: 'suspense',
|};
export type ReactEvent =
export type SchedulingEvent =
| ReactScheduleRenderEvent
| ReactScheduleStateUpdateEvent
| ReactScheduleForceUpdateEvent
| ReactSuspenseSuspendEvent
| ReactSuspenseResolvedEvent
| ReactSuspenseRejectedEvent;
export type ReactEventType = $PropertyType<ReactEvent, 'type'>;
| ReactScheduleForceUpdateEvent;
export type SchedulingEventType = $PropertyType<SchedulingEvent, 'type'>;
export type ReactMeasureType =
| 'commit'
@@ -128,20 +117,22 @@ export type FlamechartStackLayer = FlamechartStackFrame[];
export type Flamechart = FlamechartStackLayer[];
export type ReactProfilerData = {|
startTime: number,
duration: number,
nativeEvents: NativeEvent[],
reactEvents: ReactEvent[],
measures: ReactMeasure[],
flamechart: Flamechart,
measures: ReactMeasure[],
nativeEvents: NativeEvent[],
otherUserTimingMarks: UserTimingMark[],
schedulingEvents: SchedulingEvent[],
startTime: number,
suspenseEvents: SuspenseEvent[],
|};
export type ReactHoverContextInfo = {|
nativeEvent: NativeEvent | null,
reactEvent: ReactEvent | null,
measure: ReactMeasure | null,
data: $ReadOnly<ReactProfilerData> | null,
flamechartStackFrame: FlamechartStackFrame | null,
measure: ReactMeasure | null,
nativeEvent: NativeEvent | null,
schedulingEvent: SchedulingEvent | null,
suspenseEvent: SuspenseEvent | null,
userTimingMark: UserTimingMark | null,
|};
@@ -0,0 +1,30 @@
/**
* 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 prettyMilliseconds from 'pretty-ms';
export function formatTimestamp(ms: number) {
return (
ms.toLocaleString(undefined, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}) + 'ms'
);
}
export function formatDuration(ms: number) {
return prettyMilliseconds(ms, {millisecondsDecimalDigits: 1});
}
export function trimString(string: string, length: number): string {
if (string.length > length) {
return `${string.substr(0, length - 1)}…`;
}
return string;
}
@@ -9,7 +9,8 @@
import {useLayoutEffect, useRef} from 'react';
const TOOLTIP_OFFSET = 4;
const TOOLTIP_OFFSET_BOTTOM = 10;
const TOOLTIP_OFFSET_TOP = 5;
export default function useSmartTooltip({
canvasRef,
@@ -37,41 +38,41 @@ export default function useSmartTooltip({
const element = ref.current;
if (element !== null) {
// Let's check the vertical position.
if (mouseY + TOOLTIP_OFFSET + element.offsetHeight >= height) {
if (mouseY + TOOLTIP_OFFSET_BOTTOM + 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 - element.offsetHeight > 0) {
if (mouseY - TOOLTIP_OFFSET_TOP - element.offsetHeight > 0) {
// We position the tooltip above the mouse cursor if it fits there.
element.style.top = `${mouseY -
element.offsetHeight -
TOOLTIP_OFFSET}px`;
TOOLTIP_OFFSET_TOP}px`;
} else {
// Otherwise we align the tooltip with the window's top edge.
element.style.top = '0px';
}
} else {
element.style.top = `${mouseY + TOOLTIP_OFFSET}px`;
element.style.top = `${mouseY + TOOLTIP_OFFSET_BOTTOM}px`;
}
// Now let's check the horizontal position.
if (mouseX + TOOLTIP_OFFSET + element.offsetWidth >= width) {
if (mouseX + TOOLTIP_OFFSET_BOTTOM + 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 - element.offsetWidth > 0) {
if (mouseX - TOOLTIP_OFFSET_TOP - element.offsetWidth > 0) {
// We position the tooltip at the left of the mouse cursor if it fits
// there.
element.style.left = `${mouseX -
element.offsetWidth -
TOOLTIP_OFFSET}px`;
TOOLTIP_OFFSET_TOP}px`;
} else {
// Otherwise, align the tooltip with the window's left edge.
element.style.left = '0px';
}
} else {
element.style.left = `${mouseX + TOOLTIP_OFFSET}px`;
element.style.left = `${mouseX + TOOLTIP_OFFSET_BOTTOM}px`;
}
}
}, [mouseX, mouseY, ref]);
@@ -7,33 +7,17 @@
* @flow
*/
import type {Rect} from './geometry';
import {Surface} from './Surface';
import {View} from './View';
import {COLORS} from '../content-views/constants';
/**
* View that fills its visible area with a CSS color.
*/
export class ColorView extends View {
_color: string;
constructor(surface: Surface, frame: Rect, color: string) {
super(surface, frame);
this._color = color;
}
setColor(color: string) {
if (this._color === color) {
return;
}
this._color = color;
this.setNeedsDisplay();
}
export class BackgroundColorView extends View {
draw(context: CanvasRenderingContext2D) {
const {_color, visibleArea} = this;
context.fillStyle = _color;
const {visibleArea} = this;
context.fillStyle = COLORS.BACKGROUND;
context.fillRect(
visibleArea.origin.x,
visibleArea.origin.y,
@@ -14,8 +14,6 @@ import type {
MouseUpInteraction,
WheelPlainInteraction,
WheelWithShiftInteraction,
WheelWithControlInteraction,
WheelWithMetaInteraction,
} from './useCanvasInteraction';
import type {Rect} from './geometry';
import type {ScrollState} from './utils/scrollState';
@@ -202,7 +200,7 @@ export class HorizontalPanAndZoomView extends View {
}
}
_handleWheelPlain(interaction: WheelPlainInteraction) {
_handleWheel(interaction: WheelPlainInteraction | WheelWithShiftInteraction) {
const {
location,
delta: {deltaX, deltaY},
@@ -214,51 +212,41 @@ export class HorizontalPanAndZoomView extends View {
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
// Vertical scrolling zooms in and out (unless the SHIFT modifier is used).
// Horizontal scrolling pans.
if (absDeltaY > absDeltaX) {
return; // Scrolling vertically
if (absDeltaY < MOVE_WHEEL_DELTA_THRESHOLD) {
return;
}
if (interaction.type === 'wheel-shift') {
// Shift modifier is for scrolling, not zooming.
return;
}
const newState = zoomState({
state: this._scrollState,
multiplier: 1 + 0.005 * -deltaY,
fixedPoint: location.x - this._scrollState.offset,
minContentLength: this._intrinsicContentWidth * MIN_ZOOM_LEVEL,
maxContentLength: this._intrinsicContentWidth * MAX_ZOOM_LEVEL,
containerLength: this.frame.size.width,
});
this._setStateAndInformCallbacksIfChanged(newState);
} else {
if (absDeltaX < MOVE_WHEEL_DELTA_THRESHOLD) {
return;
}
const newState = translateState({
state: this._scrollState,
delta: -deltaX,
containerLength: this.frame.size.width,
});
this._setStateAndInformCallbacksIfChanged(newState);
}
if (absDeltaX < MOVE_WHEEL_DELTA_THRESHOLD) {
return;
}
const newState = translateState({
state: this._scrollState,
delta: -deltaX,
containerLength: this.frame.size.width,
});
this._setStateAndInformCallbacksIfChanged(newState);
}
_handleWheelZoom(
interaction:
| WheelWithShiftInteraction
| WheelWithControlInteraction
| WheelWithMetaInteraction,
) {
const {
location,
delta: {deltaY},
} = interaction.payload;
if (!rectContainsPoint(location, this.frame)) {
return; // Not scrolling on view
}
const absDeltaY = Math.abs(deltaY);
if (absDeltaY < MOVE_WHEEL_DELTA_THRESHOLD) {
return;
}
const newState = zoomState({
state: this._scrollState,
multiplier: 1 + 0.005 * -deltaY,
fixedPoint: location.x - this._scrollState.offset,
minContentLength: this._intrinsicContentWidth * MIN_ZOOM_LEVEL,
maxContentLength: this._intrinsicContentWidth * MAX_ZOOM_LEVEL,
containerLength: this.frame.size.width,
});
this._setStateAndInformCallbacksIfChanged(newState);
}
handleInteraction(interaction: Interaction, viewRefs: ViewRefs) {
@@ -273,12 +261,8 @@ export class HorizontalPanAndZoomView extends View {
this._handleMouseUp(interaction, viewRefs);
break;
case 'wheel-plain':
this._handleWheelPlain(interaction);
break;
case 'wheel-shift':
case 'wheel-control':
case 'wheel-meta':
this._handleWheelZoom(interaction);
this._handleWheel(interaction);
break;
}
}
@@ -1,339 +0,0 @@
/**
* 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 type {
Interaction,
MouseDownInteraction,
MouseMoveInteraction,
MouseUpInteraction,
} from './useCanvasInteraction';
import type {Rect, Size} from './geometry';
import type {ViewRefs} from './Surface';
import {COLORS} from '../content-views/constants';
import nullthrows from 'nullthrows';
import {Surface} from './Surface';
import {View} from './View';
import {rectContainsPoint} from './geometry';
import {layeredLayout, noopLayout} from './layouter';
import {ColorView} from './ColorView';
import {clamp} from './utils/clamp';
type ResizeBarState = 'normal' | 'hovered' | 'dragging';
type ResizingState = $ReadOnly<{|
/** Distance between top of resize bar and mouseY */
cursorOffsetInBarFrame: number,
/** Mouse's vertical coordinates relative to canvas */
mouseY: number,
|}>;
type LayoutState = $ReadOnly<{|
/** Resize bar's vertical position relative to resize view's frame.origin.y */
barOffsetY: number,
|}>;
function getColorForBarState(state: ResizeBarState): string {
switch (state) {
case 'normal':
case 'hovered':
case 'dragging':
return COLORS.REACT_RESIZE_BAR;
}
throw new Error(`Unknown resize bar state ${state}`);
}
class ResizeBar extends View {
_intrinsicContentSize: Size = {
width: 0,
height: 5,
};
_interactionState: ResizeBarState = 'normal';
constructor(surface: Surface, frame: Rect) {
super(surface, frame, layeredLayout);
this.addSubview(new ColorView(surface, frame, ''));
this._updateColor();
}
desiredSize() {
return this._intrinsicContentSize;
}
_getColorView(): ColorView {
return (this.subviews[0]: any);
}
_updateColor() {
this._getColorView().setColor(getColorForBarState(this._interactionState));
}
_setInteractionState(state: ResizeBarState) {
if (this._interactionState === state) {
return;
}
this._interactionState = state;
this._updateColor();
}
_handleMouseDown(interaction: MouseDownInteraction, viewRefs: ViewRefs) {
const cursorInView = rectContainsPoint(
interaction.payload.location,
this.frame,
);
if (cursorInView) {
this._setInteractionState('dragging');
viewRefs.activeView = this;
}
}
_handleMouseMove(interaction: MouseMoveInteraction, viewRefs: ViewRefs) {
const cursorInView = rectContainsPoint(
interaction.payload.location,
this.frame,
);
if (cursorInView || viewRefs.activeView === this) {
this.currentCursor = 'ns-resize';
}
if (cursorInView) {
viewRefs.hoveredView = this;
}
if (this._interactionState === 'dragging') {
return;
}
this._setInteractionState(cursorInView ? 'hovered' : 'normal');
}
_handleMouseUp(interaction: MouseUpInteraction, viewRefs: ViewRefs) {
const cursorInView = rectContainsPoint(
interaction.payload.location,
this.frame,
);
if (this._interactionState === 'dragging') {
this._setInteractionState(cursorInView ? 'hovered' : 'normal');
}
if (viewRefs.activeView === this) {
viewRefs.activeView = null;
}
}
handleInteraction(interaction: Interaction, viewRefs: ViewRefs) {
switch (interaction.type) {
case 'mousedown':
this._handleMouseDown(interaction, viewRefs);
return;
case 'mousemove':
this._handleMouseMove(interaction, viewRefs);
return;
case 'mouseup':
this._handleMouseUp(interaction, viewRefs);
return;
}
}
}
export class ResizableSplitView extends View {
_canvasRef: {current: HTMLCanvasElement | null};
_resizingState: ResizingState | null = null;
_layoutState: LayoutState;
constructor(
surface: Surface,
frame: Rect,
topSubview: View,
bottomSubview: View,
canvasRef: {current: HTMLCanvasElement | null},
) {
super(surface, frame, noopLayout);
this._canvasRef = canvasRef;
this.addSubview(topSubview);
this.addSubview(new ResizeBar(surface, frame));
this.addSubview(bottomSubview);
const topSubviewDesiredSize = topSubview.desiredSize();
this._layoutState = {
barOffsetY: topSubviewDesiredSize ? topSubviewDesiredSize.height : 0,
};
}
_getTopSubview(): View {
return this.subviews[0];
}
_getResizeBar(): View {
return this.subviews[1];
}
_getBottomSubview(): View {
return this.subviews[2];
}
_getResizeBarDesiredSize(): Size {
return nullthrows(
this._getResizeBar().desiredSize(),
'Resize bar must have desired size',
);
}
desiredSize() {
const topSubviewDesiredSize = this._getTopSubview().desiredSize();
const resizeBarDesiredSize = this._getResizeBarDesiredSize();
const bottomSubviewDesiredSize = this._getBottomSubview().desiredSize();
const topSubviewDesiredWidth = topSubviewDesiredSize
? topSubviewDesiredSize.width
: 0;
const bottomSubviewDesiredWidth = bottomSubviewDesiredSize
? bottomSubviewDesiredSize.width
: 0;
const topSubviewDesiredHeight = topSubviewDesiredSize
? topSubviewDesiredSize.height
: 0;
const bottomSubviewDesiredHeight = bottomSubviewDesiredSize
? bottomSubviewDesiredSize.height
: 0;
return {
width: Math.max(
topSubviewDesiredWidth,
resizeBarDesiredSize.width,
bottomSubviewDesiredWidth,
),
height:
topSubviewDesiredHeight +
resizeBarDesiredSize.height +
bottomSubviewDesiredHeight,
};
}
layoutSubviews() {
this._updateLayoutState();
this._updateSubviewFrames();
super.layoutSubviews();
}
_updateLayoutState() {
const {frame, visibleArea, _resizingState} = this;
const resizeBarDesiredSize = this._getResizeBarDesiredSize();
// Allow bar to travel to bottom of the visible area of this view but no further
const maxPossibleBarOffset =
visibleArea.size.height - resizeBarDesiredSize.height;
const topSubviewDesiredSize = this._getTopSubview().desiredSize();
const maxBarOffset = topSubviewDesiredSize
? Math.min(maxPossibleBarOffset, topSubviewDesiredSize.height)
: maxPossibleBarOffset;
let proposedBarOffsetY = this._layoutState.barOffsetY;
// Update bar offset if dragging bar
if (_resizingState) {
const {mouseY, cursorOffsetInBarFrame} = _resizingState;
proposedBarOffsetY = mouseY - frame.origin.y - cursorOffsetInBarFrame;
}
this._layoutState = {
...this._layoutState,
barOffsetY: clamp(0, maxBarOffset, proposedBarOffsetY),
};
}
_updateSubviewFrames() {
const {
frame: {
origin: {x, y},
size: {width, height},
},
_layoutState: {barOffsetY},
} = this;
const resizeBarDesiredSize = this._getResizeBarDesiredSize();
let currentY = y;
this._getTopSubview().setFrame({
origin: {x, y: currentY},
size: {width, height: barOffsetY},
});
currentY += this._getTopSubview().frame.size.height;
this._getResizeBar().setFrame({
origin: {x, y: currentY},
size: {width, height: resizeBarDesiredSize.height},
});
currentY += this._getResizeBar().frame.size.height;
this._getBottomSubview().setFrame({
origin: {x, y: currentY},
// Fill remaining height
size: {width, height: height + y - currentY},
});
}
_handleMouseDown(interaction: MouseDownInteraction) {
const cursorLocation = interaction.payload.location;
const resizeBarFrame = this._getResizeBar().frame;
if (rectContainsPoint(cursorLocation, resizeBarFrame)) {
const mouseY = cursorLocation.y;
this._resizingState = {
cursorOffsetInBarFrame: mouseY - resizeBarFrame.origin.y,
mouseY,
};
}
}
_handleMouseMove(interaction: MouseMoveInteraction) {
const {_resizingState} = this;
if (_resizingState) {
this._resizingState = {
..._resizingState,
mouseY: interaction.payload.location.y,
};
this.setNeedsDisplay();
}
}
_handleMouseUp(interaction: MouseUpInteraction) {
if (this._resizingState) {
this._resizingState = null;
}
}
_didGrab: boolean = false;
getCursorActiveSubView(interaction: Interaction): View | null {
const cursorLocation = interaction.payload.location;
const resizeBarFrame = this._getResizeBar().frame;
if (rectContainsPoint(cursorLocation, resizeBarFrame)) {
return this;
} else {
return null;
}
}
handleInteraction(interaction: Interaction, viewRefs: ViewRefs) {
switch (interaction.type) {
case 'mousedown':
this._handleMouseDown(interaction);
return;
case 'mousemove':
this._handleMouseMove(interaction);
return;
case 'mouseup':
this._handleMouseUp(interaction);
return;
}
}
}
@@ -0,0 +1,418 @@
/**
* 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 type {
ClickInteraction,
DoubleClickInteraction,
Interaction,
MouseDownInteraction,
MouseMoveInteraction,
MouseUpInteraction,
} from './useCanvasInteraction';
import type {Rect} from './geometry';
import type {ViewRefs} from './Surface';
import {BORDER_SIZE, COLORS} from '../content-views/constants';
import {drawText} from '../content-views/utils/text';
import {Surface} from './Surface';
import {View} from './View';
import {intersectionOfRects, rectContainsPoint} from './geometry';
import {noopLayout} from './layouter';
import {clamp} from './utils/clamp';
type ResizeBarState = 'normal' | 'hovered' | 'dragging';
type ResizingState = $ReadOnly<{|
/** Distance between top of resize bar and mouseY */
cursorOffsetInBarFrame: number,
/** Mouse's vertical coordinates relative to canvas */
mouseY: number,
|}>;
type LayoutState = $ReadOnly<{|
/** Resize bar's vertical position relative to resize view's frame.origin.y */
barOffsetY: number,
|}>;
const RESIZE_BAR_DOT_RADIUS = 1;
const RESIZE_BAR_DOT_SPACING = 4;
const RESIZE_BAR_HEIGHT = 8;
const RESIZE_BAR_WITH_LABEL_HEIGHT = 16;
const HIDDEN_RECT = {
origin: {x: 0, y: 0},
size: {width: 0, height: 0},
};
class ResizeBar extends View {
_interactionState: ResizeBarState = 'normal';
_label: string;
showLabel: boolean = false;
constructor(surface: Surface, frame: Rect, label: string) {
super(surface, frame, noopLayout);
this._label = label;
}
desiredSize() {
return this.showLabel
? {height: RESIZE_BAR_WITH_LABEL_HEIGHT, width: 0}
: {height: RESIZE_BAR_HEIGHT, width: 0};
}
draw(context: CanvasRenderingContext2D, viewRefs: ViewRefs) {
const {visibleArea} = this;
const {x, y} = visibleArea.origin;
const {width, height} = visibleArea.size;
const isActive =
this._interactionState === 'dragging' ||
(this._interactionState === 'hovered' && viewRefs.activeView === null);
context.fillStyle = isActive
? COLORS.REACT_RESIZE_BAR_ACTIVE
: COLORS.REACT_RESIZE_BAR;
context.fillRect(x, y, width, height);
context.fillStyle = COLORS.REACT_RESIZE_BAR_BORDER;
context.fillRect(x, y, width, BORDER_SIZE);
context.fillRect(x, y + height - BORDER_SIZE, width, BORDER_SIZE);
const horizontalCenter = x + width / 2;
const verticalCenter = y + height / 2;
if (this.showLabel) {
// When the resize view is collapsed entirely,
// rather than showing a resize bar– this view displays a label.
const labelRect: Rect = {
origin: {
x: 0,
y: y + height - RESIZE_BAR_WITH_LABEL_HEIGHT,
},
size: {
width: visibleArea.size.width,
height: visibleArea.size.height,
},
};
const drawableRect = intersectionOfRects(labelRect, this.visibleArea);
drawText(
this._label,
context,
labelRect,
drawableRect,
visibleArea.size.width,
'center',
COLORS.REACT_RESIZE_BAR_DOT,
);
} else {
// Otherwise draw horizontally centered resize bar dots
context.beginPath();
context.fillStyle = COLORS.REACT_RESIZE_BAR_DOT;
context.arc(
horizontalCenter,
verticalCenter,
RESIZE_BAR_DOT_RADIUS,
0,
2 * Math.PI,
);
context.arc(
horizontalCenter + RESIZE_BAR_DOT_SPACING,
verticalCenter,
RESIZE_BAR_DOT_RADIUS,
0,
2 * Math.PI,
);
context.arc(
horizontalCenter - RESIZE_BAR_DOT_SPACING,
verticalCenter,
RESIZE_BAR_DOT_RADIUS,
0,
2 * Math.PI,
);
context.fill();
}
}
_setInteractionState(state: ResizeBarState) {
if (this._interactionState === state) {
return;
}
this._interactionState = state;
this.setNeedsDisplay();
}
_handleMouseDown(interaction: MouseDownInteraction, viewRefs: ViewRefs) {
const cursorInView = rectContainsPoint(
interaction.payload.location,
this.frame,
);
if (cursorInView) {
this._setInteractionState('dragging');
viewRefs.activeView = this;
}
}
_handleMouseMove(interaction: MouseMoveInteraction, viewRefs: ViewRefs) {
const cursorInView = rectContainsPoint(
interaction.payload.location,
this.frame,
);
if (viewRefs.activeView === this) {
// If we're actively dragging this resize bar,
// show the cursor even if the pointer isn't hovering over this view.
this.currentCursor = 'ns-resize';
} else if (cursorInView) {
if (this.showLabel) {
this.currentCursor = 'pointer';
} else {
this.currentCursor = 'ns-resize';
}
}
if (cursorInView) {
viewRefs.hoveredView = this;
}
if (this._interactionState === 'dragging') {
return;
}
this._setInteractionState(cursorInView ? 'hovered' : 'normal');
}
_handleMouseUp(interaction: MouseUpInteraction, viewRefs: ViewRefs) {
const cursorInView = rectContainsPoint(
interaction.payload.location,
this.frame,
);
if (this._interactionState === 'dragging') {
this._setInteractionState(cursorInView ? 'hovered' : 'normal');
}
if (viewRefs.activeView === this) {
viewRefs.activeView = null;
}
}
handleInteraction(interaction: Interaction, viewRefs: ViewRefs) {
switch (interaction.type) {
case 'mousedown':
this._handleMouseDown(interaction, viewRefs);
return;
case 'mousemove':
this._handleMouseMove(interaction, viewRefs);
return;
case 'mouseup':
this._handleMouseUp(interaction, viewRefs);
return;
}
}
}
export class ResizableView extends View {
_canvasRef: {current: HTMLCanvasElement | null};
_layoutState: LayoutState;
_resizeBar: ResizeBar;
_resizingState: ResizingState | null = null;
_subview: View;
constructor(
surface: Surface,
frame: Rect,
subview: View,
canvasRef: {current: HTMLCanvasElement | null},
label: string,
) {
super(surface, frame, noopLayout);
this._canvasRef = canvasRef;
this._subview = subview;
this._resizeBar = new ResizeBar(surface, frame, label);
this.addSubview(this._subview);
this.addSubview(this._resizeBar);
const subviewDesiredSize = subview.desiredSize();
this._updateLayoutStateAndResizeBar(
subviewDesiredSize.maxInitialHeight != null
? Math.min(
subviewDesiredSize.maxInitialHeight,
subviewDesiredSize.height,
)
: subviewDesiredSize.height,
);
}
desiredSize() {
const resizeBarDesiredSize = this._resizeBar.desiredSize();
return {
width: this.frame.size.width,
height: this._layoutState.barOffsetY + resizeBarDesiredSize.height,
};
}
layoutSubviews() {
this._updateLayoutState();
this._updateSubviewFrames();
super.layoutSubviews();
}
_updateLayoutStateAndResizeBar(barOffsetY: number) {
if (barOffsetY <= RESIZE_BAR_WITH_LABEL_HEIGHT - RESIZE_BAR_HEIGHT) {
barOffsetY = 0;
}
this._layoutState = {
...this._layoutState,
barOffsetY,
};
this._resizeBar.showLabel = barOffsetY === 0;
}
_updateLayoutState() {
const {frame, _resizingState} = this;
// Allow bar to travel to bottom of the visible area of this view but no further
const subviewDesiredSize = this._subview.desiredSize();
const maxBarOffset = subviewDesiredSize.height;
let proposedBarOffsetY = this._layoutState.barOffsetY;
// Update bar offset if dragging bar
if (_resizingState) {
const {mouseY, cursorOffsetInBarFrame} = _resizingState;
proposedBarOffsetY = mouseY - frame.origin.y - cursorOffsetInBarFrame;
}
this._updateLayoutStateAndResizeBar(
clamp(0, maxBarOffset, proposedBarOffsetY),
);
}
_updateSubviewFrames() {
const {
frame: {
origin: {x, y},
size: {width},
},
_layoutState: {barOffsetY},
} = this;
const resizeBarDesiredSize = this._resizeBar.desiredSize();
if (barOffsetY === 0) {
this._subview.setFrame(HIDDEN_RECT);
} else {
this._subview.setFrame({
origin: {x, y},
size: {width, height: barOffsetY},
});
}
this._resizeBar.setFrame({
origin: {x, y: y + barOffsetY},
size: {width, height: resizeBarDesiredSize.height},
});
}
_handleClick(interaction: ClickInteraction) {
const cursorInView = rectContainsPoint(
interaction.payload.location,
this.frame,
);
if (cursorInView) {
if (this._layoutState.barOffsetY === 0) {
// Clicking on the collapsed label should expand.
const subviewDesiredSize = this._subview.desiredSize();
this._updateLayoutStateAndResizeBar(subviewDesiredSize.height);
this.setNeedsDisplay();
}
}
}
_handleDoubleClick(interaction: DoubleClickInteraction) {
const cursorInView = rectContainsPoint(
interaction.payload.location,
this.frame,
);
if (cursorInView) {
if (this._layoutState.barOffsetY > 0) {
// Double clicking on the expanded view should collapse.
this._updateLayoutStateAndResizeBar(0);
this.setNeedsDisplay();
}
}
}
_handleMouseDown(interaction: MouseDownInteraction) {
const cursorLocation = interaction.payload.location;
const resizeBarFrame = this._resizeBar.frame;
if (rectContainsPoint(cursorLocation, resizeBarFrame)) {
const mouseY = cursorLocation.y;
this._resizingState = {
cursorOffsetInBarFrame: mouseY - resizeBarFrame.origin.y,
mouseY,
};
}
}
_handleMouseMove(interaction: MouseMoveInteraction) {
const {_resizingState} = this;
if (_resizingState) {
this._resizingState = {
..._resizingState,
mouseY: interaction.payload.location.y,
};
this.setNeedsDisplay();
}
}
_handleMouseUp(interaction: MouseUpInteraction) {
if (this._resizingState) {
this._resizingState = null;
}
}
getCursorActiveSubView(interaction: Interaction): View | null {
const cursorLocation = interaction.payload.location;
const resizeBarFrame = this._resizeBar.frame;
if (rectContainsPoint(cursorLocation, resizeBarFrame)) {
return this;
} else {
return null;
}
}
handleInteraction(interaction: Interaction, viewRefs: ViewRefs) {
switch (interaction.type) {
case 'click':
this._handleClick(interaction);
return;
case 'double-click':
this._handleDoubleClick(interaction);
return;
case 'mousedown':
this._handleMouseDown(interaction);
return;
case 'mousemove':
this._handleMouseMove(interaction);
return;
case 'mouseup':
this._handleMouseUp(interaction);
return;
}
}
}
@@ -92,7 +92,7 @@ export class Surface {
origin: zeroPoint,
size: _canvasSize,
});
rootView.displayIfNeeded(_context);
rootView.displayIfNeeded(_context, this._viewRefs);
}
getCurrentCursor(): string | null {
@@ -0,0 +1,3 @@
// TODO Vertically stack views (via verticallyStackedLayout).
// If stacked views are taller than the available height, a vertical scrollbar will be shown on the side,
// and width will be adjusted to subtract the width of the scrollbar.
@@ -12,10 +12,11 @@ import type {
MouseDownInteraction,
MouseMoveInteraction,
MouseUpInteraction,
WheelPlainInteraction,
WheelWithShiftInteraction,
} from './useCanvasInteraction';
import type {Rect} from './geometry';
import type {ScrollState} from './utils/scrollState';
import type {ViewRefs} from './Surface';
import {Surface} from './Surface';
import {View} from './View';
@@ -26,6 +27,11 @@ import {
translateState,
} from './utils/scrollState';
import {MOVE_WHEEL_DELTA_THRESHOLD} from './constants';
import {COLORS} from '../content-views/constants';
const CARET_MARGIN = 3;
const CARET_WIDTH = 5;
const CARET_HEIGHT = 3;
export class VerticalScrollView extends View {
_scrollState: ScrollState = {offset: 0, length: 0};
@@ -48,6 +54,54 @@ export class VerticalScrollView extends View {
return this._contentView.desiredSize();
}
draw(context: CanvasRenderingContext2D, viewRefs: ViewRefs) {
super.draw(context, viewRefs);
// Show carets if there's scroll overflow above or below the viewable area.
if (this.frame.size.height > CARET_HEIGHT * 2 + CARET_MARGIN * 3) {
const offset = this._scrollState.offset;
const desiredSize = this._contentView.desiredSize();
const above = offset;
const below = this.frame.size.height - desiredSize.height - offset;
if (above < 0 || below < 0) {
const {visibleArea} = this;
const {x, y} = visibleArea.origin;
const {width, height} = visibleArea.size;
const horizontalCenter = x + width / 2;
const halfWidth = CARET_WIDTH;
const left = horizontalCenter + halfWidth;
const right = horizontalCenter - halfWidth;
if (above < 0) {
const topY = y + CARET_MARGIN;
context.beginPath();
context.moveTo(horizontalCenter, topY);
context.lineTo(left, topY + CARET_HEIGHT);
context.lineTo(right, topY + CARET_HEIGHT);
context.closePath();
context.fillStyle = COLORS.SCROLL_CARET;
context.fill();
}
if (below < 0) {
const bottomY = y + height - CARET_MARGIN;
context.beginPath();
context.moveTo(horizontalCenter, bottomY);
context.lineTo(left, bottomY - CARET_HEIGHT);
context.lineTo(right, bottomY - CARET_HEIGHT);
context.closePath();
context.fillStyle = COLORS.SCROLL_CARET;
context.fill();
}
}
}
}
/**
* Reference to the content view. This view is also the only view in
* `this.subviews`.
@@ -103,7 +157,7 @@ export class VerticalScrollView extends View {
}
}
_handleWheelPlain(interaction: WheelPlainInteraction) {
_handleWheelShift(interaction: WheelWithShiftInteraction) {
const {
location,
delta: {deltaX, deltaY},
@@ -141,8 +195,8 @@ export class VerticalScrollView extends View {
case 'mouseup':
this._handleMouseUp(interaction);
break;
case 'wheel-plain':
this._handleWheelPlain(interaction);
case 'wheel-shift':
this._handleWheelShift(interaction);
break;
}
}
@@ -8,7 +8,7 @@
*/
import type {Interaction} from './useCanvasInteraction';
import type {Rect, Size} from './geometry';
import type {Rect, Size, SizeWithMaxHeight} from './geometry';
import type {Layouter} from './layouter';
import type {ViewRefs} from './Surface';
@@ -140,7 +140,7 @@ export class View {
*
* Can be overridden by subclasses.
*/
desiredSize(): ?Size {
desiredSize(): Size | SizeWithMaxHeight {
if (this._needsDisplay) {
this.layoutSubviews();
}
@@ -186,7 +186,7 @@ export class View {
* 1. Lays out subviews with `layoutSubviews`.
* 2. Draws content with `draw`.
*/
displayIfNeeded(context: CanvasRenderingContext2D) {
displayIfNeeded(context: CanvasRenderingContext2D, viewRefs: ViewRefs) {
if (
(this._needsDisplay || this._subviewsNeedDisplay) &&
rectIntersectsRect(this.frame, this.visibleArea) &&
@@ -195,7 +195,7 @@ export class View {
this.layoutSubviews();
if (this._needsDisplay) this._needsDisplay = false;
if (this._subviewsNeedDisplay) this._subviewsNeedDisplay = false;
this.draw(context);
this.draw(context, viewRefs);
}
}
@@ -239,11 +239,11 @@ export class View {
*
* @see displayIfNeeded
*/
draw(context: CanvasRenderingContext2D) {
draw(context: CanvasRenderingContext2D, viewRefs: ViewRefs) {
const {subviews, visibleArea} = this;
subviews.forEach(subview => {
if (rectIntersectsRect(visibleArea, subview.visibleArea)) {
subview.displayIfNeeded(context);
subview.displayIfNeeded(context, viewRefs);
}
});
}
@@ -252,10 +252,9 @@ export class View {
* Handle an `interaction`.
*
* To be overwritten by subclasses that wish to handle interactions.
*
* NOTE: Do not call directly! Use `handleInteractionAndPropagateToSubviews`
*/
// Internal note: Do not call directly! Use
// `handleInteractionAndPropagateToSubviews` so that interactions are
// propagated to subviews.
handleInteraction(interaction: Interaction, viewRefs: ViewRefs) {}
/**
@@ -272,9 +271,18 @@ export class View {
interaction: Interaction,
viewRefs: ViewRefs,
) {
const {subviews, visibleArea} = this;
if (visibleArea.size.height === 0) {
return;
}
this.handleInteraction(interaction, viewRefs);
this.subviews.forEach(subview =>
subview.handleInteractionAndPropagateToSubviews(interaction, viewRefs),
);
subviews.forEach(subview => {
if (rectIntersectsRect(visibleArea, subview.visibleArea)) {
subview.handleInteractionAndPropagateToSubviews(interaction, viewRefs);
}
});
}
}
@@ -9,6 +9,10 @@
export type Point = $ReadOnly<{|x: number, y: number|}>;
export type Size = $ReadOnly<{|width: number, height: number|}>;
export type SizeWithMaxHeight = {|
...Size,
maxInitialHeight?: number,
|};
export type Rect = $ReadOnly<{|origin: Point, size: Size|}>;
/**
@@ -70,6 +74,15 @@ function boxToRect(box: Box): Rect {
}
export function rectIntersectsRect(rect1: Rect, rect2: Rect): boolean {
if (
rect1.size.width === 0 ||
rect1.size.height === 0 ||
rect2.size.width === 0 ||
rect2.size.height === 0
) {
return false;
}
const [top1, right1, bottom1, left1] = rectToBox(rect1);
const [top2, right2, bottom2, left2] = rectToBox(rect2);
return !(
@@ -7,9 +7,9 @@
* @flow
*/
export * from './ColorView';
export * from './BackgroundColorView';
export * from './HorizontalPanAndZoomView';
export * from './ResizableSplitView';
export * from './ResizableView';
export * from './Surface';
export * from './VerticalScrollView';
export * from './View';
@@ -43,8 +43,7 @@ export function collapseLayoutIntoViews(layout: Layout) {
export const noopLayout: Layouter = layout => layout;
/**
* Layer views on top of each other. All views' frames will be set to
* `containerFrame`.
* Layer views on top of each other. All views' frames will be set to `containerFrame`.
*
* Equivalent to composing:
* - `alignToContainerXLayout`,
@@ -52,12 +51,13 @@ export const noopLayout: Layouter = layout => layout;
* - `containerWidthLayout`, and
* - `containerHeightLayout`.
*/
export const layeredLayout: Layouter = (layout, containerFrame) =>
layout.map(layoutInfo => ({...layoutInfo, frame: containerFrame}));
export const layeredLayout: Layouter = (layout, containerFrame) => {
return layout.map(layoutInfo => ({...layoutInfo, frame: containerFrame}));
};
/**
* Stacks `views` vertically in `frame`. All views in `views` will have their
* widths set to the frame's width.
* Stacks `views` vertically in `frame`.
* All views in `views` will have their widths set to the frame's width.
*/
export const verticallyStackedLayout: Layouter = (layout, containerFrame) => {
let currentY = containerFrame.origin.y;
@@ -10,9 +10,23 @@
import type {NormalizedWheelDelta} from './utils/normalizeWheel';
import type {Point} from './geometry';
import {useEffect} from 'react';
import {useEffect, useRef} from 'react';
import {normalizeWheel} from './utils/normalizeWheel';
export type ClickInteraction = {|
type: 'click',
payload: {|
event: MouseEvent,
location: Point,
|},
|};
export type DoubleClickInteraction = {|
type: 'double-click',
payload: {|
event: MouseEvent,
location: Point,
|},
|};
export type MouseDownInteraction = {|
type: 'mousedown',
payload: {|
@@ -68,6 +82,8 @@ export type WheelWithMetaInteraction = {|
|};
export type Interaction =
| ClickInteraction
| DoubleClickInteraction
| MouseDownInteraction
| MouseMoveInteraction
| MouseUpInteraction
@@ -99,6 +115,9 @@ export function useCanvasInteraction(
canvasRef: {|current: HTMLCanvasElement | null|},
interactor: (interaction: Interaction) => void,
) {
const isMouseDownRef = useRef<boolean>(false);
const didMouseMoveWhileDownRef = useRef<boolean>(false);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) {
@@ -113,7 +132,38 @@ export function useCanvasInteraction(
};
}
const onCanvasClick: MouseEventHandler = event => {
if (didMouseMoveWhileDownRef.current) {
return;
}
interactor({
type: 'click',
payload: {
event,
location: localToCanvasCoordinates({x: event.x, y: event.y}),
},
});
};
const onCanvasDoubleClick: MouseEventHandler = event => {
if (didMouseMoveWhileDownRef.current) {
return;
}
interactor({
type: 'double-click',
payload: {
event,
location: localToCanvasCoordinates({x: event.x, y: event.y}),
},
});
};
const onCanvasMouseDown: MouseEventHandler = event => {
didMouseMoveWhileDownRef.current = false;
isMouseDownRef.current = true;
interactor({
type: 'mousedown',
payload: {
@@ -124,6 +174,10 @@ export function useCanvasInteraction(
};
const onDocumentMouseMove: MouseEventHandler = event => {
if (isMouseDownRef.current) {
didMouseMoveWhileDownRef.current = true;
}
interactor({
type: 'mousemove',
payload: {
@@ -134,6 +188,8 @@ export function useCanvasInteraction(
};
const onDocumentMouseUp: MouseEventHandler = event => {
isMouseDownRef.current = false;
interactor({
type: 'mouseup',
payload: {
@@ -179,6 +235,8 @@ export function useCanvasInteraction(
ownerDocument.addEventListener('mousemove', onDocumentMouseMove);
ownerDocument.addEventListener('mouseup', onDocumentMouseUp);
canvas.addEventListener('click', onCanvasClick);
canvas.addEventListener('dblclick', onCanvasDoubleClick);
canvas.addEventListener('mousedown', onCanvasMouseDown);
canvas.addEventListener('wheel', onCanvasWheel);
@@ -186,6 +244,8 @@ export function useCanvasInteraction(
ownerDocument.removeEventListener('mousemove', onDocumentMouseMove);
ownerDocument.removeEventListener('mouseup', onDocumentMouseUp);
canvas.removeEventListener('click', onCanvasClick);
canvas.removeEventListener('dblclick', onCanvasDoubleClick);
canvas.removeEventListener('mousedown', onCanvasMouseDown);
canvas.removeEventListener('wheel', onCanvasWheel);
};
@@ -45,20 +45,21 @@ function Profiler(_: {||}) {
const {supportsSchedulingProfiler} = useContext(StoreContext);
let showRightColumn = true;
let isLegacyProfilerSelected = false;
let view = null;
if (didRecordCommits || selectedTabID === 'scheduling-profiler') {
switch (selectedTabID) {
case 'flame-chart':
isLegacyProfilerSelected = true;
view = <CommitFlamegraph />;
break;
case 'ranked-chart':
isLegacyProfilerSelected = true;
view = <CommitRanked />;
break;
case 'scheduling-profiler':
view = <SchedulingProfiler />;
showRightColumn = false;
break;
default:
break;
@@ -119,7 +120,7 @@ function Profiler(_: {||}) {
<RootSelector />
<div className={styles.Spacer} />
<SettingsModalContextToggle />
{didRecordCommits && (
{isLegacyProfilerSelected && didRecordCommits && (
<Fragment>
<div className={styles.VRule} />
<SnapshotSelector />
@@ -131,7 +132,9 @@ function Profiler(_: {||}) {
<ModalDialog />
</div>
</div>
{showRightColumn && <div className={styles.RightColumn}>{sidebar}</div>}
{isLegacyProfilerSelected && (
<div className={styles.RightColumn}>{sidebar}</div>
)}
<SettingsModal />
</div>
</SettingsModalContextController>
@@ -212,7 +212,10 @@ function ProfilerContextController({children}: Props) {
const [selectedCommitIndex, selectCommitIndex] = useState<number | null>(
null,
);
const [selectedTabID, selectTab] = useState<TabID>('flame-chart');
const [selectedTabID, selectTab] = useLocalStorage<TabID>(
'React::DevTools::Profiler::defaultTab',
'flame-chart',
);
if (isProfiling) {
batchedUpdates(() => {
@@ -412,6 +412,9 @@ export function updateThemeVariables(
updateStyleHelper(theme, 'color-record-hover', documentElements);
updateStyleHelper(theme, 'color-record-inactive', documentElements);
updateStyleHelper(theme, 'color-resize-bar', documentElements);
updateStyleHelper(theme, 'color-resize-bar-active', documentElements);
updateStyleHelper(theme, 'color-resize-bar-border', documentElements);
updateStyleHelper(theme, 'color-resize-bar-dot', documentElements);
updateStyleHelper(theme, 'color-color-scroll-thumb', documentElements);
updateStyleHelper(theme, 'color-color-scroll-track', documentElements);
updateStyleHelper(theme, 'color-search-match', documentElements);
@@ -431,21 +434,6 @@ export function updateThemeVariables(
'color-scheduling-profiler-native-event-hover',
documentElements,
);
updateStyleHelper(
theme,
'color-scheduling-profiler-native-event-warning',
documentElements,
);
updateStyleHelper(
theme,
'color-scheduling-profiler-native-event-warning-hover',
documentElements,
);
updateStyleHelper(
theme,
'color-scheduling-profiler-native-event-warning-text',
documentElements,
);
updateStyleHelper(
theme,
'color-selected-tree-highlight-active',
@@ -481,11 +469,6 @@ export function updateThemeVariables(
'color-scheduling-profiler-react-idle',
documentElements,
);
updateStyleHelper(
theme,
'color-scheduling-profiler-react-idle-selected',
documentElements,
);
updateStyleHelper(
theme,
'color-scheduling-profiler-react-idle-hover',
@@ -496,11 +479,6 @@ export function updateThemeVariables(
'color-scheduling-profiler-react-render',
documentElements,
);
updateStyleHelper(
theme,
'color-scheduling-profiler-react-render-selected',
documentElements,
);
updateStyleHelper(
theme,
'color-scheduling-profiler-react-render-hover',
@@ -511,11 +489,6 @@ export function updateThemeVariables(
'color-scheduling-profiler-react-commit',
documentElements,
);
updateStyleHelper(
theme,
'color-scheduling-profiler-react-commit-selected',
documentElements,
);
updateStyleHelper(
theme,
'color-scheduling-profiler-react-commit-hover',
@@ -526,11 +499,6 @@ export function updateThemeVariables(
'color-scheduling-profiler-react-layout-effects',
documentElements,
);
updateStyleHelper(
theme,
'color-scheduling-profiler-react-layout-effects-selected',
documentElements,
);
updateStyleHelper(
theme,
'color-scheduling-profiler-react-layout-effects-hover',
@@ -541,11 +509,6 @@ export function updateThemeVariables(
'color-scheduling-profiler-react-passive-effects',
documentElements,
);
updateStyleHelper(
theme,
'color-scheduling-profiler-react-passive-effects-selected',
documentElements,
);
updateStyleHelper(
theme,
'color-scheduling-profiler-react-passive-effects-hover',
@@ -563,22 +526,32 @@ export function updateThemeVariables(
);
updateStyleHelper(
theme,
'color-scheduling-profiler-react-schedule-cascading',
'color-scheduling-profiler-react-suspense-rejected-event',
documentElements,
);
updateStyleHelper(
theme,
'color-scheduling-profiler-react-schedule-cascading-hover',
'color-scheduling-profiler-react-suspense-rejected-hover',
documentElements,
);
updateStyleHelper(
theme,
'color-scheduling-profiler-react-suspend',
'color-scheduling-profiler-react-suspense-resolved',
documentElements,
);
updateStyleHelper(
theme,
'color-scheduling-profiler-react-suspend-hover',
'color-scheduling-profiler-react-suspense-resolved-hover',
documentElements,
);
updateStyleHelper(
theme,
'color-scheduling-profiler-react-suspense-unresolved',
documentElements,
);
updateStyleHelper(
theme,
'color-scheduling-profiler-react-suspense-unresolved-hover',
documentElements,
);
updateStyleHelper(
@@ -586,6 +559,7 @@ export function updateThemeVariables(
'color-scheduling-profiler-react-work-border',
documentElements,
);
updateStyleHelper(theme, 'color-scroll-caret', documentElements);
updateStyleHelper(theme, 'color-shadow', documentElements);
updateStyleHelper(theme, 'color-tab-selected-border', documentElements);
updateStyleHelper(theme, 'color-text', documentElements);
@@ -597,6 +571,14 @@ export function updateThemeVariables(
updateStyleHelper(theme, 'color-toggle-text', documentElements);
updateStyleHelper(theme, 'color-tooltip-background', documentElements);
updateStyleHelper(theme, 'color-tooltip-text', documentElements);
updateStyleHelper(theme, 'color-warning-background', documentElements);
updateStyleHelper(theme, 'color-warning-background-hover', documentElements);
updateStyleHelper(theme, 'color-warning-text-color', documentElements);
updateStyleHelper(
theme,
'color-warning-text-color-inverted',
documentElements,
);
// Font smoothing varies based on the theme.
updateStyleHelper(theme, 'font-smoothing', documentElements);
@@ -91,7 +91,7 @@ export default function TabBar({
<Fragment>
{tabs.map(tab => {
if (tab === null) {
return <div className={styles.VRule} />;
return <div key="VRule" className={styles.VRule} />;
}
const {icon, id, label, title} = tab;
@@ -77,46 +77,43 @@
--light-color-record-active: #fc3a4b;
--light-color-record-hover: #3578e5;
--light-color-record-inactive: #0088fa;
--light-color-resize-bar: #cccccc;
--light-color-resize-bar: #eeeeee;
--light-color-resize-bar-active: #dcdcdc;
--light-color-resize-bar-border: #d1d1d1;
--light-color-resize-bar-dot: #333333;
--light-color-scheduling-profiler-native-event: #ccc;
--light-color-scheduling-profiler-native-event-hover: #aaa;
--light-color-scheduling-profiler-native-event-warning: #ee1638;
--light-color-scheduling-profiler-native-event-warning-hover: #da1030;
--light-color-scheduling-profiler-native-event-warning-text: #fff;
--light-color-scheduling-profiler-priority-background: #f6f6f6;
--light-color-scheduling-profiler-priority-border: #eeeeee;
--light-color-scheduling-profiler-user-timing: #c9cacd;
--light-color-scheduling-profiler-user-timing-hover:#93959a;
--light-color-scheduling-profiler-react-idle: #edf6ff;
--light-color-scheduling-profiler-react-idle-selected:#EDF6FF;
--light-color-scheduling-profiler-react-idle-hover:#EDF6FF;
--light-color-scheduling-profiler-user-timing-hover: #93959a;
--light-color-scheduling-profiler-react-idle: #d3e5f6;
--light-color-scheduling-profiler-react-idle-hover: #c3d9ef;
--light-color-scheduling-profiler-react-render: #9fc3f3;
--light-color-scheduling-profiler-react-render-selected:#64A9F5;
--light-color-scheduling-profiler-react-render-hover:#2683E2;
--light-color-scheduling-profiler-react-commit: #ff718e;
--light-color-scheduling-profiler-react-commit-selected:#FF5277;
--light-color-scheduling-profiler-react-commit-hover:#ed0030;
--light-color-scheduling-profiler-react-layout-effects:#c88ff0;
--light-color-scheduling-profiler-react-layout-effects-selected:#934FC1;
--light-color-scheduling-profiler-react-layout-effects-hover:#601593;
--light-color-scheduling-profiler-react-passive-effects:#c88ff0;
--light-color-scheduling-profiler-react-passive-effects-selected:#934FC1;
--light-color-scheduling-profiler-react-passive-effects-hover:#601593;
--light-color-scheduling-profiler-react-render-hover: #83afe9;
--light-color-scheduling-profiler-react-commit: #c88ff0;
--light-color-scheduling-profiler-react-commit-hover: #b281d6;
--light-color-scheduling-profiler-react-layout-effects: #b281d6;
--light-color-scheduling-profiler-react-layout-effects-hover: #9d71bd;
--light-color-scheduling-profiler-react-passive-effects: #b281d6;
--light-color-scheduling-profiler-react-passive-effects-hover: #9d71bd;
--light-color-scheduling-profiler-react-schedule: #9fc3f3;
--light-color-scheduling-profiler-react-schedule-hover:#2683E2;
--light-color-scheduling-profiler-react-schedule-cascading:#ff718e;
--light-color-scheduling-profiler-react-schedule-cascading-hover:#ed0030;
--light-color-scheduling-profiler-react-suspend: #a6e59f;
--light-color-scheduling-profiler-react-suspend-hover:#13bc00;
--light-color-scheduling-profiler-react-schedule-hover: #2683E2;
--light-color-scheduling-profiler-react-suspense-rejected: #f1cc14;
--light-color-scheduling-profiler-react-suspense-rejected-hover: #ffdf37;
--light-color-scheduling-profiler-react-suspense-resolved: #a6e59f;
--light-color-scheduling-profiler-react-suspense-resolved-hover: #89d281;
--light-color-scheduling-profiler-react-suspense-unresolved: #c9cacd;
--light-color-scheduling-profiler-react-suspense-unresolved-hover: #93959a;
--light-color-scheduling-profiler-text-color: #000000;
--light-color-scheduling-profiler-react-work-border:#ffffff;
--light-color-scheduling-profiler-react-work-border: #ffffff;
--light-color-scroll-thumb: #c2c2c2;
--light-color-scroll-track: #fafafa;
--light-color-search-match: yellow;
--light-color-search-match-current: #f7923b;
--light-color-selected-tree-highlight-active: rgba(0, 136, 250, 0.1);
--light-color-selected-tree-highlight-inactive: rgba(0, 0, 0, 0.05);
--light-color-shadow: rgba(0, 0, 0, 0.25);
--light-color-scroll-caret: rgba(150, 150, 150, 0.5);
--light-color-tab-selected-border: #0088fa;
--light-color-text: #000000;
--light-color-text-invalid: #ff0000;
@@ -127,6 +124,10 @@
--light-color-toggle-text: #ffffff;
--light-color-tooltip-background: rgba(0, 0, 0, 0.9);
--light-color-tooltip-text: #ffffff;
--light-color-warning-background: #fb3655;
--light-color-warning-background-hover: #f82042;
--light-color-warning-text-color: #ffffff;
--light-color-warning-text-color-inverted: #fd4d69;
/* Dark theme */
--dark-color-attribute-name: #9d87d2;
@@ -202,45 +203,43 @@
--dark-color-record-active: #fc3a4b;
--dark-color-record-hover: #a2e9fc;
--dark-color-record-inactive: #61dafb;
--dark-color-resize-bar: #3d424a;
--dark-color-resize-bar: #282c34;
--dark-color-resize-bar-active: #31363f;
--dark-color-resize-bar-border: #3d424a;
--dark-color-resize-bar-dot: #cfd1d5;
--dark-color-scheduling-profiler-native-event: #b2b2b2;
--dark-color-scheduling-profiler-native-event-hover: #949494;
--dark-color-scheduling-profiler-native-event-warning: #ee1638;
--dark-color-scheduling-profiler-native-event-warning-hover: #da1030;
--dark-color-scheduling-profiler-native-event-warning-text: #fff;
--dark-color-scheduling-profiler-priority-background: #1d2129;
--dark-color-scheduling-profiler-priority-border: #282c34;
--dark-color-scheduling-profiler-user-timing: #c9cacd;
--dark-color-scheduling-profiler-user-timing-hover:#93959a;
--dark-color-scheduling-profiler-user-timing-hover: #93959a;
--dark-color-scheduling-profiler-react-idle: #3d485b;
--dark-color-scheduling-profiler-react-idle-selected:#465269;
--dark-color-scheduling-profiler-react-idle-hover:#465269;
--dark-color-scheduling-profiler-react-render: #9fc3f3;
--dark-color-scheduling-profiler-react-render-selected:#64A9F5;
--dark-color-scheduling-profiler-react-render-hover:#2683E2;
--dark-color-scheduling-profiler-react-commit: #ff718e;
--dark-color-scheduling-profiler-react-commit-selected:#FF5277;
--dark-color-scheduling-profiler-react-commit-hover:#ed0030;
--dark-color-scheduling-profiler-react-layout-effects:#c88ff0;
--dark-color-scheduling-profiler-react-layout-effects-selected:#934FC1;
--dark-color-scheduling-profiler-react-layout-effects-hover:#601593;
--dark-color-scheduling-profiler-react-passive-effects:#c88ff0;
--dark-color-scheduling-profiler-react-passive-effects-selected:#934FC1;
--dark-color-scheduling-profiler-react-passive-effects-hover:#601593;
--dark-color-scheduling-profiler-react-schedule: #9fc3f3;
--dark-color-scheduling-profiler-react-schedule-hover:#2683E2;
--dark-color-scheduling-profiler-react-schedule-cascading:#ff718e;
--dark-color-scheduling-profiler-react-schedule-cascading-hover:#ed0030;
--dark-color-scheduling-profiler-react-suspend: #a6e59f;
--dark-color-scheduling-profiler-react-suspend-hover:#13bc00;
--dark-color-scheduling-profiler-react-idle-hover: #465269;
--dark-color-scheduling-profiler-react-render: #2683E2;
--dark-color-scheduling-profiler-react-render-hover: #1a76d4;
--dark-color-scheduling-profiler-react-commit: #731fad;
--dark-color-scheduling-profiler-react-commit-hover: #611b94;
--dark-color-scheduling-profiler-react-layout-effects: #611b94;
--dark-color-scheduling-profiler-react-layout-effects-hover: #51167a;
--dark-color-scheduling-profiler-react-passive-effects: #611b94;
--dark-color-scheduling-profiler-react-passive-effects-hover: #51167a;
--dark-color-scheduling-profiler-react-schedule: #2683E2;
--dark-color-scheduling-profiler-react-schedule-hover: #1a76d4;
--dark-color-scheduling-profiler-react-suspense-rejected: #f1cc14;
--dark-color-scheduling-profiler-react-suspense-rejected-hover: #e4c00f;
--dark-color-scheduling-profiler-react-suspense-resolved: #a6e59f;
--dark-color-scheduling-profiler-react-suspense-resolved-hover: #89d281;
--dark-color-scheduling-profiler-react-suspense-unresolved: #c9cacd;
--dark-color-scheduling-profiler-react-suspense-unresolved-hover: #93959a;
--dark-color-scheduling-profiler-text-color: #000000;
--dark-color-scheduling-profiler-react-work-border:#ffffff;
--dark-color-scheduling-profiler-react-work-border: #ffffff;
--dark-color-scroll-thumb: #afb3b9;
--dark-color-scroll-track: #313640;
--dark-color-search-match: yellow;
--dark-color-search-match-current: #f7923b;
--dark-color-selected-tree-highlight-active: rgba(23, 143, 185, 0.15);
--dark-color-selected-tree-highlight-inactive: rgba(255, 255, 255, 0.05);
--dark-color-scroll-caret: #4f5766;
--dark-color-shadow: rgba(0, 0, 0, 0.5);
--dark-color-tab-selected-border: #178fb9;
--dark-color-text: #ffffff;
@@ -252,6 +251,10 @@
--dark-color-toggle-text: #ffffff;
--dark-color-tooltip-background: rgba(255, 255, 255, 0.95);
--dark-color-tooltip-text: #000000;
--dark-color-warning-background: #ee1638;
--dark-color-warning-background-hover: #da1030;
--dark-color-warning-text-color: #ffffff;
--dark-color-warning-text-color-inverted: #ee1638;
/* Font smoothing */
--light-font-smoothing: auto;
+1 -8
View File
@@ -103,14 +103,7 @@ export function loadHookNames(
let didTimeout = false;
const response = loadHookNamesFunction(hooksTree);
console.log(
'loadHookNamesFunction:',
loadHookNamesFunction,
'->',
response,
);
response.then(
loadHookNamesFunction(hooksTree).then(
function onSuccess(hookNames) {
if (didTimeout) {
return;
@@ -244,7 +244,7 @@ function throwException(
}
if (enableSchedulingProfiler) {
markComponentSuspended(sourceFiber, wakeable);
markComponentSuspended(sourceFiber, wakeable, rootRenderLanes);
}
// Reset the memoizedState to what it was before we attempted to render it.
@@ -244,7 +244,7 @@ function throwException(
}
if (enableSchedulingProfiler) {
markComponentSuspended(sourceFiber, wakeable);
markComponentSuspended(sourceFiber, wakeable, rootRenderLanes);
}
// Reset the memoizedState to what it was before we attempted to render it.
+13 -3
View File
@@ -109,13 +109,23 @@ function getWakeableID(wakeable: Wakeable): number {
return ((wakeableIDs.get(wakeable): any): number);
}
export function markComponentSuspended(fiber: Fiber, wakeable: Wakeable): void {
export function markComponentSuspended(
fiber: Fiber,
wakeable: Wakeable,
lanes: Lanes,
): void {
if (enableSchedulingProfiler) {
if (supportsUserTimingV3) {
const eventType = wakeableIDs.has(wakeable) ? 'resuspend' : 'suspend';
const id = getWakeableID(wakeable);
const componentName = getComponentNameFromFiber(fiber) || 'Unknown';
// TODO Add component stack id
markAndClear(`--suspense-suspend-${id}-${componentName}`);
const phase = fiber.alternate === null ? 'mount' : 'update';
// TODO (scheduling profiler) Add component stack id if we re-add component stack info.
markAndClear(
`--suspense-${eventType}-${id}-${componentName}-${phase}-${formatLanes(
lanes,
)}`,
);
wakeable.then(
() => markAndClear(`--suspense-resolved-${id}-${componentName}`),
() => markAndClear(`--suspense-rejected-${id}-${componentName}`),
@@ -208,7 +208,7 @@ describe('SchedulingProfiler', () => {
`--react-init-${ReactVersion}`,
`--schedule-render-${formatLanes(ReactFiberLane.SyncLane)}`,
`--render-start-${formatLanes(ReactFiberLane.SyncLane)}`,
'--suspense-suspend-0-Example',
'--suspense-suspend-0-Example-mount-1-Sync',
'--render-stop',
`--commit-start-${formatLanes(ReactFiberLane.SyncLane)}`,
`--layout-effects-start-${formatLanes(ReactFiberLane.SyncLane)}`,
@@ -239,7 +239,7 @@ describe('SchedulingProfiler', () => {
`--react-init-${ReactVersion}`,
`--schedule-render-${formatLanes(ReactFiberLane.SyncLane)}`,
`--render-start-${formatLanes(ReactFiberLane.SyncLane)}`,
'--suspense-suspend-0-Example',
'--suspense-suspend-0-Example-mount-1-Sync',
'--render-stop',
`--commit-start-${formatLanes(ReactFiberLane.SyncLane)}`,
`--layout-effects-start-${formatLanes(ReactFiberLane.SyncLane)}`,
@@ -278,7 +278,7 @@ describe('SchedulingProfiler', () => {
expectMarksToEqual([
`--render-start-${formatLanes(ReactFiberLane.DefaultLane)}`,
'--suspense-suspend-0-Example',
'--suspense-suspend-0-Example-mount-16-Default',
'--render-stop',
`--commit-start-${formatLanes(ReactFiberLane.DefaultLane)}`,
`--layout-effects-start-${formatLanes(ReactFiberLane.DefaultLane)}`,
@@ -317,7 +317,7 @@ describe('SchedulingProfiler', () => {
expectMarksToEqual([
`--render-start-${formatLanes(ReactFiberLane.DefaultLane)}`,
'--suspense-suspend-0-Example',
'--suspense-suspend-0-Example-mount-16-Default',
'--render-stop',
`--commit-start-${formatLanes(ReactFiberLane.DefaultLane)}`,
`--layout-effects-start-${formatLanes(ReactFiberLane.DefaultLane)}`,