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:
parent
38a512acad
commit
eabd18c73f
|
@ -28,6 +28,7 @@
|
|||
"url-loader": "^4.1.0",
|
||||
"webpack": "^4.44.1",
|
||||
"webpack-cli": "^3.3.12",
|
||||
"webpack-dev-server": "^3.11.0"
|
||||
"webpack-dev-server": "^3.11.0",
|
||||
"worker-loader": "^3.0.2"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,23 +14,21 @@ import '@reach/tooltip/styles.css';
|
|||
|
||||
import * as React from 'react';
|
||||
|
||||
import {ModalDialogContextController} from 'react-devtools-shared/src/devtools/views/ModalDialog';
|
||||
import {SchedulingProfiler} from './SchedulingProfiler';
|
||||
import {useBrowserTheme} from './hooks';
|
||||
import {useBrowserTheme, useDisplayDensity} from './hooks';
|
||||
|
||||
import styles from './App.css';
|
||||
import 'react-devtools-shared/src/devtools/views/root.css';
|
||||
|
||||
export default function App() {
|
||||
useBrowserTheme();
|
||||
useDisplayDensity();
|
||||
|
||||
return (
|
||||
<ModalDialogContextController>
|
||||
<div className={styles.DevTools}>
|
||||
<div className={styles.TabContent}>
|
||||
<SchedulingProfiler />
|
||||
</div>
|
||||
<div className={styles.DevTools}>
|
||||
<div className={styles.TabContent}>
|
||||
<SchedulingProfiler />
|
||||
</div>
|
||||
</ModalDialogContextController>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -8,10 +8,3 @@
|
|||
overflow: hidden;
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -7,61 +7,32 @@
|
|||
* @flow
|
||||
*/
|
||||
|
||||
import type {TimelineEvent} from '@elg/speedscope';
|
||||
import type {ReactProfilerData} from './types';
|
||||
|
||||
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 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';
|
||||
|
||||
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 {dispatch: modalDialogDispatch} = useContext(ModalDialogContext);
|
||||
|
||||
const handleFiles = useCallback(async () => {
|
||||
const handleFiles = useCallback(() => {
|
||||
const input = inputRef.current;
|
||||
if (input === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.files.length > 0) {
|
||||
try {
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
});
|
||||
}
|
||||
onFileSelect(input.files[0]);
|
||||
}
|
||||
|
||||
// Reset input element to allow the same file to be re-imported
|
||||
input.value = '';
|
||||
}, [onDataImported, modalDialogDispatch]);
|
||||
}, [onFileSelect]);
|
||||
|
||||
const uploadData = useCallback(() => {
|
||||
if (inputRef.current !== null) {
|
||||
|
|
|
@ -28,6 +28,13 @@
|
|||
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 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
|
|
@ -7,30 +7,60 @@
|
|||
* @flow
|
||||
*/
|
||||
|
||||
import type {Resource} from 'react-devtools-shared/src/devtools/cache';
|
||||
import type {ReactProfilerData} from './types';
|
||||
import type {ImportWorkerOutputData} from './import-worker/import.worker';
|
||||
|
||||
import * as React from 'react';
|
||||
import {useState} from 'react';
|
||||
|
||||
import ImportButton from './ImportButton';
|
||||
import {ModalDialog} from 'react-devtools-shared/src/devtools/views/ModalDialog';
|
||||
import {Suspense, useCallback, useState} from 'react';
|
||||
import {createResource} from 'react-devtools-shared/src/devtools/cache';
|
||||
import ReactLogo from 'react-devtools-shared/src/devtools/views/ReactLogo';
|
||||
|
||||
import ImportButton from './ImportButton';
|
||||
import CanvasPage from './CanvasPage';
|
||||
import ImportWorker from './import-worker/import.worker';
|
||||
|
||||
import profilerBrowser from './assets/profilerBrowser.png';
|
||||
import styles from './SchedulingProfiler.css';
|
||||
|
||||
export function SchedulingProfiler(_: {||}) {
|
||||
const [profilerData, setProfilerData] = useState<ReactProfilerData | null>(
|
||||
null,
|
||||
);
|
||||
type DataResource = Resource<void, File, ReactProfilerData | Error>;
|
||||
|
||||
const view = profilerData ? (
|
||||
<CanvasPage profilerData={profilerData} />
|
||||
) : (
|
||||
<Welcome onDataImported={setProfilerData} />
|
||||
function createDataResourceFromImportedFile(file: File): DataResource {
|
||||
return createResource(
|
||||
() => {
|
||||
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 (
|
||||
<div className={styles.SchedulingProfiler}>
|
||||
|
@ -38,22 +68,26 @@ export function SchedulingProfiler(_: {||}) {
|
|||
<ReactLogo />
|
||||
<span className={styles.AppName}>Concurrent Mode Profiler</span>
|
||||
<div className={styles.VRule} />
|
||||
<ImportButton onDataImported={setProfilerData} />
|
||||
<ImportButton onFileSelect={handleFileSelect} />
|
||||
<div className={styles.Spacer} />
|
||||
</div>
|
||||
<div className={styles.Content}>
|
||||
{view}
|
||||
<ModalDialog />
|
||||
{dataResource ? (
|
||||
<Suspense fallback={<ProcessingData />}>
|
||||
<DataResourceComponent
|
||||
dataResource={dataResource}
|
||||
onFileSelect={handleFileSelect}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<Welcome onFileSelect={handleFileSelect} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type WelcomeProps = {|
|
||||
onDataImported: (profilerData: ReactProfilerData) => void,
|
||||
|};
|
||||
|
||||
const Welcome = ({onDataImported}: WelcomeProps) => (
|
||||
const Welcome = ({onFileSelect}: {|onFileSelect: (file: File) => void|}) => (
|
||||
<div className={styles.EmptyStateContainer}>
|
||||
<div className={styles.ScreenshotWrapper}>
|
||||
<img
|
||||
|
@ -65,8 +99,47 @@ const Welcome = ({onDataImported}: WelcomeProps) => (
|
|||
<div className={styles.Header}>Welcome!</div>
|
||||
<div className={styles.Row}>
|
||||
Click the import button
|
||||
<ImportButton onDataImported={onDataImported} /> to import a Chrome
|
||||
<ImportButton onFileSelect={onFileSelect} /> to import a Chrome
|
||||
performance profile.
|
||||
</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} />;
|
||||
};
|
||||
|
|
|
@ -13,7 +13,10 @@ import {
|
|||
useLayoutEffect,
|
||||
} 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';
|
||||
|
||||
export type BrowserTheme = 'dark' | 'light';
|
||||
|
@ -57,3 +60,10 @@ export function useBrowserTheme(): void {
|
|||
}
|
||||
}, [theme]);
|
||||
}
|
||||
|
||||
export function useDisplayDensity(): void {
|
||||
useLayoutEffect(() => {
|
||||
const documentElements = [((document.documentElement: any): HTMLElement)];
|
||||
updateDisplayDensity('comfortable', documentElements);
|
||||
}, []);
|
||||
}
|
||||
|
|
13
packages/react-devtools-scheduling-profiler/src/import-worker/InvalidProfileError.js
vendored
Normal file
13
packages/react-devtools-scheduling-profiler/src/import-worker/InvalidProfileError.js
vendored
Normal 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 {}
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
|
@ -22,6 +22,7 @@ import type {
|
|||
} from '../types';
|
||||
|
||||
import {REACT_TOTAL_NUM_LANES} from '../constants';
|
||||
import InvalidProfileError from './InvalidProfileError';
|
||||
|
||||
type MeasureStackElement = {|
|
||||
type: ReactMeasureType,
|
||||
|
@ -144,7 +145,7 @@ function throwIfIncomplete(
|
|||
if (lastIndex >= 0) {
|
||||
const last = stack[lastIndex];
|
||||
if (last.stopTime === undefined && last.type === type) {
|
||||
throw new Error(
|
||||
throw new InvalidProfileError(
|
||||
`Unexpected type "${type}" started before "${last.type}" completed.`,
|
||||
);
|
||||
}
|
||||
|
@ -369,7 +370,7 @@ function processTimelineEvent(
|
|||
|
||||
// Unrecognized event
|
||||
else {
|
||||
throw new Error(
|
||||
throw new InvalidProfileError(
|
||||
`Unrecognized event ${JSON.stringify(
|
||||
event,
|
||||
)}! This is likely a bug in this profiler tool.`,
|
||||
|
@ -378,7 +379,16 @@ function processTimelineEvent(
|
|||
}
|
||||
|
||||
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 speedscopeFlamechart = new SpeedscopeFlamechart({
|
|
@ -8,13 +8,12 @@
|
|||
*/
|
||||
|
||||
import nullthrows from 'nullthrows';
|
||||
import InvalidProfileError from './InvalidProfileError';
|
||||
|
||||
export const readInputData = (file: File): Promise<string> => {
|
||||
if (!file.name.endsWith('.json')) {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
'Invalid file type. Only JSON performance profiles are supported',
|
||||
),
|
||||
throw new InvalidProfileError(
|
||||
'Invalid file type. Only JSON performance profiles are supported',
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -26,7 +25,7 @@ export const readInputData = (file: File): Promise<string> => {
|
|||
if (typeof result === 'string') {
|
||||
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);
|
|
@ -26,6 +26,18 @@ const DEVTOOLS_VERSION = getVersionString();
|
|||
|
||||
const imageInlineSizeLimit = 10000;
|
||||
|
||||
const babelOptions = {
|
||||
configFile: resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'react-devtools-shared',
|
||||
'babel.config.js',
|
||||
),
|
||||
plugins: shouldUseDevServer
|
||||
? [resolve(builtModulesDir, 'react-refresh/babel')]
|
||||
: [],
|
||||
};
|
||||
|
||||
const config = {
|
||||
mode: __DEV__ ? 'development' : 'production',
|
||||
devtool: __DEV__ ? 'cheap-module-eval-source-map' : false,
|
||||
|
@ -53,20 +65,20 @@ const config = {
|
|||
].filter(Boolean),
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.worker\.js$/,
|
||||
use: [
|
||||
'worker-loader',
|
||||
{
|
||||
loader: 'babel-loader',
|
||||
options: babelOptions,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.js$/,
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
configFile: resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'react-devtools-shared',
|
||||
'babel.config.js',
|
||||
),
|
||||
plugins: shouldUseDevServer
|
||||
? [resolve(builtModulesDir, 'react-refresh/babel')]
|
||||
: [],
|
||||
},
|
||||
options: babelOptions,
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
|
|
|
@ -214,7 +214,7 @@ function updateStyleHelper(
|
|||
);
|
||||
}
|
||||
|
||||
function updateDisplayDensity(
|
||||
export function updateDisplayDensity(
|
||||
displayDensity: DisplayDensity,
|
||||
documentElements: DocumentElements,
|
||||
): void {
|
||||
|
|
|
@ -14102,6 +14102,14 @@ worker-farm@^1.7.0:
|
|||
dependencies:
|
||||
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:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85"
|
||||
|
|
Loading…
Reference in New Issue