/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import type {SuspenseEvent, TimelineData} from '../types';
import type {
Interaction,
IntrinsicSize,
MouseMoveInteraction,
Rect,
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: IntrinsicSize;
_maxDepth: number = 0;
_profilerData: TimelineData;
onHover: ((event: SuspenseEvent | null) => void) | null = null;
constructor(surface: Surface, frame: Rect, profilerData: TimelineData) {
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[incompatible-use] This is unnecessary.
this._depthToSuspenseEvent.get(depth).push(event);
}
});
this._intrinsicSize = {
width: duration,
height: (this._maxDepth + 1) * ROW_WITH_BORDER_HEIGHT,
hideScrollBarIfLessThanHeight: ROW_WITH_BORDER_HEIGHT,
maxInitialHeight: ROW_WITH_BORDER_HEIGHT * MAX_ROWS_TO_SHOW_INITIALLY,
};
}
desiredSize(): IntrinsicSize {
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,
promiseName,
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
}
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();
} 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 (promiseName != null) {
label = promiseName;
} else if (componentName != null) {
label = `${componentName} ${label}`;
}
if (phase !== null) {
label += ` during ${phase}`;
}
if (resolution !== 'unresolved') {
label += ` - ${formatDuration(duration)}`;
}
drawText(label, context, eventRect, drawableRect);
}
}
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.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, _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;
}
}
}