Scheduling Profiler: Move preprocessing to web worker and add loading indicator (#19759)
* Move preprocessData into a web worker * Add UI feedback for loading/import error states * Terminate worker when done handling profile * Add display density CSS variables
E-Liang Tan committed
Sep 4, 2020 at 22:57 UTC
eabd18c73f0df5e38431e47b0549566a6f41e15f
16 files changed
+245
-93
packages/react-devtools-scheduling-profiler/package.json
+2
-1
@@ -28,6 +28,7 @@
28
"url-loader": "^4.1.0",
29
"webpack": "^4.44.1",
30
"webpack-cli": "^3.3.12",
31
- "webpack-dev-server": "^3.11.0"
31
+ "webpack-dev-server": "^3.11.0",
32
+ "worker-loader": "^3.0.2"
33
}
34
}
packages/react-devtools-scheduling-profiler/src/App.js
+6
-8
@@ -14,23 +14,21 @@ import '@reach/tooltip/styles.css';
14
15
import * as React from 'react';
16
17
-import {ModalDialogContextController} from 'react-devtools-shared/src/devtools/views/ModalDialog';
17
import {SchedulingProfiler} from './SchedulingProfiler';
19
-import {useBrowserTheme} from './hooks';
18
+import {useBrowserTheme, useDisplayDensity} from './hooks';
19
20
import styles from './App.css';
21
import 'react-devtools-shared/src/devtools/views/root.css';
22
23
export default function App() {
24
useBrowserTheme();
25
+ useDisplayDensity();
26
27
return (
28
- <ModalDialogContextController>
29
- <div className={styles.DevTools}>
30
- <div className={styles.TabContent}>
31
- <SchedulingProfiler />
32
- </div>
28
+ <div className={styles.DevTools}>
29
+ <div className={styles.TabContent}>
30
+ <SchedulingProfiler />
31
</div>
34
- </ModalDialogContextController>
32
+ </div>
33
);
34
}
packages/react-devtools-scheduling-profiler/src/ImportButton.css
-7
@@ -8,10 +8,3 @@
8
overflow: hidden;
9
clip: rect(1px, 1px, 1px, 1px);
10
}
11
-
12
-.ErrorMessage {
13
- margin: 0.5rem 0;
14
- color: var(--color-dim);
15
- font-family: var(--font-family-monospace);
16
- font-size: var(--font-size-monospace-normal);
17
-}
packages/react-devtools-scheduling-profiler/src/ImportButton.js
+6
-35
@@ -7,61 +7,32 @@
7
* @flow
8
*/
9
10
-import type {TimelineEvent} from '@elg/speedscope';
11
-import type {ReactProfilerData} from './types';
12
-
10
import * as React from 'react';
14
-import {useCallback, useContext, useRef} from 'react';
11
+import {useCallback, useRef} from 'react';
12
13
import Button from 'react-devtools-shared/src/devtools/views/Button';
14
import ButtonIcon from 'react-devtools-shared/src/devtools/views/ButtonIcon';
18
-import {ModalDialogContext} from 'react-devtools-shared/src/devtools/views/ModalDialog';
19
-
20
-import preprocessData from './utils/preprocessData';
21
-import {readInputData} from './utils/readInputData';
15
16
import styles from './ImportButton.css';
17
18
type Props = {|
26
- onDataImported: (profilerData: ReactProfilerData) => void,
19
+ onFileSelect: (file: File) => void,
20
|};
21
29
-export default function ImportButton({onDataImported}: Props) {
22
+export default function ImportButton({onFileSelect}: Props) {
23
const inputRef = useRef<HTMLInputElement | null>(null);
31
- const {dispatch: modalDialogDispatch} = useContext(ModalDialogContext);
24
33
- const handleFiles = useCallback(async () => {
25
+ const handleFiles = useCallback(() => {
26
const input = inputRef.current;
27
if (input === null) {
28
return;
29
}
38
-
30
if (input.files.length > 0) {
40
- try {
41
- const readFile = await readInputData(input.files[0]);
42
- const events: TimelineEvent[] = JSON.parse(readFile);
43
- if (events.length > 0) {
44
- onDataImported(preprocessData(events));
45
- }
46
- } catch (error) {
47
- modalDialogDispatch({
48
- type: 'SHOW',
49
- title: 'Import failed',
50
- content: (
51
- <>
52
- <div>The profiling data you selected cannot be imported.</div>
53
- {error !== null && (
54
- <div className={styles.ErrorMessage}>{error.message}</div>
55
- )}
56
- </>
57
- ),
58
- });
59
- }
31
+ onFileSelect(input.files[0]);
32
}
61
-
33
// Reset input element to allow the same file to be re-imported
34
input.value = '';
64
- }, [onDataImported, modalDialogDispatch]);
35
+ }, [onFileSelect]);
36
37
const uploadData = useCallback(() => {
38
if (inputRef.current !== null) {
packages/react-devtools-scheduling-profiler/src/SchedulingProfiler.css
+7
@@ -28,6 +28,13 @@
28
text-align: center;
29
}
30
31
+.ErrorMessage {
32
+ margin: 0.5rem 0;
33
+ color: var(--color-dim);
34
+ font-family: var(--font-family-monospace);
35
+ font-size: var(--font-size-monospace-normal);
36
+}
37
+
38
.Row {
39
display: flex;
40
flex-direction: row;
packages/react-devtools-scheduling-profiler/src/SchedulingProfiler.js
+94
-21
@@ -7,30 +7,60 @@
7
* @flow
8
*/
9
10
+import type {Resource} from 'react-devtools-shared/src/devtools/cache';
11
import type {ReactProfilerData} from './types';
12
+import type {ImportWorkerOutputData} from './import-worker/import.worker';
13
14
import * as React from 'react';
13
-import {useState} from 'react';
14
-
15
-import ImportButton from './ImportButton';
16
-import {ModalDialog} from 'react-devtools-shared/src/devtools/views/ModalDialog';
15
+import {Suspense, useCallback, useState} from 'react';
16
+import {createResource} from 'react-devtools-shared/src/devtools/cache';
17
import ReactLogo from 'react-devtools-shared/src/devtools/views/ReactLogo';
18
19
+import ImportButton from './ImportButton';
20
import CanvasPage from './CanvasPage';
21
+import ImportWorker from './import-worker/import.worker';
22
23
import profilerBrowser from './assets/profilerBrowser.png';
24
import styles from './SchedulingProfiler.css';
25
24
-export function SchedulingProfiler(_: {||}) {
25
- const [profilerData, setProfilerData] = useState<ReactProfilerData | null>(
26
- null,
27
- );
26
+type DataResource = Resource<void, File, ReactProfilerData | Error>;
27
+
28
+function createDataResourceFromImportedFile(file: File): DataResource {
29
+ return createResource(
30
+ () => {
31
+ return new Promise<ReactProfilerData | Error>((resolve, reject) => {
32
+ const worker: Worker = new (ImportWorker: any)();
33
29
- const view = profilerData ? (
30
- <CanvasPage profilerData={profilerData} />
31
- ) : (
32
- <Welcome onDataImported={setProfilerData} />
34
+ worker.onmessage = function(event) {
35
+ const data = ((event.data: any): ImportWorkerOutputData);
36
+ switch (data.status) {
37
+ case 'SUCCESS':
38
+ resolve(data.processedData);
39
+ break;
40
+ case 'INVALID_PROFILE_ERROR':
41
+ resolve(data.error);
42
+ break;
43
+ case 'UNEXPECTED_ERROR':
44
+ reject(data.error);
45
+ break;
46
+ }
47
+ worker.terminate();
48
+ };
49
+
50
+ worker.postMessage({file});
51
+ });
52
+ },
53
+ () => file,
54
+ {useWeakMap: true},
55
);
56
+}
57
+
58
+export function SchedulingProfiler(_: {||}) {
59
+ const [dataResource, setDataResource] = useState<DataResource | null>(null);
60
+
61
+ const handleFileSelect = useCallback((file: File) => {
62
+ setDataResource(createDataResourceFromImportedFile(file));
63
+ }, []);
64
65
return (
66
<div className={styles.SchedulingProfiler}>
@@ -38,22 +68,26 @@ export function SchedulingProfiler(_: {||}) {
68
<ReactLogo />
69
<span className={styles.AppName}>Concurrent Mode Profiler</span>
70
<div className={styles.VRule} />
41
- <ImportButton onDataImported={setProfilerData} />
71
+ <ImportButton onFileSelect={handleFileSelect} />
72
<div className={styles.Spacer} />
73
</div>
74
<div className={styles.Content}>
45
- {view}
46
- <ModalDialog />
75
+ {dataResource ? (
76
+ <Suspense fallback={<ProcessingData />}>
77
+ <DataResourceComponent
78
+ dataResource={dataResource}
79
+ onFileSelect={handleFileSelect}
80
+ />
81
+ </Suspense>
82
+ ) : (
83
+ <Welcome onFileSelect={handleFileSelect} />
84
+ )}
85
</div>
86
</div>
87
);
88
}
89
52
-type WelcomeProps = {|
53
- onDataImported: (profilerData: ReactProfilerData) => void,
54
-|};
55
-
56
-const Welcome = ({onDataImported}: WelcomeProps) => (
90
+const Welcome = ({onFileSelect}: {|onFileSelect: (file: File) => void|}) => (
91
<div className={styles.EmptyStateContainer}>
92
<div className={styles.ScreenshotWrapper}>
93
<img
@@ -65,8 +99,47 @@ const Welcome = ({onDataImported}: WelcomeProps) => (
99
<div className={styles.Header}>Welcome!</div>
100
<div className={styles.Row}>
101
Click the import button
68
- <ImportButton onDataImported={onDataImported} /> to import a Chrome
102
+ <ImportButton onFileSelect={onFileSelect} /> to import a Chrome
103
performance profile.
104
</div>
105
</div>
106
);
107
+
108
+const ProcessingData = () => (
109
+ <div className={styles.EmptyStateContainer}>
110
+ <div className={styles.Header}>Processing data...</div>
111
+ <div className={styles.Row}>This should only take a minute.</div>
112
+ </div>
113
+);
114
+
115
+const CouldNotLoadProfile = ({error, onFileSelect}) => (
116
+ <div className={styles.EmptyStateContainer}>
117
+ <div className={styles.Header}>Could not load profile</div>
118
+ {error.message && (
119
+ <div className={styles.Row}>
120
+ <div className={styles.ErrorMessage}>{error.message}</div>
121
+ </div>
122
+ )}
123
+ <div className={styles.Row}>
124
+ Try importing
125
+ <ImportButton onFileSelect={onFileSelect} />
126
+ another Chrome performance profile.
127
+ </div>
128
+ </div>
129
+);
130
+
131
+const DataResourceComponent = ({
132
+ dataResource,
133
+ onFileSelect,
134
+}: {|
135
+ dataResource: DataResource,
136
+ onFileSelect: (file: File) => void,
137
+|}) => {
138
+ const dataOrError = dataResource.read();
139
+ if (dataOrError instanceof Error) {
140
+ return (
141
+ <CouldNotLoadProfile error={dataOrError} onFileSelect={onFileSelect} />
142
+ );
143
+ }
144
+ return <CanvasPage profilerData={dataOrError} />;
145
+};
packages/react-devtools-scheduling-profiler/src/hooks.js
+11
-1
@@ -13,7 +13,10 @@ import {
13
useLayoutEffect,
14
} from 'react';
15
16
-import {updateThemeVariables} from 'react-devtools-shared/src/devtools/views/Settings/SettingsContext';
16
+import {
17
+ updateDisplayDensity,
18
+ updateThemeVariables,
19
+} from 'react-devtools-shared/src/devtools/views/Settings/SettingsContext';
20
import {enableDarkMode} from './SchedulingProfilerFeatureFlags';
21
22
export type BrowserTheme = 'dark' | 'light';
@@ -57,3 +60,10 @@ export function useBrowserTheme(): void {
60
}
61
}, [theme]);
62
}
63
+
64
+export function useDisplayDensity(): void {
65
+ useLayoutEffect(() => {
66
+ const documentElements = [((document.documentElement: any): HTMLElement)];
67
+ updateDisplayDensity('comfortable', documentElements);
68
+ }, []);
69
+}
packages/react-devtools-scheduling-profiler/src/import-worker/InvalidProfileError.js
new
+13
@@ -0,0 +1,13 @@
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
+/**
11
+ * An error thrown when an invalid profile could not be processed.
12
+ */
13
+export default class InvalidProfileError extends Error {}
packages/react-devtools-scheduling-profiler/src/import-worker/__tests__/__snapshots__/preprocessData-test.js.snap
renamed
packages/react-devtools-scheduling-profiler/src/import-worker/__tests__/preprocessData-test.js
renamed
packages/react-devtools-scheduling-profiler/src/import-worker/import.worker.js
new
+57
@@ -0,0 +1,57 @@
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 'regenerator-runtime/runtime';
11
+
12
+import type {TimelineEvent} from '@elg/speedscope';
13
+import type {ReactProfilerData} from '../types';
14
+
15
+import preprocessData from './preprocessData';
16
+import {readInputData} from './readInputData';
17
+import InvalidProfileError from './InvalidProfileError';
18
+
19
+declare var self: DedicatedWorkerGlobalScope;
20
+
21
+type ImportWorkerInputData = {|
22
+ file: File,
23
+|};
24
+
25
+export type ImportWorkerOutputData =
26
+ | {|status: 'SUCCESS', processedData: ReactProfilerData|}
27
+ | {|status: 'INVALID_PROFILE_ERROR', error: Error|}
28
+ | {|status: 'UNEXPECTED_ERROR', error: Error|};
29
+
30
+self.onmessage = async function(event: MessageEvent) {
31
+ const {file} = ((event.data: any): ImportWorkerInputData);
32
+
33
+ try {
34
+ const readFile = await readInputData(file);
35
+ const events: TimelineEvent[] = JSON.parse(readFile);
36
+ if (events.length === 0) {
37
+ throw new InvalidProfileError('No profiling data found in file.');
38
+ }
39
+
40
+ self.postMessage({
41
+ status: 'SUCCESS',
42
+ processedData: preprocessData(events),
43
+ });
44
+ } catch (error) {
45
+ if (error instanceof InvalidProfileError) {
46
+ self.postMessage({
47
+ status: 'INVALID_PROFILE_ERROR',
48
+ error,
49
+ });
50
+ } else {
51
+ self.postMessage({
52
+ status: 'UNEXPECTED_ERROR',
53
+ error,
54
+ });
55
+ }
56
+ }
57
+};
packages/react-devtools-scheduling-profiler/src/import-worker/preprocessData.js
renamed
+13
-3
@@ -22,6 +22,7 @@ import type {
22
} from '../types';
23
24
import {REACT_TOTAL_NUM_LANES} from '../constants';
25
+import InvalidProfileError from './InvalidProfileError';
26
27
type MeasureStackElement = {|
28
type: ReactMeasureType,
@@ -144,7 +145,7 @@ function throwIfIncomplete(
145
if (lastIndex >= 0) {
146
const last = stack[lastIndex];
147
if (last.stopTime === undefined && last.type === type) {
147
- throw new Error(
148
+ throw new InvalidProfileError(
149
`Unexpected type "${type}" started before "${last.type}" completed.`,
150
);
151
}
@@ -369,7 +370,7 @@ function processTimelineEvent(
370
371
// Unrecognized event
372
else {
372
- throw new Error(
373
+ throw new InvalidProfileError(
374
`Unrecognized event ${JSON.stringify(
375
event,
376
)}! This is likely a bug in this profiler tool.`,
@@ -378,7 +379,16 @@ function processTimelineEvent(
379
}
380
381
function preprocessFlamechart(rawData: TimelineEvent[]): Flamechart {
381
- const parsedData = importFromChromeTimeline(rawData, 'react-devtools');
382
+ let parsedData;
383
+ try {
384
+ parsedData = importFromChromeTimeline(rawData, 'react-devtools');
385
+ } catch (error) {
386
+ // Assume any Speedscope errors are caused by bad profiles
387
+ const errorToRethrow = new InvalidProfileError(error.message);
388
+ errorToRethrow.stack = error.stack;
389
+ throw errorToRethrow;
390
+ }
391
+
392
const profile = parsedData.profiles[0]; // TODO: Choose the main CPU thread only
393
394
const speedscopeFlamechart = new SpeedscopeFlamechart({
packages/react-devtools-scheduling-profiler/src/import-worker/readInputData.js
renamed
+4
-5
@@ -8,13 +8,12 @@
8
*/
9
10
import nullthrows from 'nullthrows';
11
+import InvalidProfileError from './InvalidProfileError';
12
13
export const readInputData = (file: File): Promise<string> => {
14
if (!file.name.endsWith('.json')) {
14
- return Promise.reject(
15
- new Error(
16
- 'Invalid file type. Only JSON performance profiles are supported',
17
- ),
15
+ throw new InvalidProfileError(
16
+ 'Invalid file type. Only JSON performance profiles are supported',
17
);
18
}
19
@@ -26,7 +25,7 @@ export const readInputData = (file: File): Promise<string> => {
25
if (typeof result === 'string') {
26
resolve(result);
27
}
29
- reject(new Error('Input file was not read as a string'));
28
+ reject(new InvalidProfileError('Input file was not read as a string'));
29
};
30
31
fileReader.onerror = () => reject(fileReader.error);
packages/react-devtools-scheduling-profiler/webpack.config.js
+23
-11
@@ -26,6 +26,18 @@ const DEVTOOLS_VERSION = getVersionString();
26
27
const imageInlineSizeLimit = 10000;
28
29
+const babelOptions = {
30
+ configFile: resolve(
31
+ __dirname,
32
+ '..',
33
+ 'react-devtools-shared',
34
+ 'babel.config.js',
35
+ ),
36
+ plugins: shouldUseDevServer
37
+ ? [resolve(builtModulesDir, 'react-refresh/babel')]
38
+ : [],
39
+};
40
+
41
const config = {
42
mode: __DEV__ ? 'development' : 'production',
43
devtool: __DEV__ ? 'cheap-module-eval-source-map' : false,
@@ -53,20 +65,20 @@ const config = {
65
].filter(Boolean),
66
module: {
67
rules: [
68
+ {
69
+ test: /\.worker\.js$/,
70
+ use: [
71
+ 'worker-loader',
72
+ {
73
+ loader: 'babel-loader',
74
+ options: babelOptions,
75
+ },
76
+ ],
77
+ },
78
{
79
test: /\.js$/,
80
loader: 'babel-loader',
59
- options: {
60
- configFile: resolve(
61
- __dirname,
62
- '..',
63
- 'react-devtools-shared',
64
- 'babel.config.js',
65
- ),
66
- plugins: shouldUseDevServer
67
- ? [resolve(builtModulesDir, 'react-refresh/babel')]
68
- : [],
69
- },
81
+ options: babelOptions,
82
},
83
{
84
test: /\.css$/,
packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.js
+1
-1
@@ -214,7 +214,7 @@ function updateStyleHelper(
214
);
215
}
216
217
-function updateDisplayDensity(
217
+export function updateDisplayDensity(
218
displayDensity: DisplayDensity,
219
documentElements: DocumentElements,
220
): void {
yarn.lock
+8
@@ -14102,6 +14102,14 @@ worker-farm@^1.7.0:
14102
dependencies:
14103
errno "~0.1.7"
14104
14105
+worker-loader@^3.0.2:
14106
+ version "3.0.2"
14107
+ resolved "https://registry.yarnpkg.com/worker-loader/-/worker-loader-3.0.2.tgz#f82386a96366d24dbf6c2420f5bed04d3fe5a229"
14108
+ integrity sha512-a3Hk9/3OCKkiK00gRIenNd4pdwBQn2Hu2L39WPGqR5WlX90u++mAVK7K1i6zUQyio4zqpnaastJ7J0xCBaA3VA==
14109
+ dependencies:
14110
+ loader-utils "^2.0.0"
14111
+ schema-utils "^2.7.0"
14112
+
14113
wrap-ansi@^2.0.0:
14114
version "2.1.0"
14115
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85"