From 7e6fa152fd1437a91c5b332cbb4818377a14f331 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Fri, 13 Aug 2021 12:25:18 -0700 Subject: [PATCH] Add screenshots to Scheduling Profiler --- .../src/parseHookNames/parseHookNames.js | 15 +- .../src/CanvasPage.js | 44 ++++ .../src/EventTooltip.js | 21 ++ .../src/content-views/SnapshotsView.js | 208 ++++++++++++++++++ .../src/content-views/constants.js | 1 + .../src/content-views/index.js | 1 + .../src/import-worker/importFile.js | 4 +- .../src/import-worker/preprocessData.js | 47 +++- .../src/types.js | 10 + .../react-devtools-shared/src/encoding.js | 22 ++ 10 files changed, 355 insertions(+), 18 deletions(-) create mode 100644 packages/react-devtools-scheduling-profiler/src/content-views/SnapshotsView.js create mode 100644 packages/react-devtools-shared/src/encoding.js diff --git a/packages/react-devtools-extensions/src/parseHookNames/parseHookNames.js b/packages/react-devtools-extensions/src/parseHookNames/parseHookNames.js index edaf1a5e0ea1b..7050a1ffbe9fe 100644 --- a/packages/react-devtools-extensions/src/parseHookNames/parseHookNames.js +++ b/packages/react-devtools-extensions/src/parseHookNames/parseHookNames.js @@ -16,6 +16,7 @@ import {__DEBUG__} from 'react-devtools-shared/src/constants'; import {getHookSourceLocationKey} from 'react-devtools-shared/src/hookNamesCache'; import {decodeHookMap} from '../generateHookMap'; import {getHookNameForLocation} from '../getHookNameForLocation'; +import {decodeBase64String} from 'react-devtools-shared/src/encoding'; import type {MixedSourceMap} from '../SourceMapTypes'; import type {HookMap} from '../generateHookMap'; @@ -177,20 +178,6 @@ export async function parseHookNames( .then(() => findHookNames(hooksList, locationKeyToHookSourceData)); } -function decodeBase64String(encoded: string): Object { - if (typeof atob === 'function') { - return atob(encoded); - } else if ( - typeof Buffer !== 'undefined' && - Buffer !== null && - typeof Buffer.from === 'function' - ) { - return Buffer.from(encoded, 'base64'); - } else { - throw Error('Cannot decode base64 string'); - } -} - function extractAndLoadSourceMaps( locationKeyToHookSourceData: Map, wasmMappingsURL: string, diff --git a/packages/react-devtools-scheduling-profiler/src/CanvasPage.js b/packages/react-devtools-scheduling-profiler/src/CanvasPage.js index 2681eb7c876c1..83de9a2d2d598 100644 --- a/packages/react-devtools-scheduling-profiler/src/CanvasPage.js +++ b/packages/react-devtools-scheduling-profiler/src/CanvasPage.js @@ -46,6 +46,7 @@ import { NativeEventsView, ReactMeasuresView, SchedulingEventsView, + SnapshotsView, SuspenseEventsView, TimeAxisMarkersView, UserTimingMarksView, @@ -157,6 +158,7 @@ function AutoSizedCanvas({ const componentMeasuresViewRef = useRef(null); const reactMeasuresViewRef = useRef(null); const flamechartViewRef = useRef(null); + const snapshotsViewRef = useRef(null); const {hideMenu: hideContextMenu} = useContext(RegistryContext); @@ -304,6 +306,18 @@ function AutoSizedCanvas({ ); } + let snapshotsViewWrapper = null; + if (data.snapshots.length > 0) { + const snapshotsView = new SnapshotsView(surface, defaultFrame, data); + snapshotsViewRef.current = snapshotsView; + snapshotsViewWrapper = createViewHelper( + snapshotsView, + 'snapshots', + true, + true, + ); + } + const flamechartView = new FlamechartView( surface, defaultFrame, @@ -340,6 +354,9 @@ function AutoSizedCanvas({ if (componentMeasuresViewWrapper !== null) { rootView.addSubview(componentMeasuresViewWrapper); } + if (snapshotsViewWrapper !== null) { + rootView.addSubview(snapshotsViewWrapper); + } rootView.addSubview(flamechartViewWrapper); const verticalScrollOverflowView = new VerticalScrollOverflowView( @@ -389,6 +406,7 @@ function AutoSizedCanvas({ measure: null, nativeEvent: null, schedulingEvent: null, + snapshot: null, suspenseEvent: null, userTimingMark: null, }; @@ -447,6 +465,7 @@ function AutoSizedCanvas({ measure: null, nativeEvent: null, schedulingEvent: null, + snapshot: null, suspenseEvent: null, userTimingMark, }); @@ -465,6 +484,7 @@ function AutoSizedCanvas({ measure: null, nativeEvent, schedulingEvent: null, + snapshot: null, suspenseEvent: null, userTimingMark: null, }); @@ -483,6 +503,7 @@ function AutoSizedCanvas({ measure: null, nativeEvent: null, schedulingEvent, + snapshot: null, suspenseEvent: null, userTimingMark: null, }); @@ -501,6 +522,7 @@ function AutoSizedCanvas({ measure: null, nativeEvent: null, schedulingEvent: null, + snapshot: null, suspenseEvent, userTimingMark: null, }); @@ -519,6 +541,7 @@ function AutoSizedCanvas({ measure, nativeEvent: null, schedulingEvent: null, + snapshot: null, suspenseEvent: null, userTimingMark: null, }); @@ -540,6 +563,26 @@ function AutoSizedCanvas({ measure: null, nativeEvent: null, schedulingEvent: null, + snapshot: null, + suspenseEvent: null, + userTimingMark: null, + }); + } + }; + } + + const {current: snapshotsView} = snapshotsViewRef; + if (snapshotsView) { + snapshotsView.onHover = snapshot => { + if (!hoveredEvent || hoveredEvent.snapshot !== snapshot) { + setHoveredEvent({ + componentMeasure: null, + data, + flamechartStackFrame: null, + measure: null, + nativeEvent: null, + schedulingEvent: null, + snapshot, suspenseEvent: null, userTimingMark: null, }); @@ -561,6 +604,7 @@ function AutoSizedCanvas({ measure: null, nativeEvent: null, schedulingEvent: null, + snapshot: null, suspenseEvent: null, userTimingMark: null, }); diff --git a/packages/react-devtools-scheduling-profiler/src/EventTooltip.js b/packages/react-devtools-scheduling-profiler/src/EventTooltip.js index 8f3c2e2c0b1b7..df53f1a0ef68b 100644 --- a/packages/react-devtools-scheduling-profiler/src/EventTooltip.js +++ b/packages/react-devtools-scheduling-profiler/src/EventTooltip.js @@ -17,6 +17,7 @@ import type { ReactProfilerData, Return, SchedulingEvent, + Snapshot, SuspenseEvent, UserTimingMark, } from './types'; @@ -87,6 +88,7 @@ export default function EventTooltip({ measure, nativeEvent, schedulingEvent, + snapshot, suspenseEvent, userTimingMark, } = hoveredEvent; @@ -110,6 +112,8 @@ export default function EventTooltip({ tooltipRef={tooltipRef} /> ); + } else if (snapshot !== null) { + return ; } else if (suspenseEvent !== null) { return ( , +}) => { + return ( +
+ +
+ ); +}; + const TooltipSuspenseEvent = ({ suspenseEvent, tooltipRef, diff --git a/packages/react-devtools-scheduling-profiler/src/content-views/SnapshotsView.js b/packages/react-devtools-scheduling-profiler/src/content-views/SnapshotsView.js new file mode 100644 index 0000000000000..1d27ff89d62ec --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/content-views/SnapshotsView.js @@ -0,0 +1,208 @@ +/** + * 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 {Snapshot, ReactProfilerData} from '../types'; +import type { + Interaction, + MouseMoveInteraction, + Rect, + Size, + Surface, + ViewRefs, +} from '../view-base'; + +import {positioningScaleFactor, timestampToPosition} from './utils/positioning'; +import { + intersectionOfRects, + rectContainsPoint, + rectEqualToRect, + View, +} from '../view-base'; +import {BORDER_SIZE, COLORS, SNAPSHOT_HEIGHT} from './constants'; + +type OnHover = (node: Snapshot | null) => void; + +export class SnapshotsView extends View { + _intrinsicSize: Size; + _profilerData: ReactProfilerData; + + onHover: OnHover | null = null; + + constructor(surface: Surface, frame: Rect, profilerData: ReactProfilerData) { + super(surface, frame); + + this._intrinsicSize = { + width: profilerData.duration, + height: SNAPSHOT_HEIGHT, + }; + this._profilerData = profilerData; + } + + desiredSize() { + return this._intrinsicSize; + } + + draw(context: CanvasRenderingContext2D) { + const {visibleArea} = this; + + context.fillStyle = COLORS.BACKGROUND; + context.fillRect( + visibleArea.origin.x, + visibleArea.origin.y, + visibleArea.size.width, + visibleArea.size.height, + ); + + const y = visibleArea.origin.y; + + let x = visibleArea.origin.x; + + // Rather than drawing each snapshot where it occured, + // draw them at fixed intervals and just show the nearest one. + while (x < visibleArea.origin.x + visibleArea.size.width) { + const snapshot = this._findClosestSnapshot(x); + + const scaledHeight = SNAPSHOT_HEIGHT; + const scaledWidth = (snapshot.width * SNAPSHOT_HEIGHT) / snapshot.height; + + const imageRect: Rect = { + origin: { + x, + y, + }, + size: {width: scaledWidth, height: scaledHeight}, + }; + + // Lazily create and cache Image objects as we render a snapsho for the first time. + if (snapshot.image === null) { + const img = (snapshot.image = new Image()); + img.onload = () => { + this._drawSnapshotImage(context, snapshot, imageRect); + }; + img.src = snapshot.imageSource; + } else { + this._drawSnapshotImage(context, snapshot, imageRect); + } + + x += scaledWidth + BORDER_SIZE; + } + } + + handleInteraction(interaction: Interaction, viewRefs: ViewRefs) { + switch (interaction.type) { + case 'mousemove': + this._handleMouseMove(interaction, viewRefs); + break; + } + } + + _drawSnapshotImage( + context: CanvasRenderingContext2D, + snapshot: Snapshot, + imageRect: Rect, + ) { + const visibleArea = this.visibleArea; + + // Prevent snapshot from visibly overflowing its container when clipped. + const shouldClip = !rectEqualToRect(imageRect, visibleArea); + if (shouldClip) { + const clippedRect = intersectionOfRects(imageRect, visibleArea); + context.save(); + context.beginPath(); + context.rect( + clippedRect.origin.x, + clippedRect.origin.y, + clippedRect.size.width, + clippedRect.size.height, + ); + context.closePath(); + context.clip(); + } + + // $FlowFixMe Flow doesn't know about the 9 argument variant of drawImage() + context.drawImage( + snapshot.image, + + // Image coordinates + 0, + 0, + + // Native image size + snapshot.width, + snapshot.height, + + // Canvas coordinates + imageRect.origin.x, + imageRect.origin.y, + + // Scaled image size + imageRect.size.width, + imageRect.size.height, + ); + + if (shouldClip) { + context.restore(); + } + } + + _findClosestSnapshot(x: number): Snapshot { + const frame = this.frame; + const scaleFactor = positioningScaleFactor( + this._intrinsicSize.width, + frame, + ); + + const snapshots = this._profilerData.snapshots; + + let startIndex = 0; + let stopIndex = snapshots.length - 1; + while (startIndex <= stopIndex) { + const currentIndex = Math.floor((startIndex + stopIndex) / 2); + const snapshot = snapshots[currentIndex]; + const {timestamp} = snapshot; + + const snapshotX = Math.floor( + timestampToPosition(timestamp, scaleFactor, frame), + ); + + if (x < snapshotX) { + stopIndex = currentIndex - 1; + } else { + startIndex = currentIndex + 1; + } + } + + return snapshots[stopIndex]; + } + + /** + * @private + */ + _handleMouseMove(interaction: MouseMoveInteraction, viewRefs: ViewRefs) { + const {onHover, visibleArea} = this; + if (!onHover) { + return; + } + + const {location} = interaction.payload; + if (!rectContainsPoint(location, visibleArea)) { + onHover(null); + return; + } + + const snapshot = this._findClosestSnapshot(location.x); + if (snapshot) { + this.currentCursor = 'context-menu'; + viewRefs.hoveredView = this; + onHover(snapshot); + } else { + onHover(null); + } + } +} 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 6cda416ad8bb3..fe42bc16f1c8a 100644 --- a/packages/react-devtools-scheduling-profiler/src/content-views/constants.js +++ b/packages/react-devtools-scheduling-profiler/src/content-views/constants.js @@ -23,6 +23,7 @@ export const REACT_MEASURE_HEIGHT = 14; export const BORDER_SIZE = 1; export const FLAMECHART_FRAME_HEIGHT = 14; export const TEXT_PADDING = 3; +export const SNAPSHOT_HEIGHT = 50; export const INTERVAL_TIMES = [ 1, 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 fc1f4eabd4229..91ab47bfd46ce 100644 --- a/packages/react-devtools-scheduling-profiler/src/content-views/index.js +++ b/packages/react-devtools-scheduling-profiler/src/content-views/index.js @@ -12,6 +12,7 @@ export * from './FlamechartView'; export * from './NativeEventsView'; export * from './ReactMeasuresView'; export * from './SchedulingEventsView'; +export * from './SnapshotsView'; export * from './SuspenseEventsView'; export * from './TimeAxisMarkersView'; export * from './UserTimingMarksView'; diff --git a/packages/react-devtools-scheduling-profiler/src/import-worker/importFile.js b/packages/react-devtools-scheduling-profiler/src/import-worker/importFile.js index 1e0510b8539e0..61301aa331b37 100644 --- a/packages/react-devtools-scheduling-profiler/src/import-worker/importFile.js +++ b/packages/react-devtools-scheduling-profiler/src/import-worker/importFile.js @@ -26,9 +26,11 @@ export async function importFile(file: File): Promise { throw new InvalidProfileError('No profiling data found in file.'); } + const processedData = await preprocessData(events); + return { status: 'SUCCESS', - processedData: preprocessData(events), + processedData, }; } catch (error) { if (error instanceof InvalidProfileError) { 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 a356ad96885e9..9d6e56035ab88 100644 --- a/packages/react-devtools-scheduling-profiler/src/import-worker/preprocessData.js +++ b/packages/react-devtools-scheduling-profiler/src/import-worker/preprocessData.js @@ -26,7 +26,6 @@ import type { SchedulingEvent, SuspenseEvent, } from '../types'; - import {REACT_TOTAL_NUM_LANES, SCHEDULING_PROFILER_VERSION} from '../constants'; import InvalidProfileError from './InvalidProfileError'; import {getBatchRange} from '../utils/getBatchRange'; @@ -40,6 +39,7 @@ type MeasureStackElement = {| |}; type ProcessorState = {| + asyncProcessingPromises: Promise[], batchUID: BatchUID, currentReactComponentMeasure: ReactComponentMeasure | null, measureStack: MeasureStackElement[], @@ -224,6 +224,41 @@ function processTimelineEvent( ) { 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; + }), + ); + + // 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(); + }); + }); + + currentProfilerData.snapshots.push(snapshot); + break; case 'devtools.timeline': if (name === 'EventDispatch') { const type = args.data.type; @@ -661,9 +696,9 @@ function preprocessFlamechart(rawData: TimelineEvent[]): Flamechart { return flamechart; } -export default function preprocessData( +export default async function preprocessData( timeline: TimelineEvent[], -): ReactProfilerData { +): Promise { const flamechart = preprocessFlamechart(timeline); const laneToReactMeasureMap = new Map(); @@ -682,6 +717,7 @@ export default function preprocessData( otherUserTimingMarks: [], reactVersion: null, schedulingEvents: [], + snapshots: [], startTime: 0, suspenseEvents: [], }; @@ -713,6 +749,7 @@ export default function preprocessData( (timeline[timeline.length - 1].ts - profilerData.startTime) / 1000; const state: ProcessorState = { + asyncProcessingPromises: [], batchUID: 0, currentReactComponentMeasure: null, measureStack: [], @@ -773,5 +810,9 @@ export default function preprocessData( }, ); + // Wait for any async processing to complete before returning. + // Since processing is done in a worker, async work must complete before data is serialized and returned. + await Promise.all(state.asyncProcessingPromises); + return profilerData; } diff --git a/packages/react-devtools-scheduling-profiler/src/types.js b/packages/react-devtools-scheduling-profiler/src/types.js index 761033f2cdda6..637d95714106f 100644 --- a/packages/react-devtools-scheduling-profiler/src/types.js +++ b/packages/react-devtools-scheduling-profiler/src/types.js @@ -115,6 +115,14 @@ export type UserTimingMark = {| timestamp: Milliseconds, |}; +export type Snapshot = {| + height: number, + image: Image | null, + +imageSource: string, + +timestamp: Milliseconds, + width: number, +|}; + /** * 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. @@ -150,6 +158,7 @@ export type ReactProfilerData = {| otherUserTimingMarks: UserTimingMark[], reactVersion: string | null, schedulingEvents: SchedulingEvent[], + snapshots: Snapshot[], startTime: number, suspenseEvents: SuspenseEvent[], |}; @@ -162,5 +171,6 @@ export type ReactHoverContextInfo = {| nativeEvent: NativeEvent | null, schedulingEvent: SchedulingEvent | null, suspenseEvent: SuspenseEvent | null, + snapshot: Snapshot | null, userTimingMark: UserTimingMark | null, |}; diff --git a/packages/react-devtools-shared/src/encoding.js b/packages/react-devtools-shared/src/encoding.js new file mode 100644 index 0000000000000..b0be73e52e8bd --- /dev/null +++ b/packages/react-devtools-shared/src/encoding.js @@ -0,0 +1,22 @@ +/** + * 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 + */ + +export function decodeBase64String(encoded: string): Object { + if (typeof atob === 'function') { + return atob(encoded); + } else if ( + typeof Buffer !== 'undefined' && + Buffer !== null && + typeof Buffer.from === 'function' + ) { + return Buffer.from(encoded, 'base64'); + } else { + throw Error('Cannot decode base64 string'); + } +}