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,
|};