From 241ab5ceac0e59af0d64a988551dfabb4ae07776 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Fri, 13 Aug 2021 12:36:40 -0700 Subject: [PATCH] Add screenshots to Scheduling Profiler --- .../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 + .../__tests__/preprocessData-test.internal.js | 80 +++---- .../src/import-worker/importFile.js | 4 +- .../src/import-worker/preprocessData.js | 47 +++- .../src/types.js | 10 + 9 files changed, 375 insertions(+), 41 deletions(-) create mode 100644 packages/react-devtools-scheduling-profiler/src/content-views/SnapshotsView.js 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/__tests__/preprocessData-test.internal.js b/packages/react-devtools-scheduling-profiler/src/import-worker/__tests__/preprocessData-test.internal.js index 0fa434b9489da..f5267e8d06aa1 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 @@ -17,7 +17,7 @@ import { } from '../../constants'; import REACT_VERSION from 'shared/ReactVersion'; -describe(getLanesFromTransportDecimalBitmask, () => { +describe('getLanesFromTransportDecimalBitmask', () => { it('should return array of lane numbers from bitmask string', () => { expect(getLanesFromTransportDecimalBitmask('1')).toEqual([0]); expect(getLanesFromTransportDecimalBitmask('512')).toEqual([9]); @@ -57,7 +57,7 @@ describe(getLanesFromTransportDecimalBitmask, () => { }); }); -describe(preprocessData, () => { +describe('preprocessData', () => { let React; let ReactDOM; let Scheduler; @@ -217,11 +217,11 @@ describe(preprocessData, () => { delete global.performance; }); - it('should throw given an empty timeline', () => { - expect(() => preprocessData([])).toThrow(); + it('should throw given an empty timeline', async () => { + await expect(async () => preprocessData([])).rejects.toThrow(); }); - it('should throw given a timeline with no Profile event', () => { + it('should throw given a timeline with no Profile event', async () => { const randomSample = createUserTimingEntry({ dur: 100, tdur: 200, @@ -231,10 +231,10 @@ describe(preprocessData, () => { args: {}, }); - expect(() => preprocessData([randomSample])).toThrow(); + await expect(async () => preprocessData([randomSample])).rejects.toThrow(); }); - it('should return empty data given a timeline with no React scheduling profiling marks', () => { + it('should return empty data given a timeline with no React scheduling profiling marks', async () => { const cpuProfilerSample = creactCpuProfilerSample(); const randomSample = createUserTimingEntry({ dur: 100, @@ -246,7 +246,7 @@ describe(preprocessData, () => { }); if (gate(flags => flags.enableSchedulingProfiler)) { - const data = preprocessData([ + const data = await preprocessData([ ...createBoilerplateEntries(), cpuProfilerSample, randomSample, @@ -327,6 +327,7 @@ describe(preprocessData, () => { "otherUserTimingMarks": Array [], "reactVersion": "17.0.3", "schedulingEvents": Array [], + "snapshots": Array [], "startTime": 1, "suspenseEvents": Array [], } @@ -334,13 +335,13 @@ describe(preprocessData, () => { } }); - it('should process legacy data format (before lane labels were added)', () => { + it('should process legacy data format (before lane labels were added)', async () => { const cpuProfilerSample = creactCpuProfilerSample(); if (gate(flags => flags.enableSchedulingProfiler)) { // Data below is hard-coded based on an older profile sample. // Should be fine since this is explicitly a legacy-format test. - const data = preprocessData([ + const data = await preprocessData([ ...createBoilerplateEntries(), cpuProfilerSample, createUserTimingEntry({ @@ -541,6 +542,7 @@ describe(preprocessData, () => { "warning": null, }, ], + "snapshots": Array [], "startTime": 1, "suspenseEvents": Array [], } @@ -548,11 +550,11 @@ describe(preprocessData, () => { } }); - it('should process a sample legacy render sequence', () => { + it('should process a sample legacy render sequence', async () => { ReactDOM.render(
, document.createElement('div')); if (gate(flags => flags.enableSchedulingProfiler)) { - const data = preprocessData([ + const data = await preprocessData([ ...createBoilerplateEntries(), ...createUserTimingData(clearedMarks), ]); @@ -730,6 +732,7 @@ describe(preprocessData, () => { "warning": null, }, ], + "snapshots": Array [], "startTime": 4, "suspenseEvents": Array [], } @@ -737,7 +740,7 @@ describe(preprocessData, () => { } }); - it('should process a sample createRoot render sequence', () => { + it('should process a sample createRoot render sequence', async () => { function App() { const [didMount, setDidMount] = React.useState(false); React.useEffect(() => { @@ -752,7 +755,7 @@ describe(preprocessData, () => { const root = ReactDOM.createRoot(document.createElement('div')); act(() => root.render()); - const data = preprocessData([ + const data = await preprocessData([ ...createBoilerplateEntries(), ...createUserTimingData(clearedMarks), ]); @@ -1074,6 +1077,7 @@ describe(preprocessData, () => { "warning": null, }, ], + "snapshots": Array [], "startTime": 4, "suspenseEvents": Array [], } @@ -1082,7 +1086,7 @@ describe(preprocessData, () => { }); // @gate enableSchedulingProfiler - it('should error if events and measures are incomplete', () => { + it('should error if events and measures are incomplete', async () => { const container = document.createElement('div'); ReactDOM.render(
, container); @@ -1097,7 +1101,7 @@ describe(preprocessData, () => { }); // @gate enableSchedulingProfiler - it('should error if work is completed without being started', () => { + it('should error if work is completed without being started', async () => { const container = document.createElement('div'); ReactDOM.render(
, container); @@ -1111,7 +1115,7 @@ describe(preprocessData, () => { expect(error).toHaveBeenCalled(); }); - it('should populate other user timing marks', () => { + it('should populate other user timing marks', async () => { const userTimingData = createUserTimingData([]); userTimingData.push( createUserTimingEntry({ @@ -1138,7 +1142,7 @@ describe(preprocessData, () => { }), ); - const data = preprocessData([ + const data = await preprocessData([ ...createBoilerplateEntries(), ...userTimingData, ]); @@ -1162,7 +1166,7 @@ describe(preprocessData, () => { describe('warnings', () => { describe('long event handlers', () => { - it('should not warn when React scedules a (sync) update inside of a short event handler', () => { + it('should not warn when React scedules a (sync) update inside of a short event handler', async () => { function App() { return null; } @@ -1180,13 +1184,13 @@ describe(preprocessData, () => { testMarks.push(...createUserTimingData(clearedMarks)); - const data = preprocessData(testMarks); + const data = await preprocessData(testMarks); const event = data.nativeEvents.find(({type}) => type === 'click'); expect(event.warning).toBe(null); } }); - it('should not warn about long events if the cause was non-React JavaScript', () => { + it('should not warn about long events if the cause was non-React JavaScript', async () => { function App() { return null; } @@ -1206,13 +1210,13 @@ describe(preprocessData, () => { testMarks.push(...createUserTimingData(clearedMarks)); - const data = preprocessData(testMarks); + const data = await preprocessData(testMarks); const event = data.nativeEvents.find(({type}) => type === 'click'); expect(event.warning).toBe(null); } }); - it('should warn when React scedules a long (sync) update inside of an event', () => { + it('should warn when React scedules a long (sync) update inside of an event', async () => { function App() { return null; } @@ -1245,7 +1249,7 @@ describe(preprocessData, () => { }); }); - const data = preprocessData(testMarks); + const data = await preprocessData(testMarks); const event = data.nativeEvents.find(({type}) => type === 'click'); expect(event.warning).toMatchInlineSnapshot( `"An event handler scheduled a big update with React. Consider using the Transition API to defer some of this work."`, @@ -1253,7 +1257,7 @@ describe(preprocessData, () => { } }); - it('should not warn when React finishes a previously long (async) update with a short (sync) update inside of an event', () => { + it('should not warn when React finishes a previously long (async) update with a short (sync) update inside of an event', async () => { function Yield({id, value}) { Scheduler.unstable_yieldValue(`${id}:${value}`); return null; @@ -1303,7 +1307,7 @@ describe(preprocessData, () => { testMarks.push(...createUserTimingData(clearedMarks)); - const data = preprocessData(testMarks); + const data = await preprocessData(testMarks); const event = data.nativeEvents.find(({type}) => type === 'click'); expect(event.warning).toBe(null); } @@ -1311,7 +1315,7 @@ describe(preprocessData, () => { }); describe('nested updates', () => { - it('should not warn about short nested (state) updates during layout effects', () => { + it('should not warn about short nested (state) updates during layout effects', async () => { function Component() { const [didMount, setDidMount] = React.useState(false); Scheduler.unstable_yieldValue( @@ -1334,7 +1338,7 @@ describe(preprocessData, () => { 'Component update', ]); - const data = preprocessData([ + const data = await preprocessData([ ...createBoilerplateEntries(), ...createUserTimingData(clearedMarks), ]); @@ -1346,7 +1350,7 @@ describe(preprocessData, () => { } }); - it('should not warn about short (forced) updates during layout effects', () => { + it('should not warn about short (forced) updates during layout effects', async () => { class Component extends React.Component { _didMount: boolean = false; componentDidMount() { @@ -1372,7 +1376,7 @@ describe(preprocessData, () => { 'Component update', ]); - const data = preprocessData([ + const data = await preprocessData([ ...createBoilerplateEntries(), ...createUserTimingData(clearedMarks), ]); @@ -1384,7 +1388,7 @@ describe(preprocessData, () => { } }); - it('should warn about long nested (state) updates during layout effects', () => { + it('should warn about long nested (state) updates during layout effects', async () => { function Component() { const [didMount, setDidMount] = React.useState(false); Scheduler.unstable_yieldValue( @@ -1429,7 +1433,7 @@ describe(preprocessData, () => { }); }); - const data = preprocessData([ + const data = await preprocessData([ cpuProfilerSample, ...createBoilerplateEntries(), ...testMarks, @@ -1444,7 +1448,7 @@ describe(preprocessData, () => { } }); - it('should warn about long nested (forced) updates during layout effects', () => { + it('should warn about long nested (forced) updates during layout effects', async () => { class Component extends React.Component { _didMount: boolean = false; componentDidMount() { @@ -1490,7 +1494,7 @@ describe(preprocessData, () => { }); }); - const data = preprocessData([ + const data = await preprocessData([ cpuProfilerSample, ...createBoilerplateEntries(), ...testMarks, @@ -1509,7 +1513,7 @@ describe(preprocessData, () => { 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). - it('should warn about suspending during an udpate', () => { + it('should warn about suspending during an udpate', async () => { let promise = null; let resolvedValue = null; function readValue(value) { @@ -1557,7 +1561,7 @@ describe(preprocessData, () => { testMarks.push(...createUserTimingData(clearedMarks)); - const data = preprocessData(testMarks); + const data = await preprocessData(testMarks); expect(data.suspenseEvents).toHaveLength(1); expect(data.suspenseEvents[0].warning).toMatchInlineSnapshot( `"A component suspended during an update which caused a fallback to be shown. Consider using the Transition API to avoid hiding components after they've been mounted."`, @@ -1615,7 +1619,7 @@ describe(preprocessData, () => { testMarks.push(...createUserTimingData(clearedMarks)); - const data = preprocessData(testMarks); + const data = await preprocessData(testMarks); expect(data.suspenseEvents).toHaveLength(1); expect(data.suspenseEvents[0].warning).toBe(null); } @@ -1623,5 +1627,7 @@ describe(preprocessData, () => { }); }); + // TODO: Add test for snapshot base64 parsing + // TODO: Add test for flamechart parsing }); 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, |};