@samitouri / QOS-React / commits / eabd18c73f

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"