[DevTools] Add screenshots to Scheduling Profiler (#22088)
Brian Vaughn committed
Aug 13, 2021 at 17:14 UTC
54e170c79493dea734b95b0fddbf8f0bfc99bff7
9 files changed
+375
-41
packages/react-devtools-scheduling-profiler/src/CanvasPage.js
+44
@@ -46,6 +46,7 @@ import {
46
NativeEventsView,
47
ReactMeasuresView,
48
SchedulingEventsView,
49
+ SnapshotsView,
50
SuspenseEventsView,
51
TimeAxisMarkersView,
52
UserTimingMarksView,
@@ -157,6 +158,7 @@ function AutoSizedCanvas({
158
const componentMeasuresViewRef = useRef(null);
159
const reactMeasuresViewRef = useRef(null);
160
const flamechartViewRef = useRef(null);
161
+ const snapshotsViewRef = useRef(null);
162
163
const {hideMenu: hideContextMenu} = useContext(RegistryContext);
164
@@ -304,6 +306,18 @@ function AutoSizedCanvas({
306
);
307
}
308
309
+ let snapshotsViewWrapper = null;
310
+ if (data.snapshots.length > 0) {
311
+ const snapshotsView = new SnapshotsView(surface, defaultFrame, data);
312
+ snapshotsViewRef.current = snapshotsView;
313
+ snapshotsViewWrapper = createViewHelper(
314
+ snapshotsView,
315
+ 'snapshots',
316
+ true,
317
+ true,
318
+ );
319
+ }
320
+
321
const flamechartView = new FlamechartView(
322
surface,
323
defaultFrame,
@@ -340,6 +354,9 @@ function AutoSizedCanvas({
354
if (componentMeasuresViewWrapper !== null) {
355
rootView.addSubview(componentMeasuresViewWrapper);
356
}
357
+ if (snapshotsViewWrapper !== null) {
358
+ rootView.addSubview(snapshotsViewWrapper);
359
+ }
360
rootView.addSubview(flamechartViewWrapper);
361
362
const verticalScrollOverflowView = new VerticalScrollOverflowView(
@@ -389,6 +406,7 @@ function AutoSizedCanvas({
406
measure: null,
407
nativeEvent: null,
408
schedulingEvent: null,
409
+ snapshot: null,
410
suspenseEvent: null,
411
userTimingMark: null,
412
};
@@ -447,6 +465,7 @@ function AutoSizedCanvas({
465
measure: null,
466
nativeEvent: null,
467
schedulingEvent: null,
468
+ snapshot: null,
469
suspenseEvent: null,
470
userTimingMark,
471
});
@@ -465,6 +484,7 @@ function AutoSizedCanvas({
484
measure: null,
485
nativeEvent,
486
schedulingEvent: null,
487
+ snapshot: null,
488
suspenseEvent: null,
489
userTimingMark: null,
490
});
@@ -483,6 +503,7 @@ function AutoSizedCanvas({
503
measure: null,
504
nativeEvent: null,
505
schedulingEvent,
506
+ snapshot: null,
507
suspenseEvent: null,
508
userTimingMark: null,
509
});
@@ -501,6 +522,7 @@ function AutoSizedCanvas({
522
measure: null,
523
nativeEvent: null,
524
schedulingEvent: null,
525
+ snapshot: null,
526
suspenseEvent,
527
userTimingMark: null,
528
});
@@ -519,6 +541,7 @@ function AutoSizedCanvas({
541
measure,
542
nativeEvent: null,
543
schedulingEvent: null,
544
+ snapshot: null,
545
suspenseEvent: null,
546
userTimingMark: null,
547
});
@@ -540,6 +563,26 @@ function AutoSizedCanvas({
563
measure: null,
564
nativeEvent: null,
565
schedulingEvent: null,
566
+ snapshot: null,
567
+ suspenseEvent: null,
568
+ userTimingMark: null,
569
+ });
570
+ }
571
+ };
572
+ }
573
+
574
+ const {current: snapshotsView} = snapshotsViewRef;
575
+ if (snapshotsView) {
576
+ snapshotsView.onHover = snapshot => {
577
+ if (!hoveredEvent || hoveredEvent.snapshot !== snapshot) {
578
+ setHoveredEvent({
579
+ componentMeasure: null,
580
+ data,
581
+ flamechartStackFrame: null,
582
+ measure: null,
583
+ nativeEvent: null,
584
+ schedulingEvent: null,
585
+ snapshot,
586
suspenseEvent: null,
587
userTimingMark: null,
588
});
@@ -561,6 +604,7 @@ function AutoSizedCanvas({
604
measure: null,
605
nativeEvent: null,
606
schedulingEvent: null,
607
+ snapshot: null,
608
suspenseEvent: null,
609
userTimingMark: null,
610
});
packages/react-devtools-scheduling-profiler/src/EventTooltip.js
+21
@@ -17,6 +17,7 @@ import type {
17
ReactProfilerData,
18
Return,
19
SchedulingEvent,
20
+ Snapshot,
21
SuspenseEvent,
22
UserTimingMark,
23
} from './types';
@@ -87,6 +88,7 @@ export default function EventTooltip({
88
measure,
89
nativeEvent,
90
schedulingEvent,
91
+ snapshot,
92
suspenseEvent,
93
userTimingMark,
94
} = hoveredEvent;
@@ -110,6 +112,8 @@ export default function EventTooltip({
112
tooltipRef={tooltipRef}
113
/>
114
);
115
+ } else if (snapshot !== null) {
116
+ return <TooltipSnapshot snapshot={snapshot} tooltipRef={tooltipRef} />;
117
} else if (suspenseEvent !== null) {
118
return (
119
<TooltipSuspenseEvent
@@ -301,6 +305,23 @@ const TooltipSchedulingEvent = ({
305
);
306
};
307
308
+const TooltipSnapshot = ({
309
+ snapshot,
310
+ tooltipRef,
311
+}: {
312
+ snapshot: Snapshot,
313
+ tooltipRef: Return<typeof useRef>,
314
+}) => {
315
+ return (
316
+ <div className={styles.Tooltip} ref={tooltipRef}>
317
+ <img
318
+ src={snapshot.imageSource}
319
+ style={{width: snapshot.width / 2, height: snapshot.height / 2}}
320
+ />
321
+ </div>
322
+ );
323
+};
324
+
325
const TooltipSuspenseEvent = ({
326
suspenseEvent,
327
tooltipRef,
packages/react-devtools-scheduling-profiler/src/content-views/SnapshotsView.js
new
+208
@@ -0,0 +1,208 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+
10
+import type {Snapshot, ReactProfilerData} from '../types';
11
+import type {
12
+ Interaction,
13
+ MouseMoveInteraction,
14
+ Rect,
15
+ Size,
16
+ Surface,
17
+ ViewRefs,
18
+} from '../view-base';
19
+
20
+import {positioningScaleFactor, timestampToPosition} from './utils/positioning';
21
+import {
22
+ intersectionOfRects,
23
+ rectContainsPoint,
24
+ rectEqualToRect,
25
+ View,
26
+} from '../view-base';
27
+import {BORDER_SIZE, COLORS, SNAPSHOT_HEIGHT} from './constants';
28
+
29
+type OnHover = (node: Snapshot | null) => void;
30
+
31
+export class SnapshotsView extends View {
32
+ _intrinsicSize: Size;
33
+ _profilerData: ReactProfilerData;
34
+
35
+ onHover: OnHover | null = null;
36
+
37
+ constructor(surface: Surface, frame: Rect, profilerData: ReactProfilerData) {
38
+ super(surface, frame);
39
+
40
+ this._intrinsicSize = {
41
+ width: profilerData.duration,
42
+ height: SNAPSHOT_HEIGHT,
43
+ };
44
+ this._profilerData = profilerData;
45
+ }
46
+
47
+ desiredSize() {
48
+ return this._intrinsicSize;
49
+ }
50
+
51
+ draw(context: CanvasRenderingContext2D) {
52
+ const {visibleArea} = this;
53
+
54
+ context.fillStyle = COLORS.BACKGROUND;
55
+ context.fillRect(
56
+ visibleArea.origin.x,
57
+ visibleArea.origin.y,
58
+ visibleArea.size.width,
59
+ visibleArea.size.height,
60
+ );
61
+
62
+ const y = visibleArea.origin.y;
63
+
64
+ let x = visibleArea.origin.x;
65
+
66
+ // Rather than drawing each snapshot where it occured,
67
+ // draw them at fixed intervals and just show the nearest one.
68
+ while (x < visibleArea.origin.x + visibleArea.size.width) {
69
+ const snapshot = this._findClosestSnapshot(x);
70
+
71
+ const scaledHeight = SNAPSHOT_HEIGHT;
72
+ const scaledWidth = (snapshot.width * SNAPSHOT_HEIGHT) / snapshot.height;
73
+
74
+ const imageRect: Rect = {
75
+ origin: {
76
+ x,
77
+ y,
78
+ },
79
+ size: {width: scaledWidth, height: scaledHeight},
80
+ };
81
+
82
+ // Lazily create and cache Image objects as we render a snapsho for the first time.
83
+ if (snapshot.image === null) {
84
+ const img = (snapshot.image = new Image());
85
+ img.onload = () => {
86
+ this._drawSnapshotImage(context, snapshot, imageRect);
87
+ };
88
+ img.src = snapshot.imageSource;
89
+ } else {
90
+ this._drawSnapshotImage(context, snapshot, imageRect);
91
+ }
92
+
93
+ x += scaledWidth + BORDER_SIZE;
94
+ }
95
+ }
96
+
97
+ handleInteraction(interaction: Interaction, viewRefs: ViewRefs) {
98
+ switch (interaction.type) {
99
+ case 'mousemove':
100
+ this._handleMouseMove(interaction, viewRefs);
101
+ break;
102
+ }
103
+ }
104
+
105
+ _drawSnapshotImage(
106
+ context: CanvasRenderingContext2D,
107
+ snapshot: Snapshot,
108
+ imageRect: Rect,
109
+ ) {
110
+ const visibleArea = this.visibleArea;
111
+
112
+ // Prevent snapshot from visibly overflowing its container when clipped.
113
+ const shouldClip = !rectEqualToRect(imageRect, visibleArea);
114
+ if (shouldClip) {
115
+ const clippedRect = intersectionOfRects(imageRect, visibleArea);
116
+ context.save();
117
+ context.beginPath();
118
+ context.rect(
119
+ clippedRect.origin.x,
120
+ clippedRect.origin.y,
121
+ clippedRect.size.width,
122
+ clippedRect.size.height,
123
+ );
124
+ context.closePath();
125
+ context.clip();
126
+ }
127
+
128
+ // $FlowFixMe Flow doesn't know about the 9 argument variant of drawImage()
129
+ context.drawImage(
130
+ snapshot.image,
131
+
132
+ // Image coordinates
133
+ 0,
134
+ 0,
135
+
136
+ // Native image size
137
+ snapshot.width,
138
+ snapshot.height,
139
+
140
+ // Canvas coordinates
141
+ imageRect.origin.x,
142
+ imageRect.origin.y,
143
+
144
+ // Scaled image size
145
+ imageRect.size.width,
146
+ imageRect.size.height,
147
+ );
148
+
149
+ if (shouldClip) {
150
+ context.restore();
151
+ }
152
+ }
153
+
154
+ _findClosestSnapshot(x: number): Snapshot {
155
+ const frame = this.frame;
156
+ const scaleFactor = positioningScaleFactor(
157
+ this._intrinsicSize.width,
158
+ frame,
159
+ );
160
+
161
+ const snapshots = this._profilerData.snapshots;
162
+
163
+ let startIndex = 0;
164
+ let stopIndex = snapshots.length - 1;
165
+ while (startIndex <= stopIndex) {
166
+ const currentIndex = Math.floor((startIndex + stopIndex) / 2);
167
+ const snapshot = snapshots[currentIndex];
168
+ const {timestamp} = snapshot;
169
+
170
+ const snapshotX = Math.floor(
171
+ timestampToPosition(timestamp, scaleFactor, frame),
172
+ );
173
+
174
+ if (x < snapshotX) {
175
+ stopIndex = currentIndex - 1;
176
+ } else {
177
+ startIndex = currentIndex + 1;
178
+ }
179
+ }
180
+
181
+ return snapshots[stopIndex];
182
+ }
183
+
184
+ /**
185
+ * @private
186
+ */
187
+ _handleMouseMove(interaction: MouseMoveInteraction, viewRefs: ViewRefs) {
188
+ const {onHover, visibleArea} = this;
189
+ if (!onHover) {
190
+ return;
191
+ }
192
+
193
+ const {location} = interaction.payload;
194
+ if (!rectContainsPoint(location, visibleArea)) {
195
+ onHover(null);
196
+ return;
197
+ }
198
+
199
+ const snapshot = this._findClosestSnapshot(location.x);
200
+ if (snapshot) {
201
+ this.currentCursor = 'context-menu';
202
+ viewRefs.hoveredView = this;
203
+ onHover(snapshot);
204
+ } else {
205
+ onHover(null);
206
+ }
207
+ }
208
+}
packages/react-devtools-scheduling-profiler/src/content-views/constants.js
+1
@@ -23,6 +23,7 @@ export const REACT_MEASURE_HEIGHT = 14;
23
export const BORDER_SIZE = 1;
24
export const FLAMECHART_FRAME_HEIGHT = 14;
25
export const TEXT_PADDING = 3;
26
+export const SNAPSHOT_HEIGHT = 50;
27
28
export const INTERVAL_TIMES = [
29
1,
packages/react-devtools-scheduling-profiler/src/content-views/index.js
+1
@@ -12,6 +12,7 @@ export * from './FlamechartView';
12
export * from './NativeEventsView';
13
export * from './ReactMeasuresView';
14
export * from './SchedulingEventsView';
15
+export * from './SnapshotsView';
16
export * from './SuspenseEventsView';
17
export * from './TimeAxisMarkersView';
18
export * from './UserTimingMarksView';
packages/react-devtools-scheduling-profiler/src/import-worker/__tests__/preprocessData-test.internal.js
+43
-37
@@ -17,7 +17,7 @@ import {
17
} from '../../constants';
18
import REACT_VERSION from 'shared/ReactVersion';
19
20
-describe(getLanesFromTransportDecimalBitmask, () => {
20
+describe('getLanesFromTransportDecimalBitmask', () => {
21
it('should return array of lane numbers from bitmask string', () => {
22
expect(getLanesFromTransportDecimalBitmask('1')).toEqual([0]);
23
expect(getLanesFromTransportDecimalBitmask('512')).toEqual([9]);
@@ -57,7 +57,7 @@ describe(getLanesFromTransportDecimalBitmask, () => {
57
});
58
});
59
60
-describe(preprocessData, () => {
60
+describe('preprocessData', () => {
61
let React;
62
let ReactDOM;
63
let Scheduler;
@@ -217,11 +217,11 @@ describe(preprocessData, () => {
217
delete global.performance;
218
});
219
220
- it('should throw given an empty timeline', () => {
221
- expect(() => preprocessData([])).toThrow();
220
+ it('should throw given an empty timeline', async () => {
221
+ await expect(async () => preprocessData([])).rejects.toThrow();
222
});
223
224
- it('should throw given a timeline with no Profile event', () => {
224
+ it('should throw given a timeline with no Profile event', async () => {
225
const randomSample = createUserTimingEntry({
226
dur: 100,
227
tdur: 200,
@@ -231,10 +231,10 @@ describe(preprocessData, () => {
231
args: {},
232
});
233
234
- expect(() => preprocessData([randomSample])).toThrow();
234
+ await expect(async () => preprocessData([randomSample])).rejects.toThrow();
235
});
236
237
- it('should return empty data given a timeline with no React scheduling profiling marks', () => {
237
+ it('should return empty data given a timeline with no React scheduling profiling marks', async () => {
238
const cpuProfilerSample = creactCpuProfilerSample();
239
const randomSample = createUserTimingEntry({
240
dur: 100,
@@ -246,7 +246,7 @@ describe(preprocessData, () => {
246
});
247
248
if (gate(flags => flags.enableSchedulingProfiler)) {
249
- const data = preprocessData([
249
+ const data = await preprocessData([
250
...createBoilerplateEntries(),
251
cpuProfilerSample,
252
randomSample,
@@ -327,6 +327,7 @@ describe(preprocessData, () => {
327
"otherUserTimingMarks": Array [],
328
"reactVersion": "17.0.3",
329
"schedulingEvents": Array [],
330
+ "snapshots": Array [],
331
"startTime": 1,
332
"suspenseEvents": Array [],
333
}
@@ -334,13 +335,13 @@ describe(preprocessData, () => {
335
}
336
});
337
337
- it('should process legacy data format (before lane labels were added)', () => {
338
+ it('should process legacy data format (before lane labels were added)', async () => {
339
const cpuProfilerSample = creactCpuProfilerSample();
340
341
if (gate(flags => flags.enableSchedulingProfiler)) {
342
// Data below is hard-coded based on an older profile sample.
343
// Should be fine since this is explicitly a legacy-format test.
343
- const data = preprocessData([
344
+ const data = await preprocessData([
345
...createBoilerplateEntries(),
346
cpuProfilerSample,
347
createUserTimingEntry({
@@ -541,6 +542,7 @@ describe(preprocessData, () => {
542
"warning": null,
543
},
544
],
545
+ "snapshots": Array [],
546
"startTime": 1,
547
"suspenseEvents": Array [],
548
}
@@ -548,11 +550,11 @@ describe(preprocessData, () => {
550
}
551
});
552
551
- it('should process a sample legacy render sequence', () => {
553
+ it('should process a sample legacy render sequence', async () => {
554
ReactDOM.render(<div />, document.createElement('div'));
555
556
if (gate(flags => flags.enableSchedulingProfiler)) {
555
- const data = preprocessData([
557
+ const data = await preprocessData([
558
...createBoilerplateEntries(),
559
...createUserTimingData(clearedMarks),
560
]);
@@ -730,6 +732,7 @@ describe(preprocessData, () => {
732
"warning": null,
733
},
734
],
735
+ "snapshots": Array [],
736
"startTime": 4,
737
"suspenseEvents": Array [],
738
}
@@ -737,7 +740,7 @@ describe(preprocessData, () => {
740
}
741
});
742
740
- it('should process a sample createRoot render sequence', () => {
743
+ it('should process a sample createRoot render sequence', async () => {
744
function App() {
745
const [didMount, setDidMount] = React.useState(false);
746
React.useEffect(() => {
@@ -752,7 +755,7 @@ describe(preprocessData, () => {
755
const root = ReactDOM.createRoot(document.createElement('div'));
756
act(() => root.render(<App />));
757
755
- const data = preprocessData([
758
+ const data = await preprocessData([
759
...createBoilerplateEntries(),
760
...createUserTimingData(clearedMarks),
761
]);
@@ -1074,6 +1077,7 @@ describe(preprocessData, () => {
1077
"warning": null,
1078
},
1079
],
1080
+ "snapshots": Array [],
1081
"startTime": 4,
1082
"suspenseEvents": Array [],
1083
}
@@ -1082,7 +1086,7 @@ describe(preprocessData, () => {
1086
});
1087
1088
// @gate enableSchedulingProfiler
1085
- it('should error if events and measures are incomplete', () => {
1089
+ it('should error if events and measures are incomplete', async () => {
1090
const container = document.createElement('div');
1091
ReactDOM.render(<div />, container);
1092
@@ -1097,7 +1101,7 @@ describe(preprocessData, () => {
1101
});
1102
1103
// @gate enableSchedulingProfiler
1100
- it('should error if work is completed without being started', () => {
1104
+ it('should error if work is completed without being started', async () => {
1105
const container = document.createElement('div');
1106
ReactDOM.render(<div />, container);
1107
@@ -1111,7 +1115,7 @@ describe(preprocessData, () => {
1115
expect(error).toHaveBeenCalled();
1116
});
1117
1114
- it('should populate other user timing marks', () => {
1118
+ it('should populate other user timing marks', async () => {
1119
const userTimingData = createUserTimingData([]);
1120
userTimingData.push(
1121
createUserTimingEntry({
@@ -1138,7 +1142,7 @@ describe(preprocessData, () => {
1142
}),
1143
);
1144
1141
- const data = preprocessData([
1145
+ const data = await preprocessData([
1146
...createBoilerplateEntries(),
1147
...userTimingData,
1148
]);
@@ -1162,7 +1166,7 @@ describe(preprocessData, () => {
1166
1167
describe('warnings', () => {
1168
describe('long event handlers', () => {
1165
- it('should not warn when React scedules a (sync) update inside of a short event handler', () => {
1169
+ it('should not warn when React scedules a (sync) update inside of a short event handler', async () => {
1170
function App() {
1171
return null;
1172
}
@@ -1180,13 +1184,13 @@ describe(preprocessData, () => {
1184
1185
testMarks.push(...createUserTimingData(clearedMarks));
1186
1183
- const data = preprocessData(testMarks);
1187
+ const data = await preprocessData(testMarks);
1188
const event = data.nativeEvents.find(({type}) => type === 'click');
1189
expect(event.warning).toBe(null);
1190
}
1191
});
1192
1189
- it('should not warn about long events if the cause was non-React JavaScript', () => {
1193
+ it('should not warn about long events if the cause was non-React JavaScript', async () => {
1194
function App() {
1195
return null;
1196
}
@@ -1206,13 +1210,13 @@ describe(preprocessData, () => {
1210
1211
testMarks.push(...createUserTimingData(clearedMarks));
1212
1209
- const data = preprocessData(testMarks);
1213
+ const data = await preprocessData(testMarks);
1214
const event = data.nativeEvents.find(({type}) => type === 'click');
1215
expect(event.warning).toBe(null);
1216
}
1217
});
1218
1215
- it('should warn when React scedules a long (sync) update inside of an event', () => {
1219
+ it('should warn when React scedules a long (sync) update inside of an event', async () => {
1220
function App() {
1221
return null;
1222
}
@@ -1245,7 +1249,7 @@ describe(preprocessData, () => {
1249
});
1250
});
1251
1248
- const data = preprocessData(testMarks);
1252
+ const data = await preprocessData(testMarks);
1253
const event = data.nativeEvents.find(({type}) => type === 'click');
1254
expect(event.warning).toMatchInlineSnapshot(
1255
`"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, () => {
1257
}
1258
});
1259
1256
- it('should not warn when React finishes a previously long (async) update with a short (sync) update inside of an event', () => {
1260
+ it('should not warn when React finishes a previously long (async) update with a short (sync) update inside of an event', async () => {
1261
function Yield({id, value}) {
1262
Scheduler.unstable_yieldValue(`${id}:${value}`);
1263
return null;
@@ -1303,7 +1307,7 @@ describe(preprocessData, () => {
1307
1308
testMarks.push(...createUserTimingData(clearedMarks));
1309
1306
- const data = preprocessData(testMarks);
1310
+ const data = await preprocessData(testMarks);
1311
const event = data.nativeEvents.find(({type}) => type === 'click');
1312
expect(event.warning).toBe(null);
1313
}
@@ -1311,7 +1315,7 @@ describe(preprocessData, () => {
1315
});
1316
1317
describe('nested updates', () => {
1314
- it('should not warn about short nested (state) updates during layout effects', () => {
1318
+ it('should not warn about short nested (state) updates during layout effects', async () => {
1319
function Component() {
1320
const [didMount, setDidMount] = React.useState(false);
1321
Scheduler.unstable_yieldValue(
@@ -1334,7 +1338,7 @@ describe(preprocessData, () => {
1338
'Component update',
1339
]);
1340
1337
- const data = preprocessData([
1341
+ const data = await preprocessData([
1342
...createBoilerplateEntries(),
1343
...createUserTimingData(clearedMarks),
1344
]);
@@ -1346,7 +1350,7 @@ describe(preprocessData, () => {
1350
}
1351
});
1352
1349
- it('should not warn about short (forced) updates during layout effects', () => {
1353
+ it('should not warn about short (forced) updates during layout effects', async () => {
1354
class Component extends React.Component {
1355
_didMount: boolean = false;
1356
componentDidMount() {
@@ -1372,7 +1376,7 @@ describe(preprocessData, () => {
1376
'Component update',
1377
]);
1378
1375
- const data = preprocessData([
1379
+ const data = await preprocessData([
1380
...createBoilerplateEntries(),
1381
...createUserTimingData(clearedMarks),
1382
]);
@@ -1384,7 +1388,7 @@ describe(preprocessData, () => {
1388
}
1389
});
1390
1387
- it('should warn about long nested (state) updates during layout effects', () => {
1391
+ it('should warn about long nested (state) updates during layout effects', async () => {
1392
function Component() {
1393
const [didMount, setDidMount] = React.useState(false);
1394
Scheduler.unstable_yieldValue(
@@ -1429,7 +1433,7 @@ describe(preprocessData, () => {
1433
});
1434
});
1435
1432
- const data = preprocessData([
1436
+ const data = await preprocessData([
1437
cpuProfilerSample,
1438
...createBoilerplateEntries(),
1439
...testMarks,
@@ -1444,7 +1448,7 @@ describe(preprocessData, () => {
1448
}
1449
});
1450
1447
- it('should warn about long nested (forced) updates during layout effects', () => {
1451
+ it('should warn about long nested (forced) updates during layout effects', async () => {
1452
class Component extends React.Component {
1453
_didMount: boolean = false;
1454
componentDidMount() {
@@ -1490,7 +1494,7 @@ describe(preprocessData, () => {
1494
});
1495
});
1496
1493
- const data = preprocessData([
1497
+ const data = await preprocessData([
1498
cpuProfilerSample,
1499
...createBoilerplateEntries(),
1500
...testMarks,
@@ -1509,7 +1513,7 @@ describe(preprocessData, () => {
1513
describe('suspend during an update', () => {
1514
// This also tests an edge case where the a component suspends while profiling
1515
// before the first commit is logged (so the lane-to-labels map will not yet exist).
1512
- it('should warn about suspending during an udpate', () => {
1516
+ it('should warn about suspending during an udpate', async () => {
1517
let promise = null;
1518
let resolvedValue = null;
1519
function readValue(value) {
@@ -1557,7 +1561,7 @@ describe(preprocessData, () => {
1561
1562
testMarks.push(...createUserTimingData(clearedMarks));
1563
1560
- const data = preprocessData(testMarks);
1564
+ const data = await preprocessData(testMarks);
1565
expect(data.suspenseEvents).toHaveLength(1);
1566
expect(data.suspenseEvents[0].warning).toMatchInlineSnapshot(
1567
`"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, () => {
1619
1620
testMarks.push(...createUserTimingData(clearedMarks));
1621
1618
- const data = preprocessData(testMarks);
1622
+ const data = await preprocessData(testMarks);
1623
expect(data.suspenseEvents).toHaveLength(1);
1624
expect(data.suspenseEvents[0].warning).toBe(null);
1625
}
@@ -1623,5 +1627,7 @@ describe(preprocessData, () => {
1627
});
1628
});
1629
1630
+ // TODO: Add test for snapshot base64 parsing
1631
+
1632
// TODO: Add test for flamechart parsing
1633
});
packages/react-devtools-scheduling-profiler/src/import-worker/importFile.js
+3
-1
@@ -26,9 +26,11 @@ export async function importFile(file: File): Promise<ImportWorkerOutputData> {
26
throw new InvalidProfileError('No profiling data found in file.');
27
}
28
29
+ const processedData = await preprocessData(events);
30
+
31
return {
32
status: 'SUCCESS',
31
- processedData: preprocessData(events),
33
+ processedData,
34
};
35
} catch (error) {
36
if (error instanceof InvalidProfileError) {
packages/react-devtools-scheduling-profiler/src/import-worker/preprocessData.js
+44
-3
@@ -26,7 +26,6 @@ import type {
26
SchedulingEvent,
27
SuspenseEvent,
28
} from '../types';
29
-
29
import {REACT_TOTAL_NUM_LANES, SCHEDULING_PROFILER_VERSION} from '../constants';
30
import InvalidProfileError from './InvalidProfileError';
31
import {getBatchRange} from '../utils/getBatchRange';
@@ -40,6 +39,7 @@ type MeasureStackElement = {|
39
|};
40
41
type ProcessorState = {|
42
+ asyncProcessingPromises: Promise<any>[],
43
batchUID: BatchUID,
44
currentReactComponentMeasure: ReactComponentMeasure | null,
45
measureStack: MeasureStackElement[],
@@ -224,6 +224,41 @@ function processTimelineEvent(
224
) {
225
const {args, cat, name, ts, ph} = event;
226
switch (cat) {
227
+ case 'disabled-by-default-devtools.screenshot':
228
+ const encodedSnapshot = args.snapshot; // Base 64 encoded
229
+
230
+ const snapshot = {
231
+ height: 0,
232
+ image: null,
233
+ imageSource: `data:image/png;base64,${encodedSnapshot}`,
234
+ timestamp: (ts - currentProfilerData.startTime) / 1000,
235
+ width: 0,
236
+ };
237
+
238
+ // Delay processing until we've extracted snapshot dimensions.
239
+ let resolveFn = ((null: any): Function);
240
+ state.asyncProcessingPromises.push(
241
+ new Promise(resolve => {
242
+ resolveFn = resolve;
243
+ }),
244
+ );
245
+
246
+ // Parse the Base64 image data to determine native size.
247
+ // This will be used later to scale for display within the thumbnail strip.
248
+ fetch(snapshot.imageSource)
249
+ .then(response => response.blob())
250
+ .then(blob => {
251
+ // $FlowFixMe createImageBitmap
252
+ createImageBitmap(blob).then(bitmap => {
253
+ snapshot.height = bitmap.height;
254
+ snapshot.width = bitmap.width;
255
+
256
+ resolveFn();
257
+ });
258
+ });
259
+
260
+ currentProfilerData.snapshots.push(snapshot);
261
+ break;
262
case 'devtools.timeline':
263
if (name === 'EventDispatch') {
264
const type = args.data.type;
@@ -661,9 +696,9 @@ function preprocessFlamechart(rawData: TimelineEvent[]): Flamechart {
696
return flamechart;
697
}
698
664
-export default function preprocessData(
699
+export default async function preprocessData(
700
timeline: TimelineEvent[],
666
-): ReactProfilerData {
701
+): Promise<ReactProfilerData> {
702
const flamechart = preprocessFlamechart(timeline);
703
704
const laneToReactMeasureMap = new Map();
@@ -682,6 +717,7 @@ export default function preprocessData(
717
otherUserTimingMarks: [],
718
reactVersion: null,
719
schedulingEvents: [],
720
+ snapshots: [],
721
startTime: 0,
722
suspenseEvents: [],
723
};
@@ -713,6 +749,7 @@ export default function preprocessData(
749
(timeline[timeline.length - 1].ts - profilerData.startTime) / 1000;
750
751
const state: ProcessorState = {
752
+ asyncProcessingPromises: [],
753
batchUID: 0,
754
currentReactComponentMeasure: null,
755
measureStack: [],
@@ -773,5 +810,9 @@ export default function preprocessData(
810
},
811
);
812
813
+ // Wait for any async processing to complete before returning.
814
+ // Since processing is done in a worker, async work must complete before data is serialized and returned.
815
+ await Promise.all(state.asyncProcessingPromises);
816
+
817
return profilerData;
818
}
packages/react-devtools-scheduling-profiler/src/types.js
+10
@@ -115,6 +115,14 @@ export type UserTimingMark = {|
115
timestamp: Milliseconds,
116
|};
117
118
+export type Snapshot = {|
119
+ height: number,
120
+ image: Image | null,
121
+ +imageSource: string,
122
+ +timestamp: Milliseconds,
123
+ width: number,
124
+|};
125
+
126
/**
127
* A "layer" of stack frames in the profiler UI, i.e. all stack frames of the
128
* same depth across all stack traces. Displayed as a flamechart row in the UI.
@@ -150,6 +158,7 @@ export type ReactProfilerData = {|
158
otherUserTimingMarks: UserTimingMark[],
159
reactVersion: string | null,
160
schedulingEvents: SchedulingEvent[],
161
+ snapshots: Snapshot[],
162
startTime: number,
163
suspenseEvents: SuspenseEvent[],
164
|};
@@ -162,5 +171,6 @@ export type ReactHoverContextInfo = {|
171
nativeEvent: NativeEvent | null,
172
schedulingEvent: SchedulingEvent | null,
173
suspenseEvent: SuspenseEvent | null,
174
+ snapshot: Snapshot | null,
175
userTimingMark: UserTimingMark | null,
176
|};