diff --git a/packages/react-devtools-scheduling-profiler/package.json b/packages/react-devtools-scheduling-profiler/package.json index 5aa14a59f0df9..ca2b39d40da83 100644 --- a/packages/react-devtools-scheduling-profiler/package.json +++ b/packages/react-devtools-scheduling-profiler/package.json @@ -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" } } diff --git a/packages/react-devtools-scheduling-profiler/src/App.js b/packages/react-devtools-scheduling-profiler/src/App.js index 83d9238851344..9a27253b6c032 100644 --- a/packages/react-devtools-scheduling-profiler/src/App.js +++ b/packages/react-devtools-scheduling-profiler/src/App.js @@ -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 ( - -
-
- -
+
+
+
- +
); } diff --git a/packages/react-devtools-scheduling-profiler/src/ImportButton.css b/packages/react-devtools-scheduling-profiler/src/ImportButton.css index f0cb09ef4df38..e9054b7749692 100644 --- a/packages/react-devtools-scheduling-profiler/src/ImportButton.css +++ b/packages/react-devtools-scheduling-profiler/src/ImportButton.css @@ -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); -} diff --git a/packages/react-devtools-scheduling-profiler/src/ImportButton.js b/packages/react-devtools-scheduling-profiler/src/ImportButton.js index 2f6ba344a07e3..acd382b08ad05 100644 --- a/packages/react-devtools-scheduling-profiler/src/ImportButton.js +++ b/packages/react-devtools-scheduling-profiler/src/ImportButton.js @@ -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(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: ( - <> -
The profiling data you selected cannot be imported.
- {error !== null && ( -
{error.message}
- )} - - ), - }); - } + 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) { diff --git a/packages/react-devtools-scheduling-profiler/src/SchedulingProfiler.css b/packages/react-devtools-scheduling-profiler/src/SchedulingProfiler.css index b734a25e1ea83..e16279192b83e 100644 --- a/packages/react-devtools-scheduling-profiler/src/SchedulingProfiler.css +++ b/packages/react-devtools-scheduling-profiler/src/SchedulingProfiler.css @@ -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; diff --git a/packages/react-devtools-scheduling-profiler/src/SchedulingProfiler.js b/packages/react-devtools-scheduling-profiler/src/SchedulingProfiler.js index 19079a4990776..2f232b6bbe183 100644 --- a/packages/react-devtools-scheduling-profiler/src/SchedulingProfiler.js +++ b/packages/react-devtools-scheduling-profiler/src/SchedulingProfiler.js @@ -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( - null, - ); +type DataResource = Resource; + +function createDataResourceFromImportedFile(file: File): DataResource { + return createResource( + () => { + return new Promise((resolve, reject) => { + const worker: Worker = new (ImportWorker: any)(); - const view = profilerData ? ( - - ) : ( - + 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(null); + + const handleFileSelect = useCallback((file: File) => { + setDataResource(createDataResourceFromImportedFile(file)); + }, []); return (
@@ -38,22 +68,26 @@ export function SchedulingProfiler(_: {||}) { Concurrent Mode Profiler
- +
- {view} - + {dataResource ? ( + }> + + + ) : ( + + )}
); } -type WelcomeProps = {| - onDataImported: (profilerData: ReactProfilerData) => void, -|}; - -const Welcome = ({onDataImported}: WelcomeProps) => ( +const Welcome = ({onFileSelect}: {|onFileSelect: (file: File) => void|}) => (
(
Welcome!
Click the import button - to import a Chrome + to import a Chrome performance profile.
); + +const ProcessingData = () => ( +
+
Processing data...
+
This should only take a minute.
+
+); + +const CouldNotLoadProfile = ({error, onFileSelect}) => ( +
+
Could not load profile
+ {error.message && ( +
+
{error.message}
+
+ )} +
+ Try importing + + another Chrome performance profile. +
+
+); + +const DataResourceComponent = ({ + dataResource, + onFileSelect, +}: {| + dataResource: DataResource, + onFileSelect: (file: File) => void, +|}) => { + const dataOrError = dataResource.read(); + if (dataOrError instanceof Error) { + return ( + + ); + } + return ; +}; diff --git a/packages/react-devtools-scheduling-profiler/src/hooks.js b/packages/react-devtools-scheduling-profiler/src/hooks.js index 13bd7508a288e..6096d6b950546 100644 --- a/packages/react-devtools-scheduling-profiler/src/hooks.js +++ b/packages/react-devtools-scheduling-profiler/src/hooks.js @@ -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); + }, []); +} diff --git a/packages/react-devtools-scheduling-profiler/src/import-worker/InvalidProfileError.js b/packages/react-devtools-scheduling-profiler/src/import-worker/InvalidProfileError.js new file mode 100644 index 0000000000000..a6c31c941c110 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/import-worker/InvalidProfileError.js @@ -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 {} diff --git a/packages/react-devtools-scheduling-profiler/src/utils/__tests__/__snapshots__/preprocessData-test.js.snap b/packages/react-devtools-scheduling-profiler/src/import-worker/__tests__/__snapshots__/preprocessData-test.js.snap similarity index 100% rename from packages/react-devtools-scheduling-profiler/src/utils/__tests__/__snapshots__/preprocessData-test.js.snap rename to packages/react-devtools-scheduling-profiler/src/import-worker/__tests__/__snapshots__/preprocessData-test.js.snap diff --git a/packages/react-devtools-scheduling-profiler/src/utils/__tests__/preprocessData-test.js b/packages/react-devtools-scheduling-profiler/src/import-worker/__tests__/preprocessData-test.js similarity index 100% rename from packages/react-devtools-scheduling-profiler/src/utils/__tests__/preprocessData-test.js rename to packages/react-devtools-scheduling-profiler/src/import-worker/__tests__/preprocessData-test.js diff --git a/packages/react-devtools-scheduling-profiler/src/import-worker/import.worker.js b/packages/react-devtools-scheduling-profiler/src/import-worker/import.worker.js new file mode 100644 index 0000000000000..118490e2effe5 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/import-worker/import.worker.js @@ -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, + }); + } + } +}; diff --git a/packages/react-devtools-scheduling-profiler/src/utils/preprocessData.js b/packages/react-devtools-scheduling-profiler/src/import-worker/preprocessData.js similarity index 96% rename from packages/react-devtools-scheduling-profiler/src/utils/preprocessData.js rename to packages/react-devtools-scheduling-profiler/src/import-worker/preprocessData.js index 402ff9ea15756..e5239fd78d1fa 100644 --- a/packages/react-devtools-scheduling-profiler/src/utils/preprocessData.js +++ b/packages/react-devtools-scheduling-profiler/src/import-worker/preprocessData.js @@ -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({ diff --git a/packages/react-devtools-scheduling-profiler/src/utils/readInputData.js b/packages/react-devtools-scheduling-profiler/src/import-worker/readInputData.js similarity index 74% rename from packages/react-devtools-scheduling-profiler/src/utils/readInputData.js rename to packages/react-devtools-scheduling-profiler/src/import-worker/readInputData.js index fe46337f0f0e1..2280e269961bb 100644 --- a/packages/react-devtools-scheduling-profiler/src/utils/readInputData.js +++ b/packages/react-devtools-scheduling-profiler/src/import-worker/readInputData.js @@ -8,13 +8,12 @@ */ import nullthrows from 'nullthrows'; +import InvalidProfileError from './InvalidProfileError'; export const readInputData = (file: File): Promise => { 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 => { 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); diff --git a/packages/react-devtools-scheduling-profiler/webpack.config.js b/packages/react-devtools-scheduling-profiler/webpack.config.js index 60d6e4467f7d3..6f7028eea26c3 100644 --- a/packages/react-devtools-scheduling-profiler/webpack.config.js +++ b/packages/react-devtools-scheduling-profiler/webpack.config.js @@ -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$/, diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.js b/packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.js index d13fc31b57fc2..2a89a9fe679b7 100644 --- a/packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.js @@ -214,7 +214,7 @@ function updateStyleHelper( ); } -function updateDisplayDensity( +export function updateDisplayDensity( displayDensity: DisplayDensity, documentElements: DocumentElements, ): void { diff --git a/yarn.lock b/yarn.lock index 1916f50052d8c..851c8908e5331 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"