Skip to content

Scheduling Profiler marks should include thrown Errors #22419

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Sep 24, 2021
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 41 additions & 72 deletions packages/react-devtools-scheduling-profiler/src/CanvasPage.js
Original file line number Diff line number Diff line change
@@ -49,6 +49,7 @@ import {
SchedulingEventsView,
SnapshotsView,
SuspenseEventsView,
ThrownErrorsView,
TimeAxisMarkersView,
UserTimingMarksView,
} from './content-views';
@@ -138,6 +139,7 @@ const EMPTY_CONTEXT_INFO: ReactHoverContextInfo = {
schedulingEvent: null,
snapshot: null,
suspenseEvent: null,
thrownError: null,
userTimingMark: null,
};

@@ -178,6 +180,7 @@ function AutoSizedCanvas({
const flamechartViewRef = useRef(null);
const networkMeasuresViewRef = useRef(null);
const snapshotsViewRef = useRef(null);
const thrownErrorsViewRef = useRef(null);

const {hideMenu: hideContextMenu} = useContext(RegistryContext);

@@ -271,6 +274,20 @@ function AutoSizedCanvas({
true,
);

let thrownErrorsViewWrapper = null;
if (data.thrownErrors.length > 0) {
const thrownErrorsView = new ThrownErrorsView(
surface,
defaultFrame,
data,
);
thrownErrorsViewRef.current = thrownErrorsView;
thrownErrorsViewWrapper = createViewHelper(
thrownErrorsView,
'thrown errors',
);
}

const schedulingEventsView = new SchedulingEventsView(
surface,
defaultFrame,
@@ -382,6 +399,9 @@ function AutoSizedCanvas({
}
rootView.addSubview(nativeEventsViewWrapper);
rootView.addSubview(schedulingEventsViewWrapper);
if (thrownErrorsViewWrapper !== null) {
rootView.addSubview(thrownErrorsViewWrapper);
}
if (suspenseEventsViewWrapper !== null) {
rootView.addSubview(suspenseEventsViewWrapper);
}
@@ -461,14 +481,7 @@ function AutoSizedCanvas({
userTimingMarksView.onHover = userTimingMark => {
if (!hoveredEvent || hoveredEvent.userTimingMark !== userTimingMark) {
setHoveredEvent({
componentMeasure: null,
flamechartStackFrame: null,
measure: null,
nativeEvent: null,
networkMeasure: null,
schedulingEvent: null,
snapshot: null,
suspenseEvent: null,
...EMPTY_CONTEXT_INFO,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

All of the repetition in this file was driving me crazy so I cleaned it up a bit with this change.

userTimingMark,
});
}
@@ -480,15 +493,8 @@ function AutoSizedCanvas({
nativeEventsView.onHover = nativeEvent => {
if (!hoveredEvent || hoveredEvent.nativeEvent !== nativeEvent) {
setHoveredEvent({
componentMeasure: null,
flamechartStackFrame: null,
measure: null,
...EMPTY_CONTEXT_INFO,
nativeEvent,
networkMeasure: null,
schedulingEvent: null,
snapshot: null,
suspenseEvent: null,
userTimingMark: null,
});
}
};
@@ -499,15 +505,8 @@ function AutoSizedCanvas({
schedulingEventsView.onHover = schedulingEvent => {
if (!hoveredEvent || hoveredEvent.schedulingEvent !== schedulingEvent) {
setHoveredEvent({
componentMeasure: null,
flamechartStackFrame: null,
measure: null,
nativeEvent: null,
networkMeasure: null,
...EMPTY_CONTEXT_INFO,
schedulingEvent,
snapshot: null,
suspenseEvent: null,
userTimingMark: null,
});
}
};
@@ -518,15 +517,8 @@ function AutoSizedCanvas({
suspenseEventsView.onHover = suspenseEvent => {
if (!hoveredEvent || hoveredEvent.suspenseEvent !== suspenseEvent) {
setHoveredEvent({
componentMeasure: null,
flamechartStackFrame: null,
measure: null,
nativeEvent: null,
networkMeasure: null,
schedulingEvent: null,
snapshot: null,
...EMPTY_CONTEXT_INFO,
suspenseEvent,
userTimingMark: null,
});
}
};
@@ -537,15 +529,8 @@ function AutoSizedCanvas({
reactMeasuresView.onHover = measure => {
if (!hoveredEvent || hoveredEvent.measure !== measure) {
setHoveredEvent({
componentMeasure: null,
flamechartStackFrame: null,
...EMPTY_CONTEXT_INFO,
measure,
nativeEvent: null,
networkMeasure: null,
schedulingEvent: null,
snapshot: null,
suspenseEvent: null,
userTimingMark: null,
});
}
};
@@ -559,15 +544,8 @@ function AutoSizedCanvas({
hoveredEvent.componentMeasure !== componentMeasure
) {
setHoveredEvent({
...EMPTY_CONTEXT_INFO,
componentMeasure,
flamechartStackFrame: null,
measure: null,
nativeEvent: null,
networkMeasure: null,
schedulingEvent: null,
snapshot: null,
suspenseEvent: null,
userTimingMark: null,
});
}
};
@@ -578,15 +556,8 @@ function AutoSizedCanvas({
snapshotsView.onHover = snapshot => {
if (!hoveredEvent || hoveredEvent.snapshot !== snapshot) {
setHoveredEvent({
componentMeasure: null,
flamechartStackFrame: null,
measure: null,
nativeEvent: null,
networkMeasure: null,
schedulingEvent: null,
...EMPTY_CONTEXT_INFO,
snapshot,
suspenseEvent: null,
userTimingMark: null,
});
}
};
@@ -600,15 +571,8 @@ function AutoSizedCanvas({
hoveredEvent.flamechartStackFrame !== flamechartStackFrame
) {
setHoveredEvent({
componentMeasure: null,
...EMPTY_CONTEXT_INFO,
flamechartStackFrame,
measure: null,
nativeEvent: null,
networkMeasure: null,
schedulingEvent: null,
snapshot: null,
suspenseEvent: null,
userTimingMark: null,
});
}
});
@@ -619,15 +583,20 @@ function AutoSizedCanvas({
networkMeasuresView.onHover = networkMeasure => {
if (!hoveredEvent || hoveredEvent.networkMeasure !== networkMeasure) {
setHoveredEvent({
componentMeasure: null,
flamechartStackFrame: null,
measure: null,
nativeEvent: null,
...EMPTY_CONTEXT_INFO,
networkMeasure,
schedulingEvent: null,
snapshot: null,
suspenseEvent: null,
userTimingMark: null,
});
}
};
}

const {current: thrownErrorsView} = thrownErrorsViewRef;
if (thrownErrorsView) {
thrownErrorsView.onHover = thrownError => {
if (!hoveredEvent || hoveredEvent.thrownError !== thrownError) {
setHoveredEvent({
...EMPTY_CONTEXT_INFO,
thrownError,
});
}
};
30 changes: 30 additions & 0 deletions packages/react-devtools-scheduling-profiler/src/EventTooltip.js
Original file line number Diff line number Diff line change
@@ -19,6 +19,7 @@ import type {
SchedulingEvent,
Snapshot,
SuspenseEvent,
ThrownError,
UserTimingMark,
} from './types';

@@ -92,6 +93,7 @@ export default function EventTooltip({
schedulingEvent,
snapshot,
suspenseEvent,
thrownError,
userTimingMark,
} = hoveredEvent;

@@ -118,6 +120,8 @@ export default function EventTooltip({
content = <TooltipFlamechartNode stackFrame={flamechartStackFrame} />;
} else if (userTimingMark !== null) {
content = <TooltipUserTimingMark mark={userTimingMark} />;
} else if (thrownError !== null) {
content = <TooltipThrownError thrownError={thrownError} />;
}

if (content !== null) {
@@ -436,3 +440,29 @@ const TooltipUserTimingMark = ({mark}: {|mark: UserTimingMark|}) => {
</div>
);
};

const TooltipThrownError = ({thrownError}: {|thrownError: ThrownError|}) => {
const {componentName, message, phase, timestamp} = thrownError;
const label = `threw an error during ${phase}`;
return (
<div className={styles.TooltipSection}>
{componentName && (
<span className={styles.ComponentName}>
{trimString(componentName, 100)}
</span>
)}
<span className={styles.UserTimingLabel}>{label}</span>
<div className={styles.Divider} />
<div className={styles.DetailsGrid}>
<div className={styles.DetailsGridLabel}>Timestamp:</div>
<div>{formatTimestamp(timestamp)}</div>
{message !== '' && (
<>
<div className={styles.DetailsGridLabel}>Error:</div>
<div>{message}</div>
</>
)}
</div>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -136,15 +136,23 @@ export class ReactMeasuresView extends View {
// Commit phase rects are overlapped by layout and passive rects,
// and it looks bad if text flows underneath/behind these overlayed rects.
if (nextMeasure != null) {
textRect = {
...measureRect,
size: {
width:
timestampToPosition(nextMeasure.timestamp, scaleFactor, frame) -
x,
height: REACT_MEASURE_HEIGHT,
},
};
// This clipping shouldn't apply for measures that don't overlap though,
// like passive effects that are processed after a delay,
// or if there are now layout or passive effects and the next measure is render or idle.
if (nextMeasure.timestamp < measure.timestamp + measure.duration) {
textRect = {
...measureRect,
size: {
width:
timestampToPosition(
nextMeasure.timestamp,
scaleFactor,
frame,
) - x,
height: REACT_MEASURE_HEIGHT,
},
};
}
Comment on lines +139 to +155
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Notice this overflow bug while I was testing profiling data sets, so I fixed it too. It's unrelated to the main change.

}
break;
case 'render-idle':
Original file line number Diff line number Diff line change
@@ -195,7 +195,7 @@ export class SchedulingEventsView extends View {
};
if (rectIntersectsRect(borderFrame, visibleArea)) {
const borderDrawableRect = intersectionOfRects(borderFrame, visibleArea);
context.fillStyle = COLORS.PRIORITY_BORDER;
context.fillStyle = COLORS.REACT_WORK_BORDER;
context.fillRect(
borderDrawableRect.origin.x,
borderDrawableRect.origin.y,
Original file line number Diff line number Diff line change
@@ -272,7 +272,7 @@ export class SuspenseEventsView extends View {
borderFrame,
visibleArea,
);
context.fillStyle = COLORS.PRIORITY_BORDER;
context.fillStyle = COLORS.REACT_WORK_BORDER;
context.fillRect(
borderDrawableRect.origin.x,
borderDrawableRect.origin.y,
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
/**
* 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 {ThrownError, ReactProfilerData} from '../types';
import type {
Interaction,
MouseMoveInteraction,
Rect,
Size,
ViewRefs,
} from '../view-base';

import {
positioningScaleFactor,
timestampToPosition,
positionToTimestamp,
widthToDuration,
} from './utils/positioning';
import {
View,
Surface,
rectContainsPoint,
rectIntersectsRect,
intersectionOfRects,
} from '../view-base';
import {
COLORS,
TOP_ROW_PADDING,
REACT_EVENT_DIAMETER,
BORDER_SIZE,
} from './constants';

const EVENT_ROW_HEIGHT_FIXED =
TOP_ROW_PADDING + REACT_EVENT_DIAMETER + TOP_ROW_PADDING;

export class ThrownErrorsView extends View {
_profilerData: ReactProfilerData;
_intrinsicSize: Size;
_hoveredEvent: ThrownError | null = null;
onHover: ((event: ThrownError | null) => void) | null = null;

constructor(surface: Surface, frame: Rect, profilerData: ReactProfilerData) {
super(surface, frame);
this._profilerData = profilerData;

this._intrinsicSize = {
width: this._profilerData.duration,
height: EVENT_ROW_HEIGHT_FIXED,
};
}

desiredSize() {
return this._intrinsicSize;
}

setHoveredEvent(hoveredEvent: ThrownError | null) {
if (this._hoveredEvent === hoveredEvent) {
return;
}
this._hoveredEvent = hoveredEvent;
this.setNeedsDisplay();
}

/**
* Draw a single `ThrownError` as a circle in the canvas.
*/
_drawSingleThrownError(
context: CanvasRenderingContext2D,
rect: Rect,
thrownError: ThrownError,
baseY: number,
scaleFactor: number,
showHoverHighlight: boolean,
) {
const {frame} = this;
const {timestamp} = thrownError;

const x = timestampToPosition(timestamp, scaleFactor, frame);
const radius = REACT_EVENT_DIAMETER / 2;
const eventRect: Rect = {
origin: {
x: x - radius,
y: baseY,
},
size: {width: REACT_EVENT_DIAMETER, height: REACT_EVENT_DIAMETER},
};
if (!rectIntersectsRect(eventRect, rect)) {
return; // Not in view
}

const fillStyle = showHoverHighlight
? COLORS.REACT_THROWN_ERROR_HOVER
: COLORS.REACT_THROWN_ERROR;

const y = eventRect.origin.y + radius;

context.beginPath();
context.fillStyle = fillStyle;
context.arc(x, y, radius, 0, 2 * Math.PI);
context.fill();
}

draw(context: CanvasRenderingContext2D) {
const {
frame,
_profilerData: {thrownErrors},
_hoveredEvent,
visibleArea,
} = this;

context.fillStyle = COLORS.BACKGROUND;
context.fillRect(
visibleArea.origin.x,
visibleArea.origin.y,
visibleArea.size.width,
visibleArea.size.height,
);

// Draw events
const baseY = frame.origin.y + TOP_ROW_PADDING;
const scaleFactor = positioningScaleFactor(
this._intrinsicSize.width,
frame,
);

const highlightedEvents: ThrownError[] = [];

thrownErrors.forEach(thrownError => {
if (thrownError === _hoveredEvent) {
highlightedEvents.push(thrownError);
return;
}
this._drawSingleThrownError(
context,
visibleArea,
thrownError,
baseY,
scaleFactor,
false,
);
});

// 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(thrownError => {
this._drawSingleThrownError(
context,
visibleArea,
thrownError,
baseY,
scaleFactor,
true,
);
});

// Render bottom borders.
// Propose border rect, check if intersects with `rect`, draw intersection.
const borderFrame: Rect = {
origin: {
x: frame.origin.x,
y: frame.origin.y + EVENT_ROW_HEIGHT_FIXED - BORDER_SIZE,
},
size: {
width: frame.size.width,
height: BORDER_SIZE,
},
};
if (rectIntersectsRect(borderFrame, visibleArea)) {
const borderDrawableRect = intersectionOfRects(borderFrame, visibleArea);
context.fillStyle = COLORS.REACT_WORK_BORDER;
context.fillRect(
borderDrawableRect.origin.x,
borderDrawableRect.origin.y,
borderDrawableRect.size.width,
borderDrawableRect.size.height,
);
}
}

/**
* @private
*/
_handleMouseMove(interaction: MouseMoveInteraction, viewRefs: ViewRefs) {
const {frame, onHover, visibleArea} = this;
if (!onHover) {
return;
}

const {location} = interaction.payload;
if (!rectContainsPoint(location, visibleArea)) {
onHover(null);
return;
}

const {
_profilerData: {thrownErrors},
} = this;
const scaleFactor = positioningScaleFactor(
this._intrinsicSize.width,
frame,
);
const hoverTimestamp = positionToTimestamp(location.x, scaleFactor, frame);
const eventTimestampAllowance = widthToDuration(
REACT_EVENT_DIAMETER / 2,
scaleFactor,
);

// 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 = thrownErrors.length - 1; index >= 0; index--) {
const event = thrownErrors[index];
const {timestamp} = event;

if (
timestamp - eventTimestampAllowance <= hoverTimestamp &&
hoverTimestamp <= timestamp + eventTimestampAllowance
) {
this.currentCursor = 'context-menu';
viewRefs.hoveredView = this;
onHover(event);
return;
}
}

onHover(null);
}

handleInteraction(interaction: Interaction, viewRefs: ViewRefs) {
switch (interaction.type) {
case 'mousemove':
this._handleMouseMove(interaction, viewRefs);
break;
}
}
}
Original file line number Diff line number Diff line change
@@ -84,6 +84,8 @@ export let COLORS = {
REACT_SUSPENSE_RESOLVED_EVENT_HOVER: '',
REACT_SUSPENSE_UNRESOLVED_EVENT: '',
REACT_SUSPENSE_UNRESOLVED_EVENT_HOVER: '',
REACT_THROWN_ERROR: '',
REACT_THROWN_ERROR_HOVER: '',
REACT_WORK_BORDER: '',
SCROLL_CARET: '',
TEXT_COLOR: '',
@@ -218,6 +220,12 @@ export function updateColorsToMatchTheme(element: Element): boolean {
REACT_SUSPENSE_UNRESOLVED_EVENT_HOVER: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-suspense-unresolved-hover',
),
REACT_THROWN_ERROR: computedStyle.getPropertyValue(
'--color-scheduling-profiler-thrown-error',
),
REACT_THROWN_ERROR_HOVER: computedStyle.getPropertyValue(
'--color-scheduling-profiler-thrown-error-hover',
),
REACT_WORK_BORDER: computedStyle.getPropertyValue(
'--color-scheduling-profiler-react-work-border',
),
Original file line number Diff line number Diff line change
@@ -15,5 +15,6 @@ export * from './ReactMeasuresView';
export * from './SchedulingEventsView';
export * from './SnapshotsView';
export * from './SuspenseEventsView';
export * from './ThrownErrorsView';
export * from './TimeAxisMarkersView';
export * from './UserTimingMarksView';
Original file line number Diff line number Diff line change
@@ -354,6 +354,7 @@ describe('preprocessData', () => {
"snapshots": Array [],
"startTime": 1,
"suspenseEvents": Array [],
"thrownErrors": Array [],
}
`);
}
@@ -570,6 +571,7 @@ describe('preprocessData', () => {
"snapshots": Array [],
"startTime": 1,
"suspenseEvents": Array [],
"thrownErrors": Array [],
}
`);
}
@@ -761,6 +763,7 @@ describe('preprocessData', () => {
"snapshots": Array [],
"startTime": 4,
"suspenseEvents": Array [],
"thrownErrors": Array [],
}
`);
}
@@ -1107,6 +1110,7 @@ describe('preprocessData', () => {
"snapshots": Array [],
"startTime": 4,
"suspenseEvents": Array [],
"thrownErrors": Array [],
}
`);
}
@@ -1537,6 +1541,52 @@ describe('preprocessData', () => {
});
});

describe('errors thrown while rendering', () => {
it('shoult parse Errors thrown during render', async () => {
spyOnDev(console, 'error');
spyOnProd(console, 'error');

class ErrorBoundary extends React.Component {
state = {error: null};
componentDidCatch(error) {
this.setState({error});
}
render() {
if (this.state.error) {
return null;
}
return this.props.children;
}
}

function ExampleThatThrows() {
throw Error('Expected error');
}

if (gate(flags => flags.enableSchedulingProfiler)) {
const testMarks = [creactCpuProfilerSample()];

// Mount and commit the app
const root = ReactDOM.createRoot(document.createElement('div'));
act(() =>
root.render(
<ErrorBoundary>
<ExampleThatThrows />
</ErrorBoundary>,
),
);

testMarks.push(...createUserTimingData(clearedMarks));

const data = await preprocessData(testMarks);
expect(data.thrownErrors).toHaveLength(2);
expect(data.thrownErrors[0].message).toMatchInlineSnapshot(
'"Expected error"',
);
}
});
});

describe('suspend during an update', () => {
// This also tests an edge case where the a component suspends while profiling
// before the first commit is logged (so the lane-to-labels map will not yet exist).
Original file line number Diff line number Diff line change
@@ -550,6 +550,16 @@ function processTimelineEvent(
}

currentProfilerData.schedulingEvents.push(stateUpdateEvent);
} else if (name.startsWith('--error-')) {
const [componentName, phase, message] = name.substr(8).split('-');

currentProfilerData.thrownErrors.push({
componentName,
message,
phase: ((phase: any): Phase),
timestamp: startTime,
type: 'thrown-error',
});
} // eslint-disable-line brace-style

// React Events - suspense
@@ -865,6 +875,7 @@ export default async function preprocessData(
snapshots: [],
startTime: 0,
suspenseEvents: [],
thrownErrors: [],
};

// Sort `timeline`. JSON Array Format trace events need not be ordered. See:
10 changes: 10 additions & 0 deletions packages/react-devtools-scheduling-profiler/src/types.js
Original file line number Diff line number Diff line change
@@ -65,6 +65,14 @@ export type SuspenseEvent = {|
+type: 'suspense',
|};

export type ThrownError = {|
+componentName?: string,
+message: string,
+phase: Phase,
+timestamp: Milliseconds,
+type: 'thrown-error',
|};

export type SchedulingEvent =
| ReactScheduleRenderEvent
| ReactScheduleStateUpdateEvent
@@ -175,6 +183,7 @@ export type ReactProfilerData = {|
snapshots: Snapshot[],
startTime: number,
suspenseEvents: SuspenseEvent[],
thrownErrors: ThrownError[],
|};

export type ReactHoverContextInfo = {|
@@ -186,5 +195,6 @@ export type ReactHoverContextInfo = {|
schedulingEvent: SchedulingEvent | null,
suspenseEvent: SuspenseEvent | null,
snapshot: Snapshot | null,
thrownError: ThrownError | null,
userTimingMark: UserTimingMark | null,
|};
8 changes: 6 additions & 2 deletions packages/react-devtools-shared/src/constants.js
Original file line number Diff line number Diff line change
@@ -177,9 +177,11 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any} = {
'--color-scheduling-profiler-react-suspense-resolved-hover': '#89d281',
'--color-scheduling-profiler-react-suspense-unresolved': '#c9cacd',
'--color-scheduling-profiler-react-suspense-unresolved-hover': '#93959a',
'--color-scheduling-profiler-thrown-error': '#ee1638',
'--color-scheduling-profiler-thrown-error-hover': '#da1030',
'--color-scheduling-profiler-text-color': '#000000',
'--color-scheduling-profiler-text-dim-color': '#ccc',
'--color-scheduling-profiler-react-work-border': '#ffffff',
'--color-scheduling-profiler-react-work-border': '#eeeeee',
'--color-search-match': 'yellow',
'--color-search-match-current': '#f7923b',
'--color-selected-tree-highlight-active': 'rgba(0, 136, 250, 0.1)',
@@ -316,9 +318,11 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any} = {
'--color-scheduling-profiler-react-suspense-resolved-hover': '#89d281',
'--color-scheduling-profiler-react-suspense-unresolved': '#c9cacd',
'--color-scheduling-profiler-react-suspense-unresolved-hover': '#93959a',
'--color-scheduling-profiler-thrown-error': '#fb3655',
'--color-scheduling-profiler-thrown-error-hover': '#f82042',
'--color-scheduling-profiler-text-color': '#282c34',
'--color-scheduling-profiler-text-dim-color': '#555b66',
'--color-scheduling-profiler-react-work-border': '#ffffff',
'--color-scheduling-profiler-react-work-border': '#3d424a',
Copy link
Contributor Author

Choose a reason for hiding this comment

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

These color values were broken before. Noticed while implementing this so I fixed them too.

'--color-search-match': 'yellow',
'--color-search-match-current': '#f7923b',
'--color-selected-tree-highlight-active': 'rgba(23, 143, 185, 0.15)',