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
This commit is contained in:
E-Liang Tan 2020-09-04 22:57:32 +08:00 committed by GitHub
parent 38a512acad
commit eabd18c73f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 245 additions and 93 deletions

View File

@ -28,6 +28,7 @@
"url-loader": "^4.1.0", "url-loader": "^4.1.0",
"webpack": "^4.44.1", "webpack": "^4.44.1",
"webpack-cli": "^3.3.12", "webpack-cli": "^3.3.12",
"webpack-dev-server": "^3.11.0" "webpack-dev-server": "^3.11.0",
"worker-loader": "^3.0.2"
} }
} }

View File

@ -14,23 +14,21 @@ import '@reach/tooltip/styles.css';
import * as React from 'react'; import * as React from 'react';
import {ModalDialogContextController} from 'react-devtools-shared/src/devtools/views/ModalDialog';
import {SchedulingProfiler} from './SchedulingProfiler'; import {SchedulingProfiler} from './SchedulingProfiler';
import {useBrowserTheme} from './hooks'; import {useBrowserTheme, useDisplayDensity} from './hooks';
import styles from './App.css'; import styles from './App.css';
import 'react-devtools-shared/src/devtools/views/root.css'; import 'react-devtools-shared/src/devtools/views/root.css';
export default function App() { export default function App() {
useBrowserTheme(); useBrowserTheme();
useDisplayDensity();
return ( return (
<ModalDialogContextController> <div className={styles.DevTools}>
<div className={styles.DevTools}> <div className={styles.TabContent}>
<div className={styles.TabContent}> <SchedulingProfiler />
<SchedulingProfiler />
</div>
</div> </div>
</ModalDialogContextController> </div>
); );
} }

View File

@ -8,10 +8,3 @@
overflow: hidden; overflow: hidden;
clip: rect(1px, 1px, 1px, 1px); clip: rect(1px, 1px, 1px, 1px);
} }
.ErrorMessage {
margin: 0.5rem 0;
color: var(--color-dim);
font-family: var(--font-family-monospace);
font-size: var(--font-size-monospace-normal);
}

View File

@ -7,61 +7,32 @@
* @flow * @flow
*/ */
import type {TimelineEvent} from '@elg/speedscope';
import type {ReactProfilerData} from './types';
import * as React from 'react'; import * as React from 'react';
import {useCallback, useContext, useRef} from 'react'; import {useCallback, useRef} from 'react';
import Button from 'react-devtools-shared/src/devtools/views/Button'; import Button from 'react-devtools-shared/src/devtools/views/Button';
import ButtonIcon from 'react-devtools-shared/src/devtools/views/ButtonIcon'; import ButtonIcon from 'react-devtools-shared/src/devtools/views/ButtonIcon';
import {ModalDialogContext} from 'react-devtools-shared/src/devtools/views/ModalDialog';
import preprocessData from './utils/preprocessData';
import {readInputData} from './utils/readInputData';
import styles from './ImportButton.css'; import styles from './ImportButton.css';
type Props = {| type Props = {|
onDataImported: (profilerData: ReactProfilerData) => void, onFileSelect: (file: File) => void,
|}; |};
export default function ImportButton({onDataImported}: Props) { export default function ImportButton({onFileSelect}: Props) {
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const {dispatch: modalDialogDispatch} = useContext(ModalDialogContext);
const handleFiles = useCallback(async () => { const handleFiles = useCallback(() => {
const input = inputRef.current; const input = inputRef.current;
if (input === null) { if (input === null) {
return; return;
} }
if (input.files.length > 0) { if (input.files.length > 0) {
try { onFileSelect(input.files[0]);
const readFile = await readInputData(input.files[0]);
const events: TimelineEvent[] = JSON.parse(readFile);
if (events.length > 0) {
onDataImported(preprocessData(events));
}
} catch (error) {
modalDialogDispatch({
type: 'SHOW',
title: 'Import failed',
content: (
<>
<div>The profiling data you selected cannot be imported.</div>
{error !== null && (
<div className={styles.ErrorMessage}>{error.message}</div>
)}
</>
),
});
}
} }
// Reset input element to allow the same file to be re-imported // Reset input element to allow the same file to be re-imported
input.value = ''; input.value = '';
}, [onDataImported, modalDialogDispatch]); }, [onFileSelect]);
const uploadData = useCallback(() => { const uploadData = useCallback(() => {
if (inputRef.current !== null) { if (inputRef.current !== null) {

View File

@ -28,6 +28,13 @@
text-align: center; text-align: center;
} }
.ErrorMessage {
margin: 0.5rem 0;
color: var(--color-dim);
font-family: var(--font-family-monospace);
font-size: var(--font-size-monospace-normal);
}
.Row { .Row {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@ -7,30 +7,60 @@
* @flow * @flow
*/ */
import type {Resource} from 'react-devtools-shared/src/devtools/cache';
import type {ReactProfilerData} from './types'; import type {ReactProfilerData} from './types';
import type {ImportWorkerOutputData} from './import-worker/import.worker';
import * as React from 'react'; import * as React from 'react';
import {useState} from 'react'; import {Suspense, useCallback, useState} from 'react';
import {createResource} from 'react-devtools-shared/src/devtools/cache';
import ImportButton from './ImportButton';
import {ModalDialog} from 'react-devtools-shared/src/devtools/views/ModalDialog';
import ReactLogo from 'react-devtools-shared/src/devtools/views/ReactLogo'; import ReactLogo from 'react-devtools-shared/src/devtools/views/ReactLogo';
import ImportButton from './ImportButton';
import CanvasPage from './CanvasPage'; import CanvasPage from './CanvasPage';
import ImportWorker from './import-worker/import.worker';
import profilerBrowser from './assets/profilerBrowser.png'; import profilerBrowser from './assets/profilerBrowser.png';
import styles from './SchedulingProfiler.css'; import styles from './SchedulingProfiler.css';
export function SchedulingProfiler(_: {||}) { type DataResource = Resource<void, File, ReactProfilerData | Error>;
const [profilerData, setProfilerData] = useState<ReactProfilerData | null>(
null,
);
const view = profilerData ? ( function createDataResourceFromImportedFile(file: File): DataResource {
<CanvasPage profilerData={profilerData} /> return createResource(
) : ( () => {
<Welcome onDataImported={setProfilerData} /> return new Promise<ReactProfilerData | Error>((resolve, reject) => {
const worker: Worker = new (ImportWorker: any)();
worker.onmessage = function(event) {
const data = ((event.data: any): ImportWorkerOutputData);
switch (data.status) {
case 'SUCCESS':
resolve(data.processedData);
break;
case 'INVALID_PROFILE_ERROR':
resolve(data.error);
break;
case 'UNEXPECTED_ERROR':
reject(data.error);
break;
}
worker.terminate();
};
worker.postMessage({file});
});
},
() => file,
{useWeakMap: true},
); );
}
export function SchedulingProfiler(_: {||}) {
const [dataResource, setDataResource] = useState<DataResource | null>(null);
const handleFileSelect = useCallback((file: File) => {
setDataResource(createDataResourceFromImportedFile(file));
}, []);
return ( return (
<div className={styles.SchedulingProfiler}> <div className={styles.SchedulingProfiler}>
@ -38,22 +68,26 @@ export function SchedulingProfiler(_: {||}) {
<ReactLogo /> <ReactLogo />
<span className={styles.AppName}>Concurrent Mode Profiler</span> <span className={styles.AppName}>Concurrent Mode Profiler</span>
<div className={styles.VRule} /> <div className={styles.VRule} />
<ImportButton onDataImported={setProfilerData} /> <ImportButton onFileSelect={handleFileSelect} />
<div className={styles.Spacer} /> <div className={styles.Spacer} />
</div> </div>
<div className={styles.Content}> <div className={styles.Content}>
{view} {dataResource ? (
<ModalDialog /> <Suspense fallback={<ProcessingData />}>
<DataResourceComponent
dataResource={dataResource}
onFileSelect={handleFileSelect}
/>
</Suspense>
) : (
<Welcome onFileSelect={handleFileSelect} />
)}
</div> </div>
</div> </div>
); );
} }
type WelcomeProps = {| const Welcome = ({onFileSelect}: {|onFileSelect: (file: File) => void|}) => (
onDataImported: (profilerData: ReactProfilerData) => void,
|};
const Welcome = ({onDataImported}: WelcomeProps) => (
<div className={styles.EmptyStateContainer}> <div className={styles.EmptyStateContainer}>
<div className={styles.ScreenshotWrapper}> <div className={styles.ScreenshotWrapper}>
<img <img
@ -65,8 +99,47 @@ const Welcome = ({onDataImported}: WelcomeProps) => (
<div className={styles.Header}>Welcome!</div> <div className={styles.Header}>Welcome!</div>
<div className={styles.Row}> <div className={styles.Row}>
Click the import button Click the import button
<ImportButton onDataImported={onDataImported} /> to import a Chrome <ImportButton onFileSelect={onFileSelect} /> to import a Chrome
performance profile. performance profile.
</div> </div>
</div> </div>
); );
const ProcessingData = () => (
<div className={styles.EmptyStateContainer}>
<div className={styles.Header}>Processing data...</div>
<div className={styles.Row}>This should only take a minute.</div>
</div>
);
const CouldNotLoadProfile = ({error, onFileSelect}) => (
<div className={styles.EmptyStateContainer}>
<div className={styles.Header}>Could not load profile</div>
{error.message && (
<div className={styles.Row}>
<div className={styles.ErrorMessage}>{error.message}</div>
</div>
)}
<div className={styles.Row}>
Try importing
<ImportButton onFileSelect={onFileSelect} />
another Chrome performance profile.
</div>
</div>
);
const DataResourceComponent = ({
dataResource,
onFileSelect,
}: {|
dataResource: DataResource,
onFileSelect: (file: File) => void,
|}) => {
const dataOrError = dataResource.read();
if (dataOrError instanceof Error) {
return (
<CouldNotLoadProfile error={dataOrError} onFileSelect={onFileSelect} />
);
}
return <CanvasPage profilerData={dataOrError} />;
};

View File

@ -13,7 +13,10 @@ import {
useLayoutEffect, useLayoutEffect,
} from 'react'; } from 'react';
import {updateThemeVariables} from 'react-devtools-shared/src/devtools/views/Settings/SettingsContext'; import {
updateDisplayDensity,
updateThemeVariables,
} from 'react-devtools-shared/src/devtools/views/Settings/SettingsContext';
import {enableDarkMode} from './SchedulingProfilerFeatureFlags'; import {enableDarkMode} from './SchedulingProfilerFeatureFlags';
export type BrowserTheme = 'dark' | 'light'; export type BrowserTheme = 'dark' | 'light';
@ -57,3 +60,10 @@ export function useBrowserTheme(): void {
} }
}, [theme]); }, [theme]);
} }
export function useDisplayDensity(): void {
useLayoutEffect(() => {
const documentElements = [((document.documentElement: any): HTMLElement)];
updateDisplayDensity('comfortable', documentElements);
}, []);
}

View File

@ -0,0 +1,13 @@
/**
* 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
*/
/**
* An error thrown when an invalid profile could not be processed.
*/
export default class InvalidProfileError extends Error {}

View File

@ -0,0 +1,57 @@
/**
* 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 'regenerator-runtime/runtime';
import type {TimelineEvent} from '@elg/speedscope';
import type {ReactProfilerData} from '../types';
import preprocessData from './preprocessData';
import {readInputData} from './readInputData';
import InvalidProfileError from './InvalidProfileError';
declare var self: DedicatedWorkerGlobalScope;
type ImportWorkerInputData = {|
file: File,
|};
export type ImportWorkerOutputData =
| {|status: 'SUCCESS', processedData: ReactProfilerData|}
| {|status: 'INVALID_PROFILE_ERROR', error: Error|}
| {|status: 'UNEXPECTED_ERROR', error: Error|};
self.onmessage = async function(event: MessageEvent) {
const {file} = ((event.data: any): ImportWorkerInputData);
try {
const readFile = await readInputData(file);
const events: TimelineEvent[] = JSON.parse(readFile);
if (events.length === 0) {
throw new InvalidProfileError('No profiling data found in file.');
}
self.postMessage({
status: 'SUCCESS',
processedData: preprocessData(events),
});
} catch (error) {
if (error instanceof InvalidProfileError) {
self.postMessage({
status: 'INVALID_PROFILE_ERROR',
error,
});
} else {
self.postMessage({
status: 'UNEXPECTED_ERROR',
error,
});
}
}
};

View File

@ -22,6 +22,7 @@ import type {
} from '../types'; } from '../types';
import {REACT_TOTAL_NUM_LANES} from '../constants'; import {REACT_TOTAL_NUM_LANES} from '../constants';
import InvalidProfileError from './InvalidProfileError';
type MeasureStackElement = {| type MeasureStackElement = {|
type: ReactMeasureType, type: ReactMeasureType,
@ -144,7 +145,7 @@ function throwIfIncomplete(
if (lastIndex >= 0) { if (lastIndex >= 0) {
const last = stack[lastIndex]; const last = stack[lastIndex];
if (last.stopTime === undefined && last.type === type) { if (last.stopTime === undefined && last.type === type) {
throw new Error( throw new InvalidProfileError(
`Unexpected type "${type}" started before "${last.type}" completed.`, `Unexpected type "${type}" started before "${last.type}" completed.`,
); );
} }
@ -369,7 +370,7 @@ function processTimelineEvent(
// Unrecognized event // Unrecognized event
else { else {
throw new Error( throw new InvalidProfileError(
`Unrecognized event ${JSON.stringify( `Unrecognized event ${JSON.stringify(
event, event,
)}! This is likely a bug in this profiler tool.`, )}! This is likely a bug in this profiler tool.`,
@ -378,7 +379,16 @@ function processTimelineEvent(
} }
function preprocessFlamechart(rawData: TimelineEvent[]): Flamechart { function preprocessFlamechart(rawData: TimelineEvent[]): Flamechart {
const parsedData = importFromChromeTimeline(rawData, 'react-devtools'); let parsedData;
try {
parsedData = importFromChromeTimeline(rawData, 'react-devtools');
} catch (error) {
// Assume any Speedscope errors are caused by bad profiles
const errorToRethrow = new InvalidProfileError(error.message);
errorToRethrow.stack = error.stack;
throw errorToRethrow;
}
const profile = parsedData.profiles[0]; // TODO: Choose the main CPU thread only const profile = parsedData.profiles[0]; // TODO: Choose the main CPU thread only
const speedscopeFlamechart = new SpeedscopeFlamechart({ const speedscopeFlamechart = new SpeedscopeFlamechart({

View File

@ -8,13 +8,12 @@
*/ */
import nullthrows from 'nullthrows'; import nullthrows from 'nullthrows';
import InvalidProfileError from './InvalidProfileError';
export const readInputData = (file: File): Promise<string> => { export const readInputData = (file: File): Promise<string> => {
if (!file.name.endsWith('.json')) { if (!file.name.endsWith('.json')) {
return Promise.reject( throw new InvalidProfileError(
new Error( 'Invalid file type. Only JSON performance profiles are supported',
'Invalid file type. Only JSON performance profiles are supported',
),
); );
} }
@ -26,7 +25,7 @@ export const readInputData = (file: File): Promise<string> => {
if (typeof result === 'string') { if (typeof result === 'string') {
resolve(result); resolve(result);
} }
reject(new Error('Input file was not read as a string')); reject(new InvalidProfileError('Input file was not read as a string'));
}; };
fileReader.onerror = () => reject(fileReader.error); fileReader.onerror = () => reject(fileReader.error);

View File

@ -26,6 +26,18 @@ const DEVTOOLS_VERSION = getVersionString();
const imageInlineSizeLimit = 10000; const imageInlineSizeLimit = 10000;
const babelOptions = {
configFile: resolve(
__dirname,
'..',
'react-devtools-shared',
'babel.config.js',
),
plugins: shouldUseDevServer
? [resolve(builtModulesDir, 'react-refresh/babel')]
: [],
};
const config = { const config = {
mode: __DEV__ ? 'development' : 'production', mode: __DEV__ ? 'development' : 'production',
devtool: __DEV__ ? 'cheap-module-eval-source-map' : false, devtool: __DEV__ ? 'cheap-module-eval-source-map' : false,
@ -53,20 +65,20 @@ const config = {
].filter(Boolean), ].filter(Boolean),
module: { module: {
rules: [ rules: [
{
test: /\.worker\.js$/,
use: [
'worker-loader',
{
loader: 'babel-loader',
options: babelOptions,
},
],
},
{ {
test: /\.js$/, test: /\.js$/,
loader: 'babel-loader', loader: 'babel-loader',
options: { options: babelOptions,
configFile: resolve(
__dirname,
'..',
'react-devtools-shared',
'babel.config.js',
),
plugins: shouldUseDevServer
? [resolve(builtModulesDir, 'react-refresh/babel')]
: [],
},
}, },
{ {
test: /\.css$/, test: /\.css$/,

View File

@ -214,7 +214,7 @@ function updateStyleHelper(
); );
} }
function updateDisplayDensity( export function updateDisplayDensity(
displayDensity: DisplayDensity, displayDensity: DisplayDensity,
documentElements: DocumentElements, documentElements: DocumentElements,
): void { ): void {

View File

@ -14102,6 +14102,14 @@ worker-farm@^1.7.0:
dependencies: dependencies:
errno "~0.1.7" errno "~0.1.7"
worker-loader@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/worker-loader/-/worker-loader-3.0.2.tgz#f82386a96366d24dbf6c2420f5bed04d3fe5a229"
integrity sha512-a3Hk9/3OCKkiK00gRIenNd4pdwBQn2Hu2L39WPGqR5WlX90u++mAVK7K1i6zUQyio4zqpnaastJ7J0xCBaA3VA==
dependencies:
loader-utils "^2.0.0"
schema-utils "^2.7.0"
wrap-ansi@^2.0.0: wrap-ansi@^2.0.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85"