diff --git a/packages/react-devtools-scheduling-profiler/src/CanvasPage.js b/packages/react-devtools-scheduling-profiler/src/CanvasPage.js index 83de9a2d2d598..db3d3cda4ee6e 100644 --- a/packages/react-devtools-scheduling-profiler/src/CanvasPage.js +++ b/packages/react-devtools-scheduling-profiler/src/CanvasPage.js @@ -44,6 +44,7 @@ import { ComponentMeasuresView, FlamechartView, NativeEventsView, + NetworkMeasuresView, ReactMeasuresView, SchedulingEventsView, SnapshotsView, @@ -128,6 +129,18 @@ const zoomToBatch = ( viewState.updateHorizontalScrollState(scrollState); }; +const EMPTY_CONTEXT_INFO: ReactHoverContextInfo = { + componentMeasure: null, + flamechartStackFrame: null, + measure: null, + nativeEvent: null, + networkMeasure: null, + schedulingEvent: null, + snapshot: null, + suspenseEvent: null, + userTimingMark: null, +}; + type AutoSizedCanvasProps = {| data: ReactProfilerData, height: number, @@ -150,7 +163,12 @@ function AutoSizedCanvas({ setHoveredEvent, ] = useState(null); - const surfaceRef = useRef(new Surface()); + const resetHoveredEvent = useCallback( + () => setHoveredEvent(EMPTY_CONTEXT_INFO), + [], + ); + + const surfaceRef = useRef(new Surface(resetHoveredEvent)); const userTimingMarksViewRef = useRef(null); const nativeEventsViewRef = useRef(null); const schedulingEventsViewRef = useRef(null); @@ -158,6 +176,7 @@ function AutoSizedCanvas({ const componentMeasuresViewRef = useRef(null); const reactMeasuresViewRef = useRef(null); const flamechartViewRef = useRef(null); + const networkMeasuresViewRef = useRef(null); const snapshotsViewRef = useRef(null); const {hideMenu: hideContextMenu} = useContext(RegistryContext); @@ -318,6 +337,22 @@ function AutoSizedCanvas({ ); } + let networkMeasuresViewWrapper = null; + if (data.snapshots.length > 0) { + const networkMeasuresView = new NetworkMeasuresView( + surface, + defaultFrame, + data, + ); + networkMeasuresViewRef.current = networkMeasuresView; + networkMeasuresViewWrapper = createViewHelper( + networkMeasuresView, + 'network', + true, + true, + ); + } + const flamechartView = new FlamechartView( surface, defaultFrame, @@ -357,6 +392,9 @@ function AutoSizedCanvas({ if (snapshotsViewWrapper !== null) { rootView.addSubview(snapshotsViewWrapper); } + if (networkMeasuresViewWrapper !== null) { + rootView.addSubview(networkMeasuresViewWrapper); + } rootView.addSubview(flamechartViewWrapper); const verticalScrollOverflowView = new VerticalScrollOverflowView( @@ -395,16 +433,18 @@ function AutoSizedCanvas({ prevHoverEvent.flamechartStackFrame !== null || prevHoverEvent.measure !== null || prevHoverEvent.nativeEvent !== null || + prevHoverEvent.networkMeasure !== null || prevHoverEvent.schedulingEvent !== null || + prevHoverEvent.snapshot !== null || prevHoverEvent.suspenseEvent !== null || prevHoverEvent.userTimingMark !== null ) { return { componentMeasure: null, - data: prevHoverEvent.data, flamechartStackFrame: null, measure: null, nativeEvent: null, + networkMeasure: null, schedulingEvent: null, snapshot: null, suspenseEvent: null, @@ -460,10 +500,10 @@ function AutoSizedCanvas({ if (!hoveredEvent || hoveredEvent.userTimingMark !== userTimingMark) { setHoveredEvent({ componentMeasure: null, - data, flamechartStackFrame: null, measure: null, nativeEvent: null, + networkMeasure: null, schedulingEvent: null, snapshot: null, suspenseEvent: null, @@ -479,10 +519,10 @@ function AutoSizedCanvas({ if (!hoveredEvent || hoveredEvent.nativeEvent !== nativeEvent) { setHoveredEvent({ componentMeasure: null, - data, flamechartStackFrame: null, measure: null, nativeEvent, + networkMeasure: null, schedulingEvent: null, snapshot: null, suspenseEvent: null, @@ -498,10 +538,10 @@ function AutoSizedCanvas({ if (!hoveredEvent || hoveredEvent.schedulingEvent !== schedulingEvent) { setHoveredEvent({ componentMeasure: null, - data, flamechartStackFrame: null, measure: null, nativeEvent: null, + networkMeasure: null, schedulingEvent, snapshot: null, suspenseEvent: null, @@ -517,10 +557,10 @@ function AutoSizedCanvas({ if (!hoveredEvent || hoveredEvent.suspenseEvent !== suspenseEvent) { setHoveredEvent({ componentMeasure: null, - data, flamechartStackFrame: null, measure: null, nativeEvent: null, + networkMeasure: null, schedulingEvent: null, snapshot: null, suspenseEvent, @@ -536,10 +576,10 @@ function AutoSizedCanvas({ if (!hoveredEvent || hoveredEvent.measure !== measure) { setHoveredEvent({ componentMeasure: null, - data, flamechartStackFrame: null, measure, nativeEvent: null, + networkMeasure: null, schedulingEvent: null, snapshot: null, suspenseEvent: null, @@ -558,10 +598,10 @@ function AutoSizedCanvas({ ) { setHoveredEvent({ componentMeasure, - data, flamechartStackFrame: null, measure: null, nativeEvent: null, + networkMeasure: null, schedulingEvent: null, snapshot: null, suspenseEvent: null, @@ -577,10 +617,10 @@ function AutoSizedCanvas({ if (!hoveredEvent || hoveredEvent.snapshot !== snapshot) { setHoveredEvent({ componentMeasure: null, - data, flamechartStackFrame: null, measure: null, nativeEvent: null, + networkMeasure: null, schedulingEvent: null, snapshot, suspenseEvent: null, @@ -599,10 +639,10 @@ function AutoSizedCanvas({ ) { setHoveredEvent({ componentMeasure: null, - data, flamechartStackFrame, measure: null, nativeEvent: null, + networkMeasure: null, schedulingEvent: null, snapshot: null, suspenseEvent: null, @@ -611,53 +651,79 @@ function AutoSizedCanvas({ } }); } + + const {current: networkMeasuresView} = networkMeasuresViewRef; + if (networkMeasuresView) { + networkMeasuresView.onHover = networkMeasure => { + if (!hoveredEvent || hoveredEvent.networkMeasure !== networkMeasure) { + setHoveredEvent({ + componentMeasure: null, + flamechartStackFrame: null, + measure: null, + nativeEvent: null, + networkMeasure, + schedulingEvent: null, + snapshot: null, + suspenseEvent: null, + userTimingMark: null, + }); + } + }; + } }, [ hoveredEvent, data, // Attach onHover callbacks when views are re-created on data change ]); useLayoutEffect(() => { - const {current: userTimingMarksView} = userTimingMarksViewRef; + const userTimingMarksView = userTimingMarksViewRef.current; if (userTimingMarksView) { userTimingMarksView.setHoveredMark( hoveredEvent ? hoveredEvent.userTimingMark : null, ); } - const {current: nativeEventsView} = nativeEventsViewRef; + const nativeEventsView = nativeEventsViewRef.current; if (nativeEventsView) { nativeEventsView.setHoveredEvent( hoveredEvent ? hoveredEvent.nativeEvent : null, ); } - const {current: schedulingEventsView} = schedulingEventsViewRef; + const schedulingEventsView = schedulingEventsViewRef.current; if (schedulingEventsView) { schedulingEventsView.setHoveredEvent( hoveredEvent ? hoveredEvent.schedulingEvent : null, ); } - const {current: suspenseEventsView} = suspenseEventsViewRef; + const suspenseEventsView = suspenseEventsViewRef.current; if (suspenseEventsView) { suspenseEventsView.setHoveredEvent( hoveredEvent ? hoveredEvent.suspenseEvent : null, ); } - const {current: reactMeasuresView} = reactMeasuresViewRef; + const reactMeasuresView = reactMeasuresViewRef.current; if (reactMeasuresView) { reactMeasuresView.setHoveredMeasure( hoveredEvent ? hoveredEvent.measure : null, ); } - const {current: flamechartView} = flamechartViewRef; + const flamechartView = flamechartViewRef.current; if (flamechartView) { flamechartView.setHoveredFlamechartStackFrame( hoveredEvent ? hoveredEvent.flamechartStackFrame : null, ); } + + const networkMeasuresView = networkMeasuresViewRef.current; + if (networkMeasuresView) { + networkMeasuresView.setHoveredEvent( + hoveredEvent ? hoveredEvent.networkMeasure : null, + ); + } }, [hoveredEvent]); // Draw to canvas in React's commit phase @@ -677,6 +743,7 @@ function AutoSizedCanvas({ componentMeasure, flamechartStackFrame, measure, + networkMeasure, schedulingEvent, suspenseEvent, } = contextData.hoveredEvent; @@ -689,6 +756,13 @@ function AutoSizedCanvas({ Copy component name )} + {networkMeasure !== null && ( + copy(networkMeasure.url)} + title="Copy URL"> + Copy URL + + )} {schedulingEvent !== null && ( copy(schedulingEvent.componentName)} diff --git a/packages/react-devtools-scheduling-profiler/src/EventTooltip.css b/packages/react-devtools-scheduling-profiler/src/EventTooltip.css index af8c82e6308dc..5ed92c5462746 100644 --- a/packages/react-devtools-scheduling-profiler/src/EventTooltip.css +++ b/packages/react-devtools-scheduling-profiler/src/EventTooltip.css @@ -3,10 +3,10 @@ } .TooltipSection, -.TooltipWarningSection { +.TooltipWarningSection, +.SingleLineTextSection { display: block; border-radius: 0.125rem; - max-width: 300px; padding: 0.25rem; user-select: none; pointer-events: none; @@ -19,6 +19,13 @@ margin-top: 0.25rem; background-color: var(--color-warning-background); } +.TooltipSection, +.TooltipWarningSection { + max-width: 300px; +} +.SingleLineTextSection { + white-space: nowrap; +} .Divider { height: 1px; @@ -75,4 +82,8 @@ .Image { border: 1px solid var(--color-border); +} + +.DimText { + color: var(--color-dim); } \ No newline at end of file diff --git a/packages/react-devtools-scheduling-profiler/src/EventTooltip.js b/packages/react-devtools-scheduling-profiler/src/EventTooltip.js index 648bbcc56bb6c..84531e5b879c8 100644 --- a/packages/react-devtools-scheduling-profiler/src/EventTooltip.js +++ b/packages/react-devtools-scheduling-profiler/src/EventTooltip.js @@ -11,6 +11,7 @@ import type {Point} from './view-base'; import type { FlamechartStackFrame, NativeEvent, + NetworkMeasure, ReactComponentMeasure, ReactHoverContextInfo, ReactMeasure, @@ -29,6 +30,8 @@ import {getBatchRange} from './utils/getBatchRange'; import useSmartTooltip from './utils/useSmartTooltip'; import styles from './EventTooltip.css'; +const MAX_TOOLTIP_TEXT_LENGTH = 60; + type Props = {| canvasRef: {|current: HTMLCanvasElement | null|}, data: ReactProfilerData, @@ -87,6 +90,7 @@ export default function EventTooltip({ flamechartStackFrame, measure, nativeEvent, + networkMeasure, schedulingEvent, snapshot, suspenseEvent, @@ -104,6 +108,13 @@ export default function EventTooltip({ return ( ); + } else if (networkMeasure !== null) { + return ( + + ); } else if (schedulingEvent !== null) { return ( , +}) => { + const { + finishTimestamp, + lastReceivedDataTimestamp, + priority, + sendRequestTimestamp, + url, + } = networkMeasure; + + let urlToDisplay = url; + if (urlToDisplay.length > MAX_TOOLTIP_TEXT_LENGTH) { + const half = Math.floor(MAX_TOOLTIP_TEXT_LENGTH / 2); + urlToDisplay = url.substr(0, half) + '…' + url.substr(url.length - half); + } + + const timestampBegin = sendRequestTimestamp; + const timestampEnd = finishTimestamp || lastReceivedDataTimestamp; + const duration = + timestampEnd > 0 + ? formatDuration(finishTimestamp - timestampBegin) + : '(incomplete)'; + + return ( +
+
+ {duration} {priority}{' '} + {urlToDisplay} +
+
+ ); +}; + const TooltipSchedulingEvent = ({ data, schedulingEvent, diff --git a/packages/react-devtools-scheduling-profiler/src/content-views/NetworkMeasuresView.js b/packages/react-devtools-scheduling-profiler/src/content-views/NetworkMeasuresView.js new file mode 100644 index 0000000000000..321bfcca1d5aa --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/content-views/NetworkMeasuresView.js @@ -0,0 +1,337 @@ +/** + * 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 {NetworkMeasure, ReactProfilerData} from '../types'; +import type { + Interaction, + IntrinsicSize, + MouseMoveInteraction, + Rect, + ViewRefs, +} from '../view-base'; + +import { + durationToWidth, + positioningScaleFactor, + positionToTimestamp, + timestampToPosition, +} 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, SUSPENSE_EVENT_HEIGHT} from './constants'; + +const HEIGHT = SUSPENSE_EVENT_HEIGHT; // TODO Constant name +const ROW_WITH_BORDER_HEIGHT = HEIGHT + BORDER_SIZE; + +const BASE_URL_REGEX = /([^:]+:\/\/[^\/]+)/; + +export class NetworkMeasuresView extends View { + _depthToNetworkMeasure: Map; + _hoveredNetworkMeasure: NetworkMeasure | null = null; + _intrinsicSize: IntrinsicSize; + _maxDepth: number = 0; + _profilerData: ReactProfilerData; + + onHover: ((event: NetworkMeasure | null) => void) | null = null; + + constructor(surface: Surface, frame: Rect, profilerData: ReactProfilerData) { + super(surface, frame); + + this._profilerData = profilerData; + + this._performPreflightComputations(); + } + + _performPreflightComputations() { + this._depthToNetworkMeasure = new Map(); + + const {duration, networkMeasures} = this._profilerData; + + networkMeasures.forEach(event => { + const depth = event.depth; + + this._maxDepth = Math.max(this._maxDepth, depth); + + if (!this._depthToNetworkMeasure.has(depth)) { + this._depthToNetworkMeasure.set(depth, [event]); + } else { + // $FlowFixMe This is unnecessary. + this._depthToNetworkMeasure.get(depth).push(event); + } + }); + + this._intrinsicSize = { + width: duration, + height: (this._maxDepth + 1) * ROW_WITH_BORDER_HEIGHT, + // Collapsed by default + maxInitialHeight: 0, + }; + } + + desiredSize() { + return this._intrinsicSize; + } + + setHoveredEvent(networkMeasure: NetworkMeasure | null) { + if (this._hoveredNetworkMeasure === networkMeasure) { + return; + } + this._hoveredNetworkMeasure = networkMeasure; + this.setNeedsDisplay(); + } + + /** + * Draw a single `NetworkMeasure` as a box/span with text inside of it. + */ + _drawSingleNetworkMeasure( + context: CanvasRenderingContext2D, + networkMeasure: NetworkMeasure, + baseY: number, + scaleFactor: number, + showHoverHighlight: boolean, + ) { + const {frame, visibleArea} = this; + const { + depth, + finishTimestamp, + firstReceivedDataTimestamp, + lastReceivedDataTimestamp, + receiveResponseTimestamp, + sendRequestTimestamp, + url, + } = networkMeasure; + + // Account for requests that did not complete while we were profiling. + // As well as requests that did not receive data before finish (cached?). + const duration = this._profilerData.duration; + const timestampBegin = sendRequestTimestamp; + const timestampEnd = + finishTimestamp || lastReceivedDataTimestamp || duration; + const timestampMiddle = + receiveResponseTimestamp || firstReceivedDataTimestamp || timestampEnd; + + // Convert all timestamps to x coordinates. + const xStart = timestampToPosition(timestampBegin, scaleFactor, frame); + const xMiddle = timestampToPosition(timestampMiddle, scaleFactor, frame); + const xStop = timestampToPosition(timestampEnd, scaleFactor, frame); + + const width = durationToWidth(xStop - xStart, scaleFactor); + if (width < 1) { + return; // Too small to render at this zoom level + } + + baseY += depth * ROW_WITH_BORDER_HEIGHT; + + const outerRect: Rect = { + origin: { + x: xStart, + y: baseY, + }, + size: { + width: xStop - xStart, + height: HEIGHT, + }, + }; + if (!rectIntersectsRect(outerRect, visibleArea)) { + return; // Not in view + } + + // Draw the secondary rect first (since it also shows as a thin border around the primary rect). + let rect = { + origin: { + x: xStart, + y: baseY, + }, + size: { + width: xStop - xStart, + height: HEIGHT, + }, + }; + if (rectIntersectsRect(rect, visibleArea)) { + context.beginPath(); + context.fillStyle = + this._hoveredNetworkMeasure === networkMeasure + ? COLORS.NETWORK_SECONDARY_HOVER + : COLORS.NETWORK_SECONDARY; + context.fillRect( + rect.origin.x, + rect.origin.y, + rect.size.width, + rect.size.height, + ); + } + + rect = { + origin: { + x: xStart + BORDER_SIZE, + y: baseY + BORDER_SIZE, + }, + size: { + width: xMiddle - xStart - BORDER_SIZE, + height: HEIGHT - BORDER_SIZE * 2, + }, + }; + if (rectIntersectsRect(rect, visibleArea)) { + context.beginPath(); + context.fillStyle = + this._hoveredNetworkMeasure === networkMeasure + ? COLORS.NETWORK_PRIMARY_HOVER + : COLORS.NETWORK_PRIMARY; + context.fillRect( + rect.origin.x, + rect.origin.y, + rect.size.width, + rect.size.height, + ); + } + + const baseUrl = url.match(BASE_URL_REGEX); + const displayUrl = baseUrl !== null ? baseUrl[1] : url; + + const durationLabel = + finishTimestamp !== 0 + ? `${formatDuration(finishTimestamp - sendRequestTimestamp)} - ` + : ''; + + const label = durationLabel + displayUrl; + + drawText(label, context, outerRect, visibleArea); + } + + draw(context: CanvasRenderingContext2D) { + const { + frame, + _profilerData: {networkMeasures}, + _hoveredNetworkMeasure, + visibleArea, + } = this; + + context.fillStyle = COLORS.PRIORITY_BACKGROUND; + context.fillRect( + visibleArea.origin.x, + visibleArea.origin.y, + visibleArea.size.width, + visibleArea.size.height, + ); + + const scaleFactor = positioningScaleFactor( + this._intrinsicSize.width, + frame, + ); + + networkMeasures.forEach(networkMeasure => { + this._drawSingleNetworkMeasure( + context, + networkMeasure, + frame.origin.y, + scaleFactor, + networkMeasure === _hoveredNetworkMeasure, + ); + }); + + // 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 networkMeasuresAtDepth = this._depthToNetworkMeasure.get(depth); + + const duration = this._profilerData.duration; + + if (networkMeasuresAtDepth) { + // Find the event being hovered over. + for (let index = networkMeasuresAtDepth.length - 1; index >= 0; index--) { + const networkMeasure = networkMeasuresAtDepth[index]; + const { + finishTimestamp, + lastReceivedDataTimestamp, + sendRequestTimestamp, + } = networkMeasure; + + const timestampBegin = sendRequestTimestamp; + const timestampEnd = + finishTimestamp || lastReceivedDataTimestamp || duration; + + if ( + hoverTimestamp >= timestampBegin && + hoverTimestamp <= timestampEnd + ) { + this.currentCursor = 'context-menu'; + viewRefs.hoveredView = this; + onHover(networkMeasure); + return; + } + } + } + + if (viewRefs.hoveredView === this) { + viewRefs.hoveredView = null; + } + + onHover(null); + } + + handleInteraction(interaction: Interaction, viewRefs: ViewRefs) { + switch (interaction.type) { + case 'mousemove': + this._handleMouseMove(interaction, viewRefs); + break; + } + } +} diff --git a/packages/react-devtools-scheduling-profiler/src/content-views/constants.js b/packages/react-devtools-scheduling-profiler/src/content-views/constants.js index 338ca1e5d163f..48ba22aed30ae 100644 --- a/packages/react-devtools-scheduling-profiler/src/content-views/constants.js +++ b/packages/react-devtools-scheduling-profiler/src/content-views/constants.js @@ -7,6 +7,7 @@ * @flow */ +export const DPR: number = window.devicePixelRatio || 1; export const LABEL_SIZE = 80; export const MARKER_HEIGHT = 20; export const MARKER_TICK_HEIGHT = 8; @@ -20,7 +21,7 @@ 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 = 14; -export const BORDER_SIZE = 1; +export const BORDER_SIZE = 1 / DPR; export const FLAMECHART_FRAME_HEIGHT = 14; export const TEXT_PADDING = 3; export const SNAPSHOT_HEIGHT = 35; @@ -46,6 +47,10 @@ export let COLORS = { BACKGROUND: '', NATIVE_EVENT: '', NATIVE_EVENT_HOVER: '', + NETWORK_PRIMARY: '', + NETWORK_PRIMARY_HOVER: '', + NETWORK_SECONDARY: '', + NETWORK_SECONDARY_HOVER: '', PRIORITY_BACKGROUND: '', PRIORITY_BORDER: '', PRIORITY_LABEL: '', @@ -106,6 +111,18 @@ export function updateColorsToMatchTheme(element: Element): boolean { NATIVE_EVENT_HOVER: computedStyle.getPropertyValue( '--color-scheduling-profiler-native-event-hover', ), + NETWORK_PRIMARY: computedStyle.getPropertyValue( + '--color-scheduling-profiler-network-primary', + ), + NETWORK_PRIMARY_HOVER: computedStyle.getPropertyValue( + '--color-scheduling-profiler-network-primary-hover', + ), + NETWORK_SECONDARY: computedStyle.getPropertyValue( + '--color-scheduling-profiler-network-secondary', + ), + NETWORK_SECONDARY_HOVER: computedStyle.getPropertyValue( + '--color-scheduling-profiler-network-secondary-hover', + ), PRIORITY_BACKGROUND: computedStyle.getPropertyValue( '--color-scheduling-profiler-priority-background', ), diff --git a/packages/react-devtools-scheduling-profiler/src/content-views/index.js b/packages/react-devtools-scheduling-profiler/src/content-views/index.js index 91ab47bfd46ce..21c8cb570b35c 100644 --- a/packages/react-devtools-scheduling-profiler/src/content-views/index.js +++ b/packages/react-devtools-scheduling-profiler/src/content-views/index.js @@ -10,6 +10,7 @@ export * from './ComponentMeasuresView'; export * from './FlamechartView'; export * from './NativeEventsView'; +export * from './NetworkMeasuresView'; export * from './ReactMeasuresView'; export * from './SchedulingEventsView'; export * from './SnapshotsView'; diff --git a/packages/react-devtools-scheduling-profiler/src/content-views/utils/text.js b/packages/react-devtools-scheduling-profiler/src/content-views/utils/text.js index 4cd7d94821589..9351d1685ff7a 100644 --- a/packages/react-devtools-scheduling-profiler/src/content-views/utils/text.js +++ b/packages/react-devtools-scheduling-profiler/src/content-views/utils/text.js @@ -32,14 +32,34 @@ export function trimText( 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) + '…'; + const maxIndex = text.length - 1; + + let startIndex = 0; + let stopIndex = maxIndex; + + let longestValidIndex = 0; + let longestValidText = null; + + // Trimming long text could be really slow if we decrease only 1 character at a time. + // Trimming with more of a binary search approach is faster in the worst cases. + while (startIndex <= stopIndex) { + const currentIndex = Math.floor((startIndex + stopIndex) / 2); + const trimmedText = + currentIndex === maxIndex ? text : text.substr(0, currentIndex) + '…'; + if (getTextWidth(context, trimmedText) <= width) { - return trimmedText; + if (longestValidIndex < currentIndex) { + longestValidIndex = currentIndex; + longestValidText = trimmedText; + } + + startIndex = currentIndex + 1; + } else { + stopIndex = currentIndex - 1; } } - return null; + return longestValidText; } type TextConfig = {| diff --git a/packages/react-devtools-scheduling-profiler/src/import-worker/__tests__/preprocessData-test.internal.js b/packages/react-devtools-scheduling-profiler/src/import-worker/__tests__/preprocessData-test.internal.js index f5267e8d06aa1..3481068f471b2 100644 --- a/packages/react-devtools-scheduling-profiler/src/import-worker/__tests__/preprocessData-test.internal.js +++ b/packages/react-devtools-scheduling-profiler/src/import-worker/__tests__/preprocessData-test.internal.js @@ -324,6 +324,7 @@ describe('preprocessData', () => { 30 => Array [], }, "nativeEvents": Array [], + "networkMeasures": Array [], "otherUserTimingMarks": Array [], "reactVersion": "17.0.3", "schedulingEvents": Array [], @@ -530,6 +531,7 @@ describe('preprocessData', () => { 30 => Array [], }, "nativeEvents": Array [], + "networkMeasures": Array [], "otherUserTimingMarks": Array [], "reactVersion": "17.0.3", "schedulingEvents": Array [ @@ -715,6 +717,7 @@ describe('preprocessData', () => { 30 => Array [], }, "nativeEvents": Array [], + "networkMeasures": Array [], "otherUserTimingMarks": Array [ Object { "name": "__v3", @@ -1051,6 +1054,7 @@ describe('preprocessData', () => { 30 => Array [], }, "nativeEvents": Array [], + "networkMeasures": Array [], "otherUserTimingMarks": Array [ Object { "name": "__v3", diff --git a/packages/react-devtools-scheduling-profiler/src/import-worker/preprocessData.js b/packages/react-devtools-scheduling-profiler/src/import-worker/preprocessData.js index 9d6e56035ab88..a7545ef1b7a18 100644 --- a/packages/react-devtools-scheduling-profiler/src/import-worker/preprocessData.js +++ b/packages/react-devtools-scheduling-profiler/src/import-worker/preprocessData.js @@ -17,6 +17,7 @@ import type { Flamechart, Milliseconds, NativeEvent, + NetworkMeasure, Phase, ReactLane, ReactComponentMeasure, @@ -51,6 +52,7 @@ type ProcessorState = {| potentialSuspenseEventsOutsideOfTransition: Array< [SuspenseEvent, ReactLane[]], >, + requestIdToNetworkMeasureMap: Map, uidCounter: BatchUID, unresolvedSuspenseEvents: Map, |}; @@ -215,6 +217,205 @@ function throwIfIncomplete( } } +function processEventDispatch( + event: TimelineEvent, + timestamp: Milliseconds, + profilerData: ReactProfilerData, + state: ProcessorState, +) { + const data = event.args.data; + const type = data.type; + + if (type.startsWith('react-')) { + const stackTrace = data.stackTrace; + if (stackTrace) { + const topFrame = stackTrace[stackTrace.length - 1]; + if (topFrame.url.includes('/react-dom.')) { + // Filter out fake React events dispatched by invokeGuardedCallbackDev. + return; + } + } + } + + // Reduce noise from events like DOMActivate, load/unload, etc. which are usually not relevant + if ( + type === 'blur' || + type === 'click' || + type === 'input' || + type.startsWith('focus') || + type.startsWith('key') || + type.startsWith('mouse') || + type.startsWith('pointer') + ) { + const duration = event.dur / 1000; + + let depth = 0; + + 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 { + state.nativeEventStack.pop(); + } + } + + const nativeEvent = { + depth, + duration, + timestamp, + type, + warning: null, + }; + + profilerData.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); + } +} + +function processResourceFinish( + event: TimelineEvent, + timestamp: Milliseconds, + profilerData: ReactProfilerData, + state: ProcessorState, +) { + const requestId = event.args.data.requestId; + const networkMeasure = state.requestIdToNetworkMeasureMap.get(requestId); + if (networkMeasure != null) { + networkMeasure.finishTimestamp = timestamp; + if (networkMeasure.firstReceivedDataTimestamp === 0) { + networkMeasure.firstReceivedDataTimestamp = timestamp; + } + if (networkMeasure.lastReceivedDataTimestamp === 0) { + networkMeasure.lastReceivedDataTimestamp = timestamp; + } + + // Clean up now that the resource is done. + state.requestIdToNetworkMeasureMap.delete(event.args.data.requestId); + } +} + +function processResourceReceivedData( + event: TimelineEvent, + timestamp: Milliseconds, + profilerData: ReactProfilerData, + state: ProcessorState, +) { + const requestId = event.args.data.requestId; + const networkMeasure = state.requestIdToNetworkMeasureMap.get(requestId); + if (networkMeasure != null) { + if (networkMeasure.firstReceivedDataTimestamp === 0) { + networkMeasure.firstReceivedDataTimestamp = timestamp; + } + networkMeasure.lastReceivedDataTimestamp = timestamp; + networkMeasure.finishTimestamp = timestamp; + } +} + +function processResourceReceiveResponse( + event: TimelineEvent, + timestamp: Milliseconds, + profilerData: ReactProfilerData, + state: ProcessorState, +) { + const requestId = event.args.data.requestId; + const networkMeasure = state.requestIdToNetworkMeasureMap.get(requestId); + if (networkMeasure != null) { + networkMeasure.receiveResponseTimestamp = timestamp; + } +} + +function processScreenshot( + event: TimelineEvent, + timestamp: Milliseconds, + profilerData: ReactProfilerData, + state: ProcessorState, +) { + const encodedSnapshot = event.args.snapshot; // Base 64 encoded + + const snapshot = { + height: 0, + image: null, + imageSource: `data:image/png;base64,${encodedSnapshot}`, + timestamp, + width: 0, + }; + + // Delay processing until we've extracted snapshot dimensions. + let resolveFn = ((null: any): Function); + state.asyncProcessingPromises.push( + new Promise(resolve => { + resolveFn = resolve; + }), + ); + + // Parse the Base64 image data to determine native size. + // This will be used later to scale for display within the thumbnail strip. + fetch(snapshot.imageSource) + .then(response => response.blob()) + .then(blob => { + // $FlowFixMe createImageBitmap + createImageBitmap(blob).then(bitmap => { + snapshot.height = bitmap.height; + snapshot.width = bitmap.width; + + resolveFn(); + }); + }); + + profilerData.snapshots.push(snapshot); +} + +function processResourceSendRequest( + event: TimelineEvent, + timestamp: Milliseconds, + profilerData: ReactProfilerData, + state: ProcessorState, +) { + const data = event.args.data; + const requestId = data.requestId; + + const availableDepths = new Array( + state.requestIdToNetworkMeasureMap.size + 1, + ).fill(true); + state.requestIdToNetworkMeasureMap.forEach(({depth}) => { + availableDepths[depth] = false; + }); + + let depth = 0; + for (let i = 0; i < availableDepths.length; i++) { + if (availableDepths[i]) { + depth = i; + break; + } + } + + const networkMeasure: NetworkMeasure = { + depth, + finishTimestamp: 0, + firstReceivedDataTimestamp: 0, + lastReceivedDataTimestamp: 0, + requestId, + requestMethod: data.requestMethod, + priority: data.priority, + sendRequestTimestamp: timestamp, + receiveResponseTimestamp: 0, + url: data.url, + }; + + state.requestIdToNetworkMeasureMap.set(requestId, networkMeasure); + + profilerData.networkMeasures.push(networkMeasure); + networkMeasure.sendRequestTimestamp = timestamp; +} + function processTimelineEvent( event: TimelineEvent, /** Finalized profiler data up to `event`. May be mutated. */ @@ -222,106 +423,49 @@ function processTimelineEvent( /** Intermediate processor state. May be mutated. */ state: ProcessorState, ) { - const {args, cat, name, ts, ph} = event; - switch (cat) { - case 'disabled-by-default-devtools.screenshot': - const encodedSnapshot = args.snapshot; // Base 64 encoded - - const snapshot = { - height: 0, - image: null, - imageSource: `data:image/png;base64,${encodedSnapshot}`, - timestamp: (ts - currentProfilerData.startTime) / 1000, - width: 0, - }; - - // Delay processing until we've extracted snapshot dimensions. - let resolveFn = ((null: any): Function); - state.asyncProcessingPromises.push( - new Promise(resolve => { - resolveFn = resolve; - }), - ); + const {cat, name, ts, ph} = event; - // Parse the Base64 image data to determine native size. - // This will be used later to scale for display within the thumbnail strip. - fetch(snapshot.imageSource) - .then(response => response.blob()) - .then(blob => { - // $FlowFixMe createImageBitmap - createImageBitmap(blob).then(bitmap => { - snapshot.height = bitmap.height; - snapshot.width = bitmap.width; - - resolveFn(); - }); - }); + const startTime = (ts - currentProfilerData.startTime) / 1000; - currentProfilerData.snapshots.push(snapshot); + switch (cat) { + case 'disabled-by-default-devtools.screenshot': + processScreenshot(event, startTime, currentProfilerData, state); break; case 'devtools.timeline': - if (name === 'EventDispatch') { - const type = args.data.type; - - if (type.startsWith('react-')) { - const stackTrace = args.data.stackTrace; - if (stackTrace) { - const topFrame = stackTrace[stackTrace.length - 1]; - if (topFrame.url.includes('/react-dom.')) { - // Filter out fake React events dispatched by invokeGuardedCallbackDev. - return; - } - } - } - - // Reduce noise from events like DOMActivate, load/unload, etc. which are usually not relevant - if ( - type === 'blur' || - type === 'click' || - type === 'input' || - type.startsWith('focus') || - type.startsWith('key') || - type.startsWith('mouse') || - type.startsWith('pointer') - ) { - const timestamp = (ts - currentProfilerData.startTime) / 1000; - const duration = event.dur / 1000; - - let depth = 0; - - 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 { - 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); - } + switch (name) { + case 'EventDispatch': + processEventDispatch(event, startTime, currentProfilerData, state); + break; + case 'ResourceFinish': + processResourceFinish(event, startTime, currentProfilerData, state); + break; + case 'ResourceReceivedData': + processResourceReceivedData( + event, + startTime, + currentProfilerData, + state, + ); + break; + case 'ResourceReceiveResponse': + processResourceReceiveResponse( + event, + startTime, + currentProfilerData, + state, + ); + break; + case 'ResourceSendRequest': + processResourceSendRequest( + event, + startTime, + currentProfilerData, + state, + ); + break; } break; case 'blink.user_timing': - const startTime = (ts - currentProfilerData.startTime) / 1000; - if (name.startsWith('--react-version-')) { const [reactVersion] = name.substr(16).split('-'); currentProfilerData.reactVersion = reactVersion; @@ -714,6 +858,7 @@ export default async function preprocessData( laneToLabelMap: new Map(), laneToReactMeasureMap, nativeEvents: [], + networkMeasures: [], otherUserTimingMarks: [], reactVersion: null, schedulingEvents: [], @@ -759,6 +904,7 @@ export default async function preprocessData( potentialLongNestedUpdate: null, potentialLongNestedUpdates: [], potentialSuspenseEventsOutsideOfTransition: [], + requestIdToNetworkMeasureMap: new Map(), uidCounter: 0, unresolvedSuspenseEvents: new Map(), }; diff --git a/packages/react-devtools-scheduling-profiler/src/types.js b/packages/react-devtools-scheduling-profiler/src/types.js index 637d95714106f..073f4e1fbff10 100644 --- a/packages/react-devtools-scheduling-profiler/src/types.js +++ b/packages/react-devtools-scheduling-profiler/src/types.js @@ -91,6 +91,19 @@ export type ReactMeasure = {| +depth: number, |}; +export type NetworkMeasure = {| + +depth: number, + finishTimestamp: Milliseconds, + firstReceivedDataTimestamp: Milliseconds, + lastReceivedDataTimestamp: Milliseconds, + priority: string, + receiveResponseTimestamp: Milliseconds, + +requestId: string, + requestMethod: string, + sendRequestTimestamp: Milliseconds, + url: string, +|}; + export type ReactComponentMeasure = {| +componentName: string, duration: Milliseconds, @@ -155,6 +168,7 @@ export type ReactProfilerData = {| laneToLabelMap: Map, laneToReactMeasureMap: Map, nativeEvents: NativeEvent[], + networkMeasures: NetworkMeasure[], otherUserTimingMarks: UserTimingMark[], reactVersion: string | null, schedulingEvents: SchedulingEvent[], @@ -165,10 +179,10 @@ export type ReactProfilerData = {| export type ReactHoverContextInfo = {| componentMeasure: ReactComponentMeasure | null, - data: $ReadOnly | null, flamechartStackFrame: FlamechartStackFrame | null, measure: ReactMeasure | null, nativeEvent: NativeEvent | null, + networkMeasure: NetworkMeasure | null, schedulingEvent: SchedulingEvent | null, suspenseEvent: SuspenseEvent | null, snapshot: Snapshot | null, diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/HorizontalPanAndZoomView.js b/packages/react-devtools-scheduling-profiler/src/view-base/HorizontalPanAndZoomView.js index 5519599f12dcc..2a998493eda1b 100644 --- a/packages/react-devtools-scheduling-profiler/src/view-base/HorizontalPanAndZoomView.js +++ b/packages/react-devtools-scheduling-profiler/src/view-base/HorizontalPanAndZoomView.js @@ -156,7 +156,7 @@ export class HorizontalPanAndZoomView extends View { interaction.payload.location, this.frame, ); - if (isHovered) { + if (isHovered && viewRefs.hoveredView === null) { viewRefs.hoveredView = this; } @@ -169,9 +169,16 @@ export class HorizontalPanAndZoomView extends View { if (!this._isPanning) { return; } + + // Don't prevent mouse-move events from bubbling if they are vertical drags. + const {movementX, movementY} = interaction.payload.event; + if (Math.abs(movementX) < Math.abs(movementY)) { + return; + } + const newState = translateState({ state: this._viewState.horizontalScrollState, - delta: interaction.payload.event.movementX, + delta: movementX, containerLength: this.frame.size.width, }); this._viewState.updateHorizontalScrollState(newState); diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/Surface.js b/packages/react-devtools-scheduling-profiler/src/view-base/Surface.js index 6cdaff1c6e758..2a5774db3197b 100644 --- a/packages/react-devtools-scheduling-profiler/src/view-base/Surface.js +++ b/packages/react-devtools-scheduling-profiler/src/view-base/Surface.js @@ -7,6 +7,7 @@ * @flow */ +import type {ReactHoverContextInfo} from '../types'; import type {Interaction} from './useCanvasInteraction'; import type {Size} from './geometry'; @@ -14,6 +15,7 @@ import memoize from 'memoize-one'; import {View} from './View'; import {zeroPoint} from './geometry'; +import {DPR} from '../content-views/constants'; export type ViewRefs = {| activeView: View | null, @@ -22,12 +24,10 @@ export type ViewRefs = {| // hidpi canvas: https://www.html5rocks.com/en/tutorials/canvas/hidpi/ function configureRetinaCanvas(canvas, height, width) { - const dpr: number = window.devicePixelRatio || 1; - canvas.width = width * dpr; - canvas.height = height * dpr; + canvas.width = width * DPR; + canvas.height = height * DPR; canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; - return dpr; } const getCanvasContext = memoize( @@ -39,14 +39,19 @@ const getCanvasContext = memoize( ): CanvasRenderingContext2D => { const context = canvas.getContext('2d', {alpha: false}); if (scaleCanvas) { - const dpr = configureRetinaCanvas(canvas, height, width); + configureRetinaCanvas(canvas, height, width); + // Scale all drawing operations by the dpr, so you don't have to worry about the difference. - context.scale(dpr, dpr); + context.scale(DPR, DPR); } return context; }, ); +type ResetHoveredEventFn = ( + partialState: $Shape, +) => void; + /** * Represents the canvas surface and a view heirarchy. A surface is also the * place where all interactions enter the view heirarchy. @@ -57,11 +62,17 @@ export class Surface { _context: ?CanvasRenderingContext2D; _canvasSize: ?Size; + _resetHoveredEvent: ResetHoveredEventFn; + _viewRefs: ViewRefs = { activeView: null, hoveredView: null, }; + constructor(resetHoveredEvent: ResetHoveredEventFn) { + this._resetHoveredEvent = resetHoveredEvent; + } + hasActiveView(): boolean { return this._viewRefs.activeView !== null; } @@ -107,12 +118,32 @@ export class Surface { } handleInteraction(interaction: Interaction) { - if (!this.rootView) { - return; + const rootView = this.rootView; + if (rootView != null) { + const viewRefs = this._viewRefs; + switch (interaction.type) { + case 'mousemove': + // Clean out the hovered view before processing a new mouse move interaction. + const hoveredView = viewRefs.hoveredView; + viewRefs.hoveredView = null; + + rootView.handleInteractionAndPropagateToSubviews( + interaction, + viewRefs, + ); + + // If a previously hovered view is no longer hovered, update the outer state. + if (hoveredView !== null && viewRefs.hoveredView === null) { + this._resetHoveredEvent({}); + } + break; + default: + rootView.handleInteractionAndPropagateToSubviews( + interaction, + viewRefs, + ); + break; + } } - this.rootView.handleInteractionAndPropagateToSubviews( - interaction, - this._viewRefs, - ); } } diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/VerticalScrollView.js b/packages/react-devtools-scheduling-profiler/src/view-base/VerticalScrollView.js index a78e88425faa2..d54907da1a8db 100644 --- a/packages/react-devtools-scheduling-profiler/src/view-base/VerticalScrollView.js +++ b/packages/react-devtools-scheduling-profiler/src/view-base/VerticalScrollView.js @@ -199,12 +199,20 @@ export class VerticalScrollView extends View { if (!this._isPanning) { return; } + + // Don't prevent mouse-move events from bubbling if they are horizontal drags. + const {movementX, movementY} = interaction.payload.event; + if (Math.abs(movementX) > Math.abs(movementY)) { + return; + } + const newState = translateState({ state: this._scrollState, delta: interaction.payload.event.movementY, containerLength: this.frame.size.height, }); this._setScrollState(newState); + return true; } @@ -255,12 +263,14 @@ export class VerticalScrollView extends View { } _setScrollState(proposedState: ScrollState): boolean { - const height = this._contentView.frame.size.height; + const contentHeight = this._contentView.frame.size.height; + const containerHeight = this.frame.size.height; + const clampedState = clampState({ state: proposedState, - minContentLength: height, - maxContentLength: height, - containerLength: this.frame.size.height, + minContentLength: contentHeight, + maxContentLength: contentHeight, + containerLength: containerHeight, }); if (!areScrollStatesEqual(clampedState, this._scrollState)) { this._scrollState.offset = clampedState.offset; @@ -275,6 +285,13 @@ export class VerticalScrollView extends View { return true; } - return false; + // Don't allow wheel events to bubble past this view even if we've scrolled to the edge. + // It just feels bad to have the scrolling jump unexpectedly from in a container to the outer page. + // The only exception is when the container fitst the contnet (no scrolling). + if (contentHeight === containerHeight) { + return false; + } + + return true; } } diff --git a/packages/react-devtools-shared/src/constants.js b/packages/react-devtools-shared/src/constants.js index 2476ce32839d3..04962c204e287 100644 --- a/packages/react-devtools-shared/src/constants.js +++ b/packages/react-devtools-shared/src/constants.js @@ -141,6 +141,10 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any} = { '--color-resize-bar-dot': '#333333', '--color-scheduling-profiler-native-event': '#ccc', '--color-scheduling-profiler-native-event-hover': '#aaa', + '--color-scheduling-profiler-network-primary': '#fcf3dc', + '--color-scheduling-profiler-network-primary-hover': '#f0e7d1', + '--color-scheduling-profiler-network-secondary': '#efc457', + '--color-scheduling-profiler-network-secondary-hover': '#e3ba52', '--color-scheduling-profiler-priority-background': '#f6f6f6', '--color-scheduling-profiler-priority-border': '#eeeeee', '--color-scheduling-profiler-user-timing': '#c9cacd', @@ -276,6 +280,10 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any} = { '--color-resize-bar-dot': '#cfd1d5', '--color-scheduling-profiler-native-event': '#b2b2b2', '--color-scheduling-profiler-native-event-hover': '#949494', + '--color-scheduling-profiler-network-primary': '#fcf3dc', + '--color-scheduling-profiler-network-primary-hover': '#e3dbc5', + '--color-scheduling-profiler-network-secondary': '#efc457', + '--color-scheduling-profiler-network-secondary-hover': '#d6af4d', '--color-scheduling-profiler-priority-background': '#1d2129', '--color-scheduling-profiler-priority-border': '#282c34', '--color-scheduling-profiler-user-timing': '#c9cacd',