diff --git a/src/CanvasPage.js b/src/CanvasPage.js index 35d608a81f3e8..3590da30414e5 100644 --- a/src/CanvasPage.js +++ b/src/CanvasPage.js @@ -42,6 +42,7 @@ import { ReactEventsView, ReactMeasuresView, TimeAxisMarkersView, + UserTimingMarksView, } from './canvas/views'; type ContextMenuContextData = {| @@ -121,6 +122,7 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) { ] = useState(null); const surfaceRef = useRef(new Surface()); + const userTimingMarksViewRef = useRef(null); const reactEventsViewRef = useRef(null); const reactMeasuresViewRef = useRef(null); const flamechartViewRef = useRef(null); @@ -137,21 +139,32 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) { // Top content - const axisMarkersView = new TimeAxisMarkersView( + const topContentStack = new View( surface, defaultFrame, - data.duration, + verticallyStackedLayout, ); - const reactEventsView = new ReactEventsView(surface, defaultFrame, data); - reactEventsViewRef.current = reactEventsView; - - const topContentStack = new View( + const axisMarkersView = new TimeAxisMarkersView( surface, defaultFrame, - verticallyStackedLayout, + data.duration, ); topContentStack.addSubview(axisMarkersView); + + if (data.otherUserTimingMarks.length > 0) { + const userTimingMarksView = new UserTimingMarksView( + surface, + defaultFrame, + data.otherUserTimingMarks, + data.duration, + ); + userTimingMarksViewRef.current = userTimingMarksView; + topContentStack.addSubview(userTimingMarksView); + } + + const reactEventsView = new ReactEventsView(surface, defaultFrame, data); + reactEventsViewRef.current = reactEventsView; topContentStack.addSubview(reactEventsView); const topContentHorizontalPanAndZoomView = new HorizontalPanAndZoomView( @@ -239,7 +252,8 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) { hoveredEvent && (hoveredEvent.event || hoveredEvent.measure || - hoveredEvent.flamechartStackFrame) + hoveredEvent.flamechartStackFrame || + hoveredEvent.userTimingMark) ) { setMouseLocation({ x: interaction.payload.event.x, @@ -268,11 +282,27 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) { }); useEffect(() => { + const {current: userTimingMarksView} = userTimingMarksViewRef; + if (userTimingMarksView) { + userTimingMarksView.onHover = userTimingMark => { + if (!hoveredEvent || hoveredEvent.userTimingMark !== userTimingMark) { + setHoveredEvent({ + userTimingMark, + event: null, + flamechartStackFrame: null, + measure: null, + data, + }); + } + }; + } + const {current: reactEventsView} = reactEventsViewRef; if (reactEventsView) { reactEventsView.onHover = event => { if (!hoveredEvent || hoveredEvent.event !== event) { setHoveredEvent({ + userTimingMark: null, event, flamechartStackFrame: null, measure: null, @@ -287,6 +317,7 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) { reactMeasuresView.onHover = measure => { if (!hoveredEvent || hoveredEvent.measure !== measure) { setHoveredEvent({ + userTimingMark: null, event: null, flamechartStackFrame: null, measure, @@ -304,6 +335,7 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) { hoveredEvent.flamechartStackFrame !== flamechartStackFrame ) { setHoveredEvent({ + userTimingMark: null, event: null, flamechartStackFrame, measure: null, @@ -321,6 +353,13 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) { ]); useLayoutEffect(() => { + const {current: userTimingMarksView} = userTimingMarksViewRef; + if (userTimingMarksView) { + userTimingMarksView.setHoveredMark( + hoveredEvent ? hoveredEvent.userTimingMark : null, + ); + } + const {current: reactEventsView} = reactEventsViewRef; if (reactEventsView) { reactEventsView.setHoveredEvent(hoveredEvent ? hoveredEvent.event : null); diff --git a/src/EventTooltip.js b/src/EventTooltip.js index 5a5985e8a046f..37ba646da8d92 100644 --- a/src/EventTooltip.js +++ b/src/EventTooltip.js @@ -4,10 +4,11 @@ import type {Point} from './layout'; import type { FlamechartStackFrame, ReactEvent, + ReactHoverContextInfo, ReactMeasure, ReactProfilerData, - ReactHoverContextInfo, Return, + UserTimingMark, } from './types'; import prettyMilliseconds from 'pretty-ms'; @@ -84,7 +85,7 @@ export default function EventTooltip({data, hoveredEvent, origin}: Props) { return null; } - const {event, flamechartStackFrame, measure} = hoveredEvent; + const {event, flamechartStackFrame, measure, userTimingMark} = hoveredEvent; if (event !== null) { switch (event.type) { @@ -150,6 +151,10 @@ export default function EventTooltip({data, hoveredEvent, origin}: Props) { tooltipRef={tooltipRef} /> ); + } else if (userTimingMark !== null) { + return ( + + ); } return null; } @@ -180,13 +185,7 @@ const TooltipFlamechartNode = ({ locationColumn, } = stackFrame; return ( -
+
{formatDuration(duration)} {trimComponentName(name)}
Timestamp:
@@ -223,13 +222,7 @@ const TooltipReactEvent = ({ const label = getReactEventLabel(type); return ( -
+
{componentName && ( {trimComponentName(componentName)} @@ -267,14 +260,7 @@ const TooltipReactMeasure = ({ const [startTime, stopTime] = getBatchRange(batchUID, data); return ( -
+
{formatDuration(duration)} {label}
@@ -290,3 +276,23 @@ const TooltipReactMeasure = ({
); }; + +const TooltipUserTimingMark = ({ + mark, + tooltipRef, +}: { + mark: UserTimingMark, + tooltipRef: Return, +}) => { + const {name, timestamp} = mark; + return ( +
+ {name} +
+
+
Timestamp:
+
{formatTimestamp(timestamp)}
+
+
+ ); +}; diff --git a/src/canvas/constants.js b/src/canvas/constants.js index 4f7c19bd3efb6..d10d138e97902 100644 --- a/src/canvas/constants.js +++ b/src/canvas/constants.js @@ -67,6 +67,8 @@ export const COLORS = Object.freeze({ PRIORITY_BACKGROUND: '#ededf0', PRIORITY_BORDER: '#d7d7db', PRIORITY_LABEL: '#272727', + USER_TIMING: '#45a1ff', + USER_TIMING_HOVER: '#0a84ff', REACT_IDLE: '#edf6ff', REACT_IDLE_SELECTED: '#EDF6FF', REACT_IDLE_HOVER: '#EDF6FF', diff --git a/src/canvas/views/UserTimingMarksView.js b/src/canvas/views/UserTimingMarksView.js new file mode 100644 index 0000000000000..8e431c18d504d --- /dev/null +++ b/src/canvas/views/UserTimingMarksView.js @@ -0,0 +1,229 @@ +// @flow + +import type {Interaction, HoverInteraction} from '../../useCanvasInteraction'; +import type {UserTimingMark} from '../../types'; +import type {Rect, Size} from '../../layout'; + +import { + positioningScaleFactor, + timestampToPosition, + positionToTimestamp, + widthToDuration, +} from '../canvasUtils'; +import { + View, + Surface, + rectContainsPoint, + rectIntersectsRect, + rectIntersectionWithRect, +} from '../../layout'; +import { + COLORS, + EVENT_ROW_HEIGHT_FIXED, + REACT_EVENT_ROW_PADDING, + REACT_EVENT_SIZE, + REACT_WORK_BORDER_SIZE, +} from '../constants'; + +// COMBAK: use this viewA + +export class UserTimingMarksView extends View { + _marks: UserTimingMark[]; + _intrinsicSize: Size; + + _hoveredMark: UserTimingMark | null = null; + onHover: ((mark: UserTimingMark | null) => void) | null = null; + + constructor( + surface: Surface, + frame: Rect, + marks: UserTimingMark[], + duration: number, + ) { + super(surface, frame); + this._marks = marks; + + this._intrinsicSize = { + width: duration, + height: EVENT_ROW_HEIGHT_FIXED, + }; + } + + desiredSize() { + return this._intrinsicSize; + } + + setHoveredMark(hoveredMark: UserTimingMark | null) { + if (this._hoveredMark === hoveredMark) { + return; + } + this._hoveredMark = hoveredMark; + this.setNeedsDisplay(); + } + + /** + * Draw a single `UserTimingMark` as a circle in the canvas. + */ + _drawSingleMark( + context: CanvasRenderingContext2D, + rect: Rect, + mark: UserTimingMark, + baseY: number, + scaleFactor: number, + showHoverHighlight: boolean, + ) { + const {frame} = this; + const {timestamp} = mark; + + const x = timestampToPosition(timestamp, scaleFactor, frame); + const radius = REACT_EVENT_SIZE / 2; + const markRect: Rect = { + origin: { + x: x - radius, + y: baseY, + }, + size: {width: REACT_EVENT_SIZE, height: REACT_EVENT_SIZE}, + }; + if (!rectIntersectsRect(markRect, rect)) { + return; // Not in view + } + + // TODO: Use blue color from Firefox + const fillStyle = showHoverHighlight + ? COLORS.USER_TIMING_HOVER + : COLORS.USER_TIMING; + + if (fillStyle !== null) { + const y = markRect.origin.y + radius; + + context.beginPath(); + context.fillStyle = fillStyle; + context.arc(x, y, radius, 0, 2 * Math.PI); + context.fill(); + } + } + + draw(context: CanvasRenderingContext2D) { + const {frame, _marks, _hoveredMark, visibleArea} = this; + + context.fillStyle = COLORS.BACKGROUND; + context.fillRect( + visibleArea.origin.x, + visibleArea.origin.y, + visibleArea.size.width, + visibleArea.size.height, + ); + + // Draw marks + const baseY = frame.origin.y + REACT_EVENT_ROW_PADDING; + const scaleFactor = positioningScaleFactor( + this._intrinsicSize.width, + frame, + ); + + _marks.forEach(mark => { + if (mark === _hoveredMark) { + return; + } + this._drawSingleMark( + context, + visibleArea, + mark, + baseY, + scaleFactor, + false, + ); + }); + + // Draw the hovered and/or selected items on top so they stand out. + // This is helpful if there are multiple (overlapping) items close to each other. + if (_hoveredMark !== null) { + this._drawSingleMark( + context, + visibleArea, + _hoveredMark, + baseY, + scaleFactor, + true, + ); + } + + // Render bottom border. + // 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 - REACT_WORK_BORDER_SIZE, + }, + size: { + width: frame.size.width, + height: REACT_WORK_BORDER_SIZE, + }, + }; + if (rectIntersectsRect(borderFrame, visibleArea)) { + const borderDrawableRect = rectIntersectionWithRect( + borderFrame, + visibleArea, + ); + context.fillStyle = COLORS.PRIORITY_BORDER; + context.fillRect( + borderDrawableRect.origin.x, + borderDrawableRect.origin.y, + borderDrawableRect.size.width, + borderDrawableRect.size.height, + ); + } + } + + /** + * @private + */ + _handleHover(interaction: HoverInteraction) { + const {frame, onHover, visibleArea} = this; + if (!onHover) { + return; + } + + const {location} = interaction.payload; + if (!rectContainsPoint(location, visibleArea)) { + onHover(null); + return; + } + + const {_marks} = this; + const scaleFactor = positioningScaleFactor( + this._intrinsicSize.width, + frame, + ); + const hoverTimestamp = positionToTimestamp(location.x, scaleFactor, frame); + const markTimestampAllowance = widthToDuration( + REACT_EVENT_SIZE / 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 = _marks.length - 1; index >= 0; index--) { + const mark = _marks[index]; + const {timestamp} = mark; + + if ( + timestamp - markTimestampAllowance <= hoverTimestamp && + hoverTimestamp <= timestamp + markTimestampAllowance + ) { + onHover(mark); + return; + } + } + + onHover(null); + } + + handleInteraction(interaction: Interaction) { + switch (interaction.type) { + case 'hover': + this._handleHover(interaction); + break; + } + } +} diff --git a/src/canvas/views/index.js b/src/canvas/views/index.js index 306e396f2f834..de3ee38c05539 100644 --- a/src/canvas/views/index.js +++ b/src/canvas/views/index.js @@ -4,3 +4,4 @@ export * from './FlamechartView'; export * from './ReactEventsView'; export * from './ReactMeasuresView'; export * from './TimeAxisMarkersView'; +export * from './UserTimingMarksView'; diff --git a/src/types.js b/src/types.js index 05c72016f0f40..708b6bc4bba88 100644 --- a/src/types.js +++ b/src/types.js @@ -97,6 +97,11 @@ export type FlamechartStackFrame = {| locationColumn?: number, |}; +export type UserTimingMark = {| + name: string, + timestamp: Milliseconds, +|}; + /** * A "layer" of stack frames in the profiler UI, i.e. all stack frames of the * same depth across all stack traces. Displayed as a flamechart row in the UI. @@ -111,6 +116,7 @@ export type ReactProfilerData = {| events: ReactEvent[], measures: ReactMeasure[], flamechart: Flamechart, + otherUserTimingMarks: UserTimingMark[], |}; export type ReactHoverContextInfo = {| @@ -118,4 +124,5 @@ export type ReactHoverContextInfo = {| measure: ReactMeasure | null, data: $ReadOnly | null, flamechartStackFrame: FlamechartStackFrame | null, + userTimingMark: UserTimingMark | null, |}; diff --git a/src/util/__tests__/__snapshots__/preprocessData-test.js.snap b/src/util/__tests__/__snapshots__/preprocessData-test.js.snap index 89993c69a8a0b..4055b6d45e239 100644 --- a/src/util/__tests__/__snapshots__/preprocessData-test.js.snap +++ b/src/util/__tests__/__snapshots__/preprocessData-test.js.snap @@ -1256,6 +1256,60 @@ Object { "type": "layout-effects", }, ], + "otherUserTimingMarks": Array [ + Object { + "name": "navigationStart", + "timestamp": -29.357, + }, + Object { + "name": "fetchStart", + "timestamp": -26.92, + }, + Object { + "name": "requestStart", + "timestamp": -21.171, + }, + Object { + "name": "responseEnd", + "timestamp": -15.655, + }, + Object { + "name": "unloadEventStart", + "timestamp": -0.74, + }, + Object { + "name": "unloadEventEnd", + "timestamp": 39.608, + }, + Object { + "name": "requestStart", + "timestamp": 107.649, + }, + Object { + "name": "requestStart", + "timestamp": 108.385, + }, + Object { + "name": "loadEventStart", + "timestamp": 307.056, + }, + Object { + "name": "loadEventEnd", + "timestamp": 308.242, + }, + Object { + "name": "requestStart", + "timestamp": 341.329, + }, + Object { + "name": "requestStart", + "timestamp": 344.02, + }, + Object { + "name": "requestStart", + "timestamp": 387.585, + }, + ], "startTime": 8993778496, } `; @@ -1320,6 +1374,7 @@ Object { "type": "layout-effects", }, ], + "otherUserTimingMarks": Array [], "startTime": 40806924876, } `; diff --git a/src/util/__tests__/preprocessData-test.js b/src/util/__tests__/preprocessData-test.js index e4a3ebac12c85..3dd79054a8a90 100644 --- a/src/util/__tests__/preprocessData-test.js +++ b/src/util/__tests__/preprocessData-test.js @@ -81,19 +81,10 @@ describe(preprocessData, () => { events: [], measures: [], flamechart: [], + otherUserTimingMarks: [], }); }); - it('should throw if unrecognized React mark is encountered', () => { - expect(() => - // prettier-ignore - preprocessData([ - {"args":{"data":{"startTime":8993778496}},"cat":"disabled-by-default-v8.cpu_profiler","id":"0x1","name":"Profile","ph":"P","pid":9312,"tid":10252,"ts":8993778520,"tts":1614266}, - {"args":{"data":{"navigationId":"E082C30FBDA3ACEE0E7B5FD75F8B7F0D"}},"cat":"blink.user_timing","name":"--there-are-four-lights","ph":"R","pid":17232,"tid":13628,"ts":264686513020,"tts":4082554}, - ]), - ).toThrow(); - }); - it('should throw if events and measures are incomplete', () => { const error = jest.spyOn(console, 'error'); // prettier-ignore @@ -325,5 +316,32 @@ describe(preprocessData, () => { ).toMatchSnapshot(); }); + it('should populate other user timing marks', () => { + expect( + // prettier-ignore + preprocessData([ + {"args":{"data":{"startTime":8993778496}},"cat":"disabled-by-default-v8.cpu_profiler","id":"0x1","name":"Profile","ph":"P","pid":9312,"tid":10252,"ts":8993778520,"tts":1614266}, + {"args":{"data":{"navigationId":"E082C30FBDA3ACEE0E7B5FD75F8B7F0D"}},"cat":"blink.user_timing","name":"--a-mark-that-looks-like-one-of-ours","ph":"R","pid":17232,"tid":13628,"ts":264686513020,"tts":4082554}, + {"args":{"data":{"navigationId":"E082C30FBDA3ACEE0E7B5FD75F8B7F0D"}},"cat":"blink.user_timing","name":"Some other mark","ph":"R","pid":17232,"tid":13628,"ts":264686513020,"tts":4082554}, + {"args":{},"cat":"blink.user_timing","id":"0xcdf75f7c","name":"VCWithoutImage: root","ph":"n","pid":55132,"scope":"blink.user_timing","tid":775,"ts":458734963394}, + ]).otherUserTimingMarks, + ).toMatchInlineSnapshot(` + Array [ + Object { + "name": "Some other mark", + "timestamp": 255692734.524, + }, + Object { + "name": "--a-mark-that-looks-like-one-of-ours", + "timestamp": 255692734.524, + }, + Object { + "name": "VCWithoutImage: root", + "timestamp": 449741184.898, + }, + ] + `); + }); + // TODO: Add test for flamechart parsing }); diff --git a/src/util/preprocessData.js b/src/util/preprocessData.js index 3cfc126212a9e..9d46a36134756 100644 --- a/src/util/preprocessData.js +++ b/src/util/preprocessData.js @@ -146,8 +146,8 @@ function processTimelineEvent( /** Intermediate processor state. May be mutated. */ state: ProcessorState, ) { - const {cat, name, ts} = event; - if (cat !== 'blink.user_timing' || !name.startsWith('--')) { + const {cat, name, ts, ph} = event; + if (cat !== 'blink.user_timing') { return; } @@ -342,10 +342,25 @@ function processTimelineEvent( ); } // eslint-disable-line brace-style + // Other user timing marks/measures + else if (ph === 'R' || ph === 'n') { + // User Timing mark + currentProfilerData.otherUserTimingMarks.push({ + name, + timestamp: startTime, + }); + } else if (ph === 'b') { + // TODO: Begin user timing measure (#112) + } else if (ph === 'e') { + // TODO: End user timing measure (#112) + } // eslint-disable-line brace-style + // Unrecognized event else { throw new Error( - `Unrecognized event ${name}! This is likely a bug in this profiler tool.`, + `Unrecognized event ${JSON.stringify( + event, + )}! This is likely a bug in this profiler tool.`, ); } } @@ -386,6 +401,7 @@ export default function preprocessData( events: [], measures: [], flamechart, + otherUserTimingMarks: [], }; // Sort `timeline`. JSON Array Format trace events need not be ordered. See: