From 2bea3fb0b8f13db702da14b2b02e85127ed397a4 Mon Sep 17 00:00:00 2001 From: E-Liang Tan Date: Fri, 21 Aug 2020 02:06:28 +0800 Subject: [PATCH] Import React Concurrent Mode Profiler (#19634) Co-authored-by: Brian Vaughn Co-authored-by: Kartik Choudhary --- .eslintignore | 4 +- .gitignore | 3 +- .prettierignore | 4 +- .../README.md | 3 + .../package.json | 30 + .../src/App.js | 28 + .../src/CanvasPage.css | 7 + .../src/CanvasPage.js | 480 ++++++ .../src/EventTooltip.css | 78 + .../src/EventTooltip.js | 292 ++++ .../src/ImportPage.css | 206 +++ .../src/ImportPage.js | 119 ++ .../src/assets/logo.svg | 1 + .../src/assets/profilerBrowser.png | Bin 0 -> 77466 bytes .../src/assets/reactlogo.svg | 7 + .../src/constants.js | 11 + .../src/content-views/FlamechartView.js | 378 +++++ .../src/content-views/ReactEventsView.js | 276 ++++ .../src/content-views/ReactMeasuresView.js | 318 ++++ .../src/content-views/TimeAxisMarkersView.js | 163 ++ .../src/content-views/UserTimingMarksView.js | 230 +++ .../src/content-views/constants.js | 73 + .../src/content-views/index.js | 14 + .../utils/__tests__/colors-test.js | 93 ++ .../src/content-views/utils/colors.js | 113 ++ .../src/content-views/utils/positioning.js | 41 + .../src/context/ContextMenu.css | 10 + .../src/context/ContextMenu.js | 143 ++ .../src/context/ContextMenuItem.css | 20 + .../src/context/ContextMenuItem.js | 40 + .../src/context/Contexts.js | 87 ++ .../src/context/index.js | 15 + .../src/context/useContextMenu.js | 52 + .../src/index.css | 13 + .../src/index.js | 30 + .../src/types.js | 135 ++ .../__snapshots__/preprocessData-test.js.snap | 1380 +++++++++++++++++ .../utils/__tests__/preprocessData-test.js | 352 +++++ .../src/utils/getBatchRange.js | 44 + .../src/utils/preprocessData.js | 461 ++++++ .../src/utils/readInputData.js | 36 + .../src/utils/useSmartTooltip.js | 70 + .../src/view-base/ColorView.js | 44 + .../src/view-base/HorizontalPanAndZoomView.js | 342 ++++ .../src/view-base/ResizableSplitView.js | 311 ++++ .../src/view-base/Surface.js | 89 ++ .../src/view-base/VerticalScrollView.js | 181 +++ .../src/view-base/View.js | 274 ++++ .../src/view-base/__tests__/geometry-test.js | 272 ++++ .../src/view-base/constants.js | 13 + .../src/view-base/geometry.js | 128 ++ .../src/view-base/index.js | 18 + .../src/view-base/layouter.js | 269 ++++ .../src/view-base/useCanvasInteraction.js | 192 +++ .../src/view-base/utils/clamp.js | 17 + .../src/view-base/utils/normalizeWheel.js | 91 ++ .../webpack.config.js | 104 ++ yarn.lock | 760 ++++++++- 58 files changed, 8946 insertions(+), 19 deletions(-) create mode 100644 packages/react-devtools-scheduling-profiler/README.md create mode 100644 packages/react-devtools-scheduling-profiler/package.json create mode 100644 packages/react-devtools-scheduling-profiler/src/App.js create mode 100644 packages/react-devtools-scheduling-profiler/src/CanvasPage.css create mode 100644 packages/react-devtools-scheduling-profiler/src/CanvasPage.js create mode 100644 packages/react-devtools-scheduling-profiler/src/EventTooltip.css create mode 100644 packages/react-devtools-scheduling-profiler/src/EventTooltip.js create mode 100644 packages/react-devtools-scheduling-profiler/src/ImportPage.css create mode 100644 packages/react-devtools-scheduling-profiler/src/ImportPage.js create mode 100644 packages/react-devtools-scheduling-profiler/src/assets/logo.svg create mode 100644 packages/react-devtools-scheduling-profiler/src/assets/profilerBrowser.png create mode 100644 packages/react-devtools-scheduling-profiler/src/assets/reactlogo.svg create mode 100644 packages/react-devtools-scheduling-profiler/src/constants.js create mode 100644 packages/react-devtools-scheduling-profiler/src/content-views/FlamechartView.js create mode 100644 packages/react-devtools-scheduling-profiler/src/content-views/ReactEventsView.js create mode 100644 packages/react-devtools-scheduling-profiler/src/content-views/ReactMeasuresView.js create mode 100644 packages/react-devtools-scheduling-profiler/src/content-views/TimeAxisMarkersView.js create mode 100644 packages/react-devtools-scheduling-profiler/src/content-views/UserTimingMarksView.js create mode 100644 packages/react-devtools-scheduling-profiler/src/content-views/constants.js create mode 100644 packages/react-devtools-scheduling-profiler/src/content-views/index.js create mode 100644 packages/react-devtools-scheduling-profiler/src/content-views/utils/__tests__/colors-test.js create mode 100644 packages/react-devtools-scheduling-profiler/src/content-views/utils/colors.js create mode 100644 packages/react-devtools-scheduling-profiler/src/content-views/utils/positioning.js create mode 100644 packages/react-devtools-scheduling-profiler/src/context/ContextMenu.css create mode 100644 packages/react-devtools-scheduling-profiler/src/context/ContextMenu.js create mode 100644 packages/react-devtools-scheduling-profiler/src/context/ContextMenuItem.css create mode 100644 packages/react-devtools-scheduling-profiler/src/context/ContextMenuItem.js create mode 100644 packages/react-devtools-scheduling-profiler/src/context/Contexts.js create mode 100644 packages/react-devtools-scheduling-profiler/src/context/index.js create mode 100644 packages/react-devtools-scheduling-profiler/src/context/useContextMenu.js create mode 100644 packages/react-devtools-scheduling-profiler/src/index.css create mode 100644 packages/react-devtools-scheduling-profiler/src/index.js create mode 100644 packages/react-devtools-scheduling-profiler/src/types.js create mode 100644 packages/react-devtools-scheduling-profiler/src/utils/__tests__/__snapshots__/preprocessData-test.js.snap create mode 100644 packages/react-devtools-scheduling-profiler/src/utils/__tests__/preprocessData-test.js create mode 100644 packages/react-devtools-scheduling-profiler/src/utils/getBatchRange.js create mode 100644 packages/react-devtools-scheduling-profiler/src/utils/preprocessData.js create mode 100644 packages/react-devtools-scheduling-profiler/src/utils/readInputData.js create mode 100644 packages/react-devtools-scheduling-profiler/src/utils/useSmartTooltip.js create mode 100644 packages/react-devtools-scheduling-profiler/src/view-base/ColorView.js create mode 100644 packages/react-devtools-scheduling-profiler/src/view-base/HorizontalPanAndZoomView.js create mode 100644 packages/react-devtools-scheduling-profiler/src/view-base/ResizableSplitView.js create mode 100644 packages/react-devtools-scheduling-profiler/src/view-base/Surface.js create mode 100644 packages/react-devtools-scheduling-profiler/src/view-base/VerticalScrollView.js create mode 100644 packages/react-devtools-scheduling-profiler/src/view-base/View.js create mode 100644 packages/react-devtools-scheduling-profiler/src/view-base/__tests__/geometry-test.js create mode 100644 packages/react-devtools-scheduling-profiler/src/view-base/constants.js create mode 100644 packages/react-devtools-scheduling-profiler/src/view-base/geometry.js create mode 100644 packages/react-devtools-scheduling-profiler/src/view-base/index.js create mode 100644 packages/react-devtools-scheduling-profiler/src/view-base/layouter.js create mode 100644 packages/react-devtools-scheduling-profiler/src/view-base/useCanvasInteraction.js create mode 100644 packages/react-devtools-scheduling-profiler/src/view-base/utils/clamp.js create mode 100644 packages/react-devtools-scheduling-profiler/src/view-base/utils/normalizeWheel.js create mode 100644 packages/react-devtools-scheduling-profiler/webpack.config.js diff --git a/.eslintignore b/.eslintignore index 62ca593965..2af8176bc3 100644 --- a/.eslintignore +++ b/.eslintignore @@ -18,4 +18,6 @@ packages/react-devtools-extensions/chrome/build packages/react-devtools-extensions/firefox/build packages/react-devtools-extensions/shared/build packages/react-devtools-inline/dist -packages/react-devtools-shell/dist \ No newline at end of file +packages/react-devtools-shell/dist +packages/react-devtools-scheduling-profiler/dist +packages/react-devtools-scheduling-profiler/static \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2a70572385..5a913ac6e5 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,5 @@ packages/react-devtools-extensions/firefox/*.pem packages/react-devtools-extensions/shared/build packages/react-devtools-extensions/.tempUserDataDir packages/react-devtools-inline/dist -packages/react-devtools-shell/dist \ No newline at end of file +packages/react-devtools-shell/dist +packages/react-devtools-scheduling-profiler/dist \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index bea24210ec..80650567ef 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,4 +3,6 @@ packages/react-devtools-extensions/chrome/build packages/react-devtools-extensions/firefox/build packages/react-devtools-extensions/shared/build packages/react-devtools-inline/dist -packages/react-devtools-shell/dist \ No newline at end of file +packages/react-devtools-shell/dist +packages/react-devtools-scheduling-profiler/dist +packages/react-devtools-scheduling-profiler/static \ No newline at end of file diff --git a/packages/react-devtools-scheduling-profiler/README.md b/packages/react-devtools-scheduling-profiler/README.md new file mode 100644 index 0000000000..b3e0b07b05 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/README.md @@ -0,0 +1,3 @@ +# Experimental React Concurrent Mode Profiler + +- Deployed at: https://react-scheduling-profiler.vercel.app diff --git a/packages/react-devtools-scheduling-profiler/package.json b/packages/react-devtools-scheduling-profiler/package.json new file mode 100644 index 0000000000..2923f10ff7 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/package.json @@ -0,0 +1,30 @@ +{ + "private": true, + "name": "react-devtools-scheduling-profiler", + "version": "0.0.1", + "license": "MIT", + "scripts": { + "build": "cross-env NODE_ENV=production cross-env TARGET=remote webpack --config webpack.config.js", + "start": "cross-env NODE_ENV=development cross-env TARGET=local webpack-dev-server --open" + }, + "dependencies": { + "@elg/speedscope": "1.9.0-a6f84db", + "clipboard-js": "^0.3.6", + "memoize-one": "^5.1.1", + "nullthrows": "^1.1.1", + "pretty-ms": "^7.0.0", + "react-virtualized-auto-sizer": "^1.0.2", + "regenerator-runtime": "^0.13.7" + }, + "devDependencies": { + "babel-loader": "^8.1.0", + "css-loader": "^4.2.1", + "file-loader": "^6.0.0", + "html-webpack-plugin": "^4.3.0", + "style-loader": "^1.2.1", + "url-loader": "^4.1.0", + "webpack": "^4.44.1", + "webpack-cli": "^3.3.12", + "webpack-dev-server": "^3.11.0" + } +} diff --git a/packages/react-devtools-scheduling-profiler/src/App.js b/packages/react-devtools-scheduling-profiler/src/App.js new file mode 100644 index 0000000000..93f66ac09b --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/App.js @@ -0,0 +1,28 @@ +/** + * 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 type {ReactProfilerData} from './types'; + +import * as React from 'react'; +import {useState} from 'react'; + +import ImportPage from './ImportPage'; +import CanvasPage from './CanvasPage'; + +export default function App() { + const [profilerData, setProfilerData] = useState( + null, + ); + + if (profilerData) { + return ; + } else { + return ; + } +} diff --git a/packages/react-devtools-scheduling-profiler/src/CanvasPage.css b/packages/react-devtools-scheduling-profiler/src/CanvasPage.css new file mode 100644 index 0000000000..e5d238a0d9 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/CanvasPage.css @@ -0,0 +1,7 @@ +.CanvasPage { + position: absolute; + top: 0.5rem; + bottom: 0.5rem; + left: 0.5rem; + right: 0.5rem; +} diff --git a/packages/react-devtools-scheduling-profiler/src/CanvasPage.js b/packages/react-devtools-scheduling-profiler/src/CanvasPage.js new file mode 100644 index 0000000000..9d93005f06 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/CanvasPage.js @@ -0,0 +1,480 @@ +/** + * 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 type { + Point, + HorizontalPanAndZoomViewOnChangeCallback, +} from './view-base'; +import type { + ReactHoverContextInfo, + ReactProfilerData, + ReactMeasure, +} from './types'; + +import * as React from 'react'; +import { + Fragment, + useEffect, + useLayoutEffect, + useRef, + useState, + useCallback, +} from 'react'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import {copy} from 'clipboard-js'; +import prettyMilliseconds from 'pretty-ms'; + +import { + HorizontalPanAndZoomView, + ResizableSplitView, + Surface, + VerticalScrollView, + View, + createComposedLayout, + lastViewTakesUpRemainingSpaceLayout, + useCanvasInteraction, + verticallyStackedLayout, + zeroPoint, +} from './view-base'; +import { + FlamechartView, + ReactEventsView, + ReactMeasuresView, + TimeAxisMarkersView, + UserTimingMarksView, +} from './content-views'; +import {COLORS} from './content-views/constants'; + +import EventTooltip from './EventTooltip'; +import {ContextMenu, ContextMenuItem, useContextMenu} from './context'; +import {getBatchRange} from './utils/getBatchRange'; + +import styles from './CanvasPage.css'; + +const CONTEXT_MENU_ID = 'canvas'; + +type ContextMenuContextData = {| + data: ReactProfilerData, + hoveredEvent: ReactHoverContextInfo | null, +|}; + +type Props = {| + profilerData: ReactProfilerData, +|}; + +function CanvasPage({profilerData}: Props) { + return ( +
+ + {({height, width}: {height: number, width: number}) => ( + + )} + +
+ ); +} + +const copySummary = (data: ReactProfilerData, measure: ReactMeasure) => { + const {batchUID, duration, timestamp, type} = measure; + + const [startTime, stopTime] = getBatchRange(batchUID, data); + + copy( + JSON.stringify({ + type, + timestamp: prettyMilliseconds(timestamp), + duration: prettyMilliseconds(duration), + batchDuration: prettyMilliseconds(stopTime - startTime), + }), + ); +}; + +const zoomToBatch = ( + data: ReactProfilerData, + measure: ReactMeasure, + syncedHorizontalPanAndZoomViews: HorizontalPanAndZoomView[], +) => { + const {batchUID} = measure; + const [startTime, stopTime] = getBatchRange(batchUID, data); + syncedHorizontalPanAndZoomViews.forEach(syncedView => + // Using time as range works because the views' intrinsic content size is + // based on time. + syncedView.zoomToRange(startTime, stopTime), + ); +}; + +type AutoSizedCanvasProps = {| + data: ReactProfilerData, + height: number, + width: number, +|}; + +function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) { + const canvasRef = useRef(null); + + const [isContextMenuShown, setIsContextMenuShown] = useState(false); + const [mouseLocation, setMouseLocation] = useState(zeroPoint); // DOM coordinates + const [ + hoveredEvent, + setHoveredEvent, + ] = useState(null); + + const surfaceRef = useRef(new Surface()); + const userTimingMarksViewRef = useRef(null); + const reactEventsViewRef = useRef(null); + const reactMeasuresViewRef = useRef(null); + const flamechartViewRef = useRef(null); + const syncedHorizontalPanAndZoomViewsRef = useRef( + [], + ); + + useLayoutEffect(() => { + const surface = surfaceRef.current; + const defaultFrame = {origin: zeroPoint, size: {width, height}}; + + // Clear synced views + syncedHorizontalPanAndZoomViewsRef.current = []; + + const syncAllHorizontalPanAndZoomViewStates: HorizontalPanAndZoomViewOnChangeCallback = ( + newState, + triggeringView?: HorizontalPanAndZoomView, + ) => { + syncedHorizontalPanAndZoomViewsRef.current.forEach( + syncedView => + triggeringView !== syncedView && + syncedView.setPanAndZoomState(newState), + ); + }; + + // Top content + + const topContentStack = new View( + surface, + defaultFrame, + verticallyStackedLayout, + ); + + const axisMarkersView = new TimeAxisMarkersView( + surface, + defaultFrame, + data.duration, + ); + topContentStack.addSubview(axisMarkersView); + + if (data.otherUserTimingMarks.length > 0) { + const userTimingMarksView = new UserTimingMarksView( + surface, + defaultFrame, + data.otherUserTimingMarks, + data.duration, + ); + userTimingMarksViewRef.current = userTimingMarksView; + topContentStack.addSubview(userTimingMarksView); + } + + const reactEventsView = new ReactEventsView(surface, defaultFrame, data); + reactEventsViewRef.current = reactEventsView; + topContentStack.addSubview(reactEventsView); + + const topContentHorizontalPanAndZoomView = new HorizontalPanAndZoomView( + surface, + defaultFrame, + topContentStack, + data.duration, + syncAllHorizontalPanAndZoomViewStates, + ); + syncedHorizontalPanAndZoomViewsRef.current.push( + topContentHorizontalPanAndZoomView, + ); + + // Resizable content + + const reactMeasuresView = new ReactMeasuresView( + surface, + defaultFrame, + data, + ); + reactMeasuresViewRef.current = reactMeasuresView; + const reactMeasuresVerticalScrollView = new VerticalScrollView( + surface, + defaultFrame, + reactMeasuresView, + ); + const reactMeasuresHorizontalPanAndZoomView = new HorizontalPanAndZoomView( + surface, + defaultFrame, + reactMeasuresVerticalScrollView, + data.duration, + syncAllHorizontalPanAndZoomViewStates, + ); + syncedHorizontalPanAndZoomViewsRef.current.push( + reactMeasuresHorizontalPanAndZoomView, + ); + + const flamechartView = new FlamechartView( + surface, + defaultFrame, + data.flamechart, + data.duration, + ); + flamechartViewRef.current = flamechartView; + const flamechartVerticalScrollView = new VerticalScrollView( + surface, + defaultFrame, + flamechartView, + ); + const flamechartHorizontalPanAndZoomView = new HorizontalPanAndZoomView( + surface, + defaultFrame, + flamechartVerticalScrollView, + data.duration, + syncAllHorizontalPanAndZoomViewStates, + ); + syncedHorizontalPanAndZoomViewsRef.current.push( + flamechartHorizontalPanAndZoomView, + ); + + const resizableContentStack = new ResizableSplitView( + surface, + defaultFrame, + reactMeasuresHorizontalPanAndZoomView, + flamechartHorizontalPanAndZoomView, + ); + + const rootView = new View( + surface, + defaultFrame, + createComposedLayout( + verticallyStackedLayout, + lastViewTakesUpRemainingSpaceLayout, + ), + ); + rootView.addSubview(topContentHorizontalPanAndZoomView); + rootView.addSubview(resizableContentStack); + + surfaceRef.current.rootView = rootView; + }, [data]); + + useLayoutEffect(() => { + if (canvasRef.current) { + surfaceRef.current.setCanvas(canvasRef.current, {width, height}); + } + }, [width, height]); + + const interactor = useCallback(interaction => { + if (canvasRef.current === null) { + return; + } + surfaceRef.current.handleInteraction(interaction); + // Defer drawing to canvas until React's commit phase, to avoid drawing + // twice and to ensure that both the canvas and DOM elements managed by + // React are in sync. + setMouseLocation({ + x: interaction.payload.event.x, + y: interaction.payload.event.y, + }); + }, []); + + useCanvasInteraction(canvasRef, interactor); + + useContextMenu({ + data: { + data, + hoveredEvent, + }, + id: CONTEXT_MENU_ID, + onChange: setIsContextMenuShown, + ref: canvasRef, + }); + + useEffect(() => { + const {current: userTimingMarksView} = userTimingMarksViewRef; + if (userTimingMarksView) { + userTimingMarksView.onHover = userTimingMark => { + if (!hoveredEvent || hoveredEvent.userTimingMark !== userTimingMark) { + setHoveredEvent({ + userTimingMark, + event: null, + flamechartStackFrame: null, + measure: null, + data, + }); + } + }; + } + + const {current: reactEventsView} = reactEventsViewRef; + if (reactEventsView) { + reactEventsView.onHover = event => { + if (!hoveredEvent || hoveredEvent.event !== event) { + setHoveredEvent({ + userTimingMark: null, + event, + flamechartStackFrame: null, + measure: null, + data, + }); + } + }; + } + + const {current: reactMeasuresView} = reactMeasuresViewRef; + if (reactMeasuresView) { + reactMeasuresView.onHover = measure => { + if (!hoveredEvent || hoveredEvent.measure !== measure) { + setHoveredEvent({ + userTimingMark: null, + event: null, + flamechartStackFrame: null, + measure, + data, + }); + } + }; + } + + const {current: flamechartView} = flamechartViewRef; + if (flamechartView) { + flamechartView.setOnHover(flamechartStackFrame => { + if ( + !hoveredEvent || + hoveredEvent.flamechartStackFrame !== flamechartStackFrame + ) { + setHoveredEvent({ + userTimingMark: null, + event: null, + flamechartStackFrame, + measure: null, + data, + }); + } + }); + } + }, [hoveredEvent]); + + useLayoutEffect(() => { + const {current: userTimingMarksView} = userTimingMarksViewRef; + if (userTimingMarksView) { + userTimingMarksView.setHoveredMark( + hoveredEvent ? hoveredEvent.userTimingMark : null, + ); + } + + const {current: reactEventsView} = reactEventsViewRef; + if (reactEventsView) { + reactEventsView.setHoveredEvent(hoveredEvent ? hoveredEvent.event : null); + } + + const {current: reactMeasuresView} = reactMeasuresViewRef; + if (reactMeasuresView) { + reactMeasuresView.setHoveredMeasure( + hoveredEvent ? hoveredEvent.measure : null, + ); + } + + const {current: flamechartView} = flamechartViewRef; + if (flamechartView) { + flamechartView.setHoveredFlamechartStackFrame( + hoveredEvent ? hoveredEvent.flamechartStackFrame : null, + ); + } + }, [hoveredEvent]); + + // Draw to canvas in React's commit phase + useLayoutEffect(() => { + surfaceRef.current.displayIfNeeded(); + }); + + return ( + + + + {(contextData: ContextMenuContextData) => { + if (contextData.hoveredEvent == null) { + return null; + } + const { + event, + flamechartStackFrame, + measure, + } = contextData.hoveredEvent; + return ( + + {event !== null && ( + copy(event.componentName)} + title="Copy component name"> + Copy component name + + )} + {event !== null && ( + copy(event.componentStack)} + title="Copy component stack"> + Copy component stack + + )} + {measure !== null && ( + + zoomToBatch( + contextData.data, + measure, + syncedHorizontalPanAndZoomViewsRef.current, + ) + } + title="Zoom to batch"> + Zoom to batch + + )} + {measure !== null && ( + copySummary(contextData.data, measure)} + title="Copy summary"> + Copy summary + + )} + {flamechartStackFrame !== null && ( + copy(flamechartStackFrame.scriptUrl)} + title="Copy file path"> + Copy file path + + )} + {flamechartStackFrame !== null && ( + + copy( + `line ${flamechartStackFrame.locationLine ?? + ''}, column ${flamechartStackFrame.locationColumn ?? + ''}`, + ) + } + title="Copy location"> + Copy location + + )} + + ); + }} + + {!isContextMenuShown && ( + + )} + + ); +} + +export default CanvasPage; diff --git a/packages/react-devtools-scheduling-profiler/src/EventTooltip.css b/packages/react-devtools-scheduling-profiler/src/EventTooltip.css new file mode 100644 index 0000000000..f721295b2f --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/EventTooltip.css @@ -0,0 +1,78 @@ +.Tooltip { + position: fixed; + display: inline-block; + border-radius: 0.125rem; + max-width: 300px; + padding: 0.25rem; + user-select: none; + pointer-events: none; + background-color: #ffffff; + border: 1px solid #ccc; + box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.2); + font-size: 11px; +} + +.Divider { + height: 1px; + background-color: #aaa; + margin: 0.5rem 0; +} + +.DetailsGrid { + display: grid; + padding-top: 5px; + grid-gap: 2px 5px; + grid-template-columns: min-content auto; +} + +.DetailsGridLabel { + color: #666; + text-align: right; +} + +.DetailsGridURL { + word-break: break-all; + max-height: 50vh; + overflow: hidden; +} + +.FlamechartStackFrameName { + word-break: break-word; + margin-left: 0.4rem; +} + +.ComponentName { + font-weight: bold; + word-break: break-word; + margin-right: 0.4rem; +} + +.ComponentStack { + overflow: hidden; + max-width: 35em; + max-height: 10em; + margin: 0; + font-size: 0.9em; + line-height: 1.5; + -webkit-mask-image: linear-gradient( + 180deg, + #fff, + #fff 5em, + transparent + ); + mask-image: linear-gradient( + 180deg, + #fff, + #fff 5em, + transparent + ); + white-space: pre; +} + +.ReactMeasureLabel { + margin-left: 0.4rem; +} + +.UserTimingLabel { + word-break: break-word; +} diff --git a/packages/react-devtools-scheduling-profiler/src/EventTooltip.js b/packages/react-devtools-scheduling-profiler/src/EventTooltip.js new file mode 100644 index 0000000000..52fb66d981 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/EventTooltip.js @@ -0,0 +1,292 @@ +/** + * 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 type {Point} from './view-base'; +import type { + FlamechartStackFrame, + ReactEvent, + ReactHoverContextInfo, + ReactMeasure, + ReactProfilerData, + Return, + UserTimingMark, +} from './types'; + +import * as React from 'react'; +import {Fragment, useRef} from 'react'; +import prettyMilliseconds from 'pretty-ms'; +import {COLORS} from './content-views/constants'; +import {getBatchRange} from './utils/getBatchRange'; +import useSmartTooltip from './utils/useSmartTooltip'; +import styles from './EventTooltip.css'; + +type Props = {| + data: ReactProfilerData, + hoveredEvent: ReactHoverContextInfo | null, + origin: Point, +|}; + +function formatTimestamp(ms) { + return ms.toLocaleString(undefined, {minimumFractionDigits: 2}) + 'ms'; +} + +function formatDuration(ms) { + return prettyMilliseconds(ms, {millisecondsDecimalDigits: 3}); +} + +function trimmedString(string: string, length: number): string { + if (string.length > length) { + return `${string.substr(0, length - 1)}…`; + } + return string; +} + +function getReactEventLabel(type): string | null { + switch (type) { + case 'schedule-render': + return 'render scheduled'; + case 'schedule-state-update': + return 'state update scheduled'; + case 'schedule-force-update': + return 'force update scheduled'; + case 'suspense-suspend': + return 'suspended'; + case 'suspense-resolved': + return 'suspense resolved'; + case 'suspense-rejected': + return 'suspense rejected'; + default: + return null; + } +} + +function getReactEventColor(event: ReactEvent): string | null { + switch (event.type) { + case 'schedule-render': + return COLORS.REACT_SCHEDULE_HOVER; + case 'schedule-state-update': + case 'schedule-force-update': + return event.isCascading + ? COLORS.REACT_SCHEDULE_CASCADING_HOVER + : COLORS.REACT_SCHEDULE_HOVER; + case 'suspense-suspend': + case 'suspense-resolved': + case 'suspense-rejected': + return COLORS.REACT_SUSPEND_HOVER; + default: + return null; + } +} + +function getReactMeasureLabel(type): string | null { + switch (type) { + case 'commit': + return 'commit'; + case 'render-idle': + return 'idle'; + case 'render': + return 'render'; + case 'layout-effects': + return 'layout effects'; + case 'passive-effects': + return 'passive effects'; + default: + return null; + } +} + +export default function EventTooltip({data, hoveredEvent, origin}: Props) { + const tooltipRef = useSmartTooltip({ + mouseX: origin.x, + mouseY: origin.y, + }); + + if (hoveredEvent === null) { + return null; + } + + const {event, measure, flamechartStackFrame, userTimingMark} = hoveredEvent; + + if (event !== null) { + return ; + } else if (measure !== null) { + return ( + + ); + } else if (flamechartStackFrame !== null) { + return ( + + ); + } else if (userTimingMark !== null) { + return ( + + ); + } + return null; +} + +function formatComponentStack(componentStack: string): string { + const lines = componentStack.split('\n').map(line => line.trim()); + lines.shift(); + + if (lines.length > 5) { + return lines.slice(0, 5).join('\n') + '\n...'; + } + return lines.join('\n'); +} + +const TooltipFlamechartNode = ({ + stackFrame, + tooltipRef, +}: { + stackFrame: FlamechartStackFrame, + tooltipRef: Return, +}) => { + const { + name, + timestamp, + duration, + scriptUrl, + locationLine, + locationColumn, + } = stackFrame; + return ( +
+ {formatDuration(duration)} + {name} +
+
Timestamp:
+
{formatTimestamp(timestamp)}
+ {scriptUrl && ( + <> +
Script URL:
+
{scriptUrl}
+ + )} + {(locationLine !== undefined || locationColumn !== undefined) && ( + <> +
Location:
+
+ line {locationLine}, column {locationColumn} +
+ + )} +
+
+ ); +}; + +const TooltipReactEvent = ({ + event, + tooltipRef, +}: { + event: ReactEvent, + tooltipRef: Return, +}) => { + const label = getReactEventLabel(event.type); + const color = getReactEventColor(event); + if (!label || !color) { + if (__DEV__) { + console.warn('Unexpected event type "%s"', event.type); + } + return null; + } + + const {componentName, componentStack, timestamp} = event; + + return ( +
+ {componentName && ( + + {trimmedString(componentName, 768)} + + )} + {label} +
+
+
Timestamp:
+
{formatTimestamp(timestamp)}
+ {componentStack && ( + +
Component stack:
+
+              {formatComponentStack(componentStack)}
+            
+
+ )} +
+
+ ); +}; + +const TooltipReactMeasure = ({ + data, + measure, + tooltipRef, +}: { + data: ReactProfilerData, + measure: ReactMeasure, + tooltipRef: Return, +}) => { + const label = getReactMeasureLabel(measure.type); + if (!label) { + if (__DEV__) { + console.warn('Unexpected measure type "%s"', measure.type); + } + return null; + } + + const {batchUID, duration, timestamp, lanes} = measure; + const [startTime, stopTime] = getBatchRange(batchUID, data); + + return ( +
+ {formatDuration(duration)} + {label} +
+
+
Timestamp:
+
{formatTimestamp(timestamp)}
+
Batch duration:
+
{formatDuration(stopTime - startTime)}
+
+ Lane{lanes.length === 1 ? '' : 's'}: +
+
{lanes.join(', ')}
+
+
+ ); +}; + +const TooltipUserTimingMark = ({ + mark, + tooltipRef, +}: { + mark: UserTimingMark, + tooltipRef: Return, +}) => { + const {name, timestamp} = mark; + return ( +
+ {name} +
+
+
Timestamp:
+
{formatTimestamp(timestamp)}
+
+
+ ); +}; diff --git a/packages/react-devtools-scheduling-profiler/src/ImportPage.css b/packages/react-devtools-scheduling-profiler/src/ImportPage.css new file mode 100644 index 0000000000..0bfcd43892 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/ImportPage.css @@ -0,0 +1,206 @@ +.App { + background-color: #282c34; + text-align: center; +} + +.container { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + font-size: calc(10px + 1.5vmin); + min-height: 100vh; +} + +.link { + color: #282c34; + transition: 0.2s; + font-size: calc(10px + 1.5vmin); +} +.link:hover { + color: #61dafb; + transition: 0.2s; + font-size: calc(10px + 1.5vmin); +} + +kbd { + display: inline-block; + padding: 0 0.5em; + border: 1px solid #d7dfe4; + margin: 0 0.2em; + background-color: #f6f6f6; + border-radius: 0.2em; +} + +/* Landing Graphic */ +.browserScreenshot { + width: 35rem; + max-width: inherit; + align-self: center; + justify-content: center; + border: 1px solid #d7dfe4; + border-radius: 0.4em; + box-shadow: 0 2px 4px #ddd; +} + +.legendKey { + font-size: calc(8px + 1.5vmin); + margin: 0; + margin-bottom: 1rem; +} + +.legendKey > svg { + padding-left: 2rem; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +hr { + margin-top: 0px; + margin-left: 4px; + width: 80%; +} + +.row { + display: flex; + flex-direction: row; + flex-wrap: wrap; + width: 100%; +} + +.column { + padding: 1rem; + display: flex; + flex-direction: column; + flex-basis: 100%; + flex: 1; +} + +.columncontent { + display: flex; + flex-direction: column; + flex-basis: 100%; + flex: 1; + text-align: left; +} + +/* Card Styling */ +.card { + background-color: white; + box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2); + border-radius: 5px; + transition: 0.3s; + width: 80%; + color: black; +} + +.card:hover { + box-shadow: 0 8px 16px 0 rgba(0, 0, 0, 0.2); +} + +.cardcontainer { + padding: 40px 16px; +} + +.inputbtn { + display: none; +} + +.buttongrp { + float: left; /* Float the buttons side by side */ +} + +/* Import Button Styling */ +.ImportButton { + background-color: #61dafb; + border: none; + color: #ffffff; + text-align: center; + font-size: 28px; + padding: 20px; + width: 200px; + transition: all 0.3s; + cursor: pointer; + margin: 5px; +} + +.ImportButton span { + cursor: pointer; + display: inline-block; + position: relative; + transition: 0.3s; +} + +.ImportButton span:after { + content: '\00bb'; + position: absolute; + opacity: 0; + top: 0; + right: -20px; + transition: 0.3s; +} + +.ImportButton:hover { + background-color: white; + color: black; +} + +.ImportButton:hover span { + padding-right: 25px; +} + +.ImportButton:hover span:after { + opacity: 1; + right: 0; +} + +/* ViewSource Button styling */ +.ViewSourceButton { + background-color: transparent; + color: black; + border: none; + text-align: center; + font-size: 28px; + padding: 20px; + width: 200px; + transition: all 0.3s; + cursor: pointer; + margin: 5px; +} + +.ViewSourceButton span { + cursor: pointer; + display: inline-block; + position: relative; + transition: 0.3s; +} + +.ViewSourceButton span:after { + content: '\00bb'; + position: absolute; + opacity: 0; + top: 0; + right: -20px; + transition: 0.3s; +} + +.ViewSourceButton:hover { + background-color: white; + color: black; +} + +.ViewSourceButton:hover span { + padding-right: 25px; +} + +.ViewSourceButton:hover span:after { + opacity: 1; + right: 0; +} diff --git a/packages/react-devtools-scheduling-profiler/src/ImportPage.js b/packages/react-devtools-scheduling-profiler/src/ImportPage.js new file mode 100644 index 0000000000..93a02ba5e3 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/ImportPage.js @@ -0,0 +1,119 @@ +/** + * 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 type {TimelineEvent} from '@elg/speedscope'; +import type {ReactProfilerData} from './types'; + +import * as React from 'react'; +import {useCallback, useRef} from 'react'; + +import profilerBrowser from './assets/profilerBrowser.png'; +import style from './ImportPage.css'; + +import preprocessData from './utils/preprocessData'; +import {readInputData} from './utils/readInputData'; + +type Props = {| + onDataImported: (profilerData: ReactProfilerData) => void, +|}; + +export default function ImportPage({onDataImported}: Props) { + const processTimeline = useCallback( + (events: TimelineEvent[]) => { + if (events.length > 0) { + onDataImported(preprocessData(events)); + } + }, + [onDataImported], + ); + + const handleProfilerInput = useCallback( + async (event: SyntheticInputEvent) => { + const readFile = await readInputData(event.target.files[0]); + processTimeline(JSON.parse(readFile)); + }, + [processTimeline], + ); + + const upload = useRef(null); + + return ( +
+
+
+
+
+
+ logo +
+
+

React Concurrent Mode Profiler

+
+

+ Import a captured{' '} + + performance profile + {' '} + from Chrome Devtools. +
+ To zoom, scroll while holding down Ctrl or{' '} + Shift +

+

+ + + + State Update Scheduled +
+ + + + State Update Scheduled +
+ + + + Suspended +

+ +
+ + + + +
+
+
+
+
+
+
+ ); +} diff --git a/packages/react-devtools-scheduling-profiler/src/assets/logo.svg b/packages/react-devtools-scheduling-profiler/src/assets/logo.svg new file mode 100644 index 0000000000..2e5df0d3ab --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/assets/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/react-devtools-scheduling-profiler/src/assets/profilerBrowser.png b/packages/react-devtools-scheduling-profiler/src/assets/profilerBrowser.png new file mode 100644 index 0000000000000000000000000000000000000000..b0282be2f68289ae83da05816100e26a1b7b35cf GIT binary patch literal 77466 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

f`ZWPEhg5ob7!T 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̓{K7am(nwR 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 H@z4JP4@+@R literal 0 HcmV?d00001 diff --git a/packages/react-devtools-scheduling-profiler/src/assets/reactlogo.svg b/packages/react-devtools-scheduling-profiler/src/assets/reactlogo.svg new file mode 100644 index 0000000000..6b60c1042f --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/assets/reactlogo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/react-devtools-scheduling-profiler/src/constants.js b/packages/react-devtools-scheduling-profiler/src/constants.js new file mode 100644 index 0000000000..77f8964e82 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/constants.js @@ -0,0 +1,11 @@ +/** + * 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 + */ +// App constants + +export const REACT_TOTAL_NUM_LANES = 31; diff --git a/packages/react-devtools-scheduling-profiler/src/content-views/FlamechartView.js b/packages/react-devtools-scheduling-profiler/src/content-views/FlamechartView.js new file mode 100644 index 0000000000..00b022899f --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/content-views/FlamechartView.js @@ -0,0 +1,378 @@ +/** + * 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 type { + Flamechart, + FlamechartStackFrame, + FlamechartStackLayer, +} from '../types'; +import type {Interaction, MouseMoveInteraction, Rect, Size} from '../view-base'; + +import { + ColorView, + Surface, + View, + layeredLayout, + rectContainsPoint, + rectEqualToRect, + intersectionOfRects, + rectIntersectsRect, + verticallyStackedLayout, +} from '../view-base'; +import { + durationToWidth, + positioningScaleFactor, + timestampToPosition, +} from './utils/positioning'; +import { + COLORS, + FLAMECHART_FONT_SIZE, + FLAMECHART_FRAME_HEIGHT, + FLAMECHART_TEXT_PADDING, + COLOR_HOVER_DIM_DELTA, + BORDER_SIZE, +} from './constants'; +import {ColorGenerator, dimmedColor, hslaColorToString} from './utils/colors'; + +// Source: https://source.chromium.org/chromium/chromium/src/+/master:out/Debug/gen/devtools/timeline/TimelineUIUtils.js;l=2109;drc=fb32e928d79707a693351b806b8710b2f6b7d399 +const colorGenerator = new ColorGenerator( + {min: 30, max: 330}, + {min: 50, max: 80, count: 3}, + 85, +); +colorGenerator.setColorForID('', {h: 43.6, s: 45.8, l: 90.6, a: 100}); + +function defaultHslaColorForStackFrame({scriptUrl}: FlamechartStackFrame) { + return colorGenerator.colorForID(scriptUrl ?? ''); +} + +function defaultColorForStackFrame(stackFrame: FlamechartStackFrame): string { + const color = defaultHslaColorForStackFrame(stackFrame); + return hslaColorToString(color); +} + +function hoverColorForStackFrame(stackFrame: FlamechartStackFrame): string { + const color = dimmedColor( + defaultHslaColorForStackFrame(stackFrame), + COLOR_HOVER_DIM_DELTA, + ); + return hslaColorToString(color); +} + +const cachedFlamechartTextWidths = new Map(); +const trimFlamechartText = ( + context: CanvasRenderingContext2D, + text: string, + width: number, +) => { + for (let i = text.length - 1; i >= 0; i--) { + const trimmedText = i === text.length - 1 ? text : text.substr(0, i) + '…'; + + let measuredWidth = cachedFlamechartTextWidths.get(trimmedText); + if (measuredWidth == null) { + measuredWidth = context.measureText(trimmedText).width; + cachedFlamechartTextWidths.set(trimmedText, measuredWidth); + } + + if (measuredWidth <= width) { + return trimmedText; + } + } + + return null; +}; + +class FlamechartStackLayerView extends View { + /** Layer to display */ + _stackLayer: FlamechartStackLayer; + + /** A set of `stackLayer`'s frames, for efficient lookup. */ + _stackFrameSet: Set; + + _intrinsicSize: Size; + + _hoveredStackFrame: FlamechartStackFrame | null = null; + _onHover: ((node: FlamechartStackFrame | null) => void) | null = null; + + constructor( + surface: Surface, + frame: Rect, + stackLayer: FlamechartStackLayer, + duration: number, + ) { + super(surface, frame); + this._stackLayer = stackLayer; + this._stackFrameSet = new Set(stackLayer); + this._intrinsicSize = { + width: duration, + height: FLAMECHART_FRAME_HEIGHT, + }; + } + + desiredSize() { + return this._intrinsicSize; + } + + setHoveredFlamechartStackFrame( + hoveredStackFrame: FlamechartStackFrame | null, + ) { + if (this._hoveredStackFrame === hoveredStackFrame) { + return; // We're already hovering over this frame + } + + // Only care about frames displayed by this view. + const stackFrameToSet = + hoveredStackFrame && this._stackFrameSet.has(hoveredStackFrame) + ? hoveredStackFrame + : null; + if (this._hoveredStackFrame === stackFrameToSet) { + return; // Resulting state is unchanged + } + this._hoveredStackFrame = stackFrameToSet; + this.setNeedsDisplay(); + } + + draw(context: CanvasRenderingContext2D) { + const { + frame, + _stackLayer, + _hoveredStackFrame, + _intrinsicSize, + visibleArea, + } = this; + + context.fillStyle = COLORS.BACKGROUND; + context.fillRect( + visibleArea.origin.x, + visibleArea.origin.y, + visibleArea.size.width, + visibleArea.size.height, + ); + + context.textAlign = 'left'; + context.textBaseline = 'middle'; + context.font = `${FLAMECHART_FONT_SIZE}px sans-serif`; + + const scaleFactor = positioningScaleFactor(_intrinsicSize.width, frame); + + for (let i = 0; i < _stackLayer.length; i++) { + const stackFrame = _stackLayer[i]; + const {name, timestamp, duration} = stackFrame; + + const width = durationToWidth(duration, scaleFactor); + if (width < 1) { + continue; // Too small to render at this zoom level + } + + const x = Math.floor(timestampToPosition(timestamp, scaleFactor, frame)); + const nodeRect: Rect = { + origin: {x, y: frame.origin.y}, + size: { + width: Math.floor(width - BORDER_SIZE), + height: Math.floor(FLAMECHART_FRAME_HEIGHT - BORDER_SIZE), + }, + }; + if (!rectIntersectsRect(nodeRect, visibleArea)) { + continue; // Not in view + } + + const showHoverHighlight = _hoveredStackFrame === _stackLayer[i]; + context.fillStyle = showHoverHighlight + ? hoverColorForStackFrame(stackFrame) + : defaultColorForStackFrame(stackFrame); + + const drawableRect = intersectionOfRects(nodeRect, visibleArea); + context.fillRect( + drawableRect.origin.x, + drawableRect.origin.y, + drawableRect.size.width, + drawableRect.size.height, + ); + + if (width > FLAMECHART_TEXT_PADDING * 2) { + const trimmedName = trimFlamechartText( + context, + name, + width - FLAMECHART_TEXT_PADDING * 2 + (x < 0 ? x : 0), + ); + + if (trimmedName !== null) { + context.fillStyle = COLORS.PRIORITY_LABEL; + + // Prevent text from being drawn outside `viewableArea` + const textOverflowsViewableArea = !rectEqualToRect( + drawableRect, + nodeRect, + ); + if (textOverflowsViewableArea) { + context.save(); + context.beginPath(); + context.rect( + drawableRect.origin.x, + drawableRect.origin.y, + drawableRect.size.width, + drawableRect.size.height, + ); + context.closePath(); + context.clip(); + } + + context.fillText( + trimmedName, + nodeRect.origin.x + FLAMECHART_TEXT_PADDING - (x < 0 ? x : 0), + nodeRect.origin.y + FLAMECHART_FRAME_HEIGHT / 2, + ); + + if (textOverflowsViewableArea) { + context.restore(); + } + } + } + } + } + + /** + * @private + */ + _handleMouseMove(interaction: MouseMoveInteraction) { + const {_stackLayer, frame, _intrinsicSize, _onHover, visibleArea} = this; + const {location} = interaction.payload; + if (!_onHover || !rectContainsPoint(location, visibleArea)) { + return; + } + + // Find the node being hovered over. + const scaleFactor = positioningScaleFactor(_intrinsicSize.width, frame); + let startIndex = 0; + let stopIndex = _stackLayer.length - 1; + while (startIndex <= stopIndex) { + const currentIndex = Math.floor((startIndex + stopIndex) / 2); + const flamechartStackFrame = _stackLayer[currentIndex]; + const {timestamp, duration} = flamechartStackFrame; + + const width = durationToWidth(duration, scaleFactor); + const x = Math.floor(timestampToPosition(timestamp, scaleFactor, frame)); + if (x <= location.x && x + width >= location.x) { + _onHover(flamechartStackFrame); + return; + } + + if (x > location.x) { + stopIndex = currentIndex - 1; + } else { + startIndex = currentIndex + 1; + } + } + + _onHover(null); + } + + handleInteraction(interaction: Interaction) { + switch (interaction.type) { + case 'mousemove': + this._handleMouseMove(interaction); + break; + } + } +} + +export class FlamechartView extends View { + _flamechartRowViews: FlamechartStackLayerView[] = []; + + /** Container view that vertically stacks flamechart rows */ + _verticalStackView: View; + + _hoveredStackFrame: FlamechartStackFrame | null = null; + _onHover: ((node: FlamechartStackFrame | null) => void) | null = null; + + constructor( + surface: Surface, + frame: Rect, + flamechart: Flamechart, + duration: number, + ) { + super(surface, frame, layeredLayout); + this.setDataAndUpdateSubviews(flamechart, duration); + } + + setDataAndUpdateSubviews(flamechart: Flamechart, duration: number) { + const {surface, frame, _onHover, _hoveredStackFrame} = this; + + // Clear existing rows on data update + if (this._verticalStackView) { + this.removeAllSubviews(); + this._flamechartRowViews = []; + } + + this._verticalStackView = new View(surface, frame, verticallyStackedLayout); + this._flamechartRowViews = flamechart.map(stackLayer => { + const rowView = new FlamechartStackLayerView( + surface, + frame, + stackLayer, + duration, + ); + this._verticalStackView.addSubview(rowView); + + // Update states + rowView._onHover = _onHover; + rowView.setHoveredFlamechartStackFrame(_hoveredStackFrame); + return rowView; + }); + + // Add a plain background view to prevent gaps from appearing between + // flamechartRowViews. + const colorView = new ColorView(surface, frame, COLORS.BACKGROUND); + this.addSubview(colorView); + this.addSubview(this._verticalStackView); + } + + setHoveredFlamechartStackFrame( + hoveredStackFrame: FlamechartStackFrame | null, + ) { + this._hoveredStackFrame = hoveredStackFrame; + this._flamechartRowViews.forEach(rowView => + rowView.setHoveredFlamechartStackFrame(hoveredStackFrame), + ); + } + + setOnHover(onHover: (node: FlamechartStackFrame | null) => void) { + this._onHover = onHover; + this._flamechartRowViews.forEach(rowView => (rowView._onHover = onHover)); + } + + desiredSize() { + // Ignore the wishes of the background color view + return this._verticalStackView.desiredSize(); + } + + /** + * @private + */ + _handleMouseMove(interaction: MouseMoveInteraction) { + const {_onHover, visibleArea} = this; + if (!_onHover) { + return; + } + + const {location} = interaction.payload; + if (!rectContainsPoint(location, visibleArea)) { + // Clear out any hovered flamechart stack frame + _onHover(null); + } + } + + handleInteraction(interaction: Interaction) { + switch (interaction.type) { + case 'mousemove': + this._handleMouseMove(interaction); + break; + } + } +} diff --git a/packages/react-devtools-scheduling-profiler/src/content-views/ReactEventsView.js b/packages/react-devtools-scheduling-profiler/src/content-views/ReactEventsView.js new file mode 100644 index 0000000000..fb60e54eb4 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/content-views/ReactEventsView.js @@ -0,0 +1,276 @@ +/** + * 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 type {ReactEvent, ReactProfilerData} from '../types'; +import type {Interaction, MouseMoveInteraction, Rect, Size} from '../view-base'; + +import { + positioningScaleFactor, + timestampToPosition, + positionToTimestamp, + widthToDuration, +} from './utils/positioning'; +import { + View, + Surface, + rectContainsPoint, + rectIntersectsRect, + intersectionOfRects, +} from '../view-base'; +import { + COLORS, + EVENT_ROW_PADDING, + EVENT_DIAMETER, + BORDER_SIZE, +} from './constants'; + +const EVENT_ROW_HEIGHT_FIXED = + EVENT_ROW_PADDING + EVENT_DIAMETER + EVENT_ROW_PADDING; + +function isSuspenseEvent(event: ReactEvent): boolean %checks { + return ( + event.type === 'suspense-suspend' || + event.type === 'suspense-resolved' || + event.type === 'suspense-rejected' + ); +} + +export class ReactEventsView extends View { + _profilerData: ReactProfilerData; + _intrinsicSize: Size; + + _hoveredEvent: ReactEvent | null = null; + onHover: ((event: ReactEvent | null) => void) | null = null; + + constructor(surface: Surface, frame: Rect, profilerData: ReactProfilerData) { + super(surface, frame); + this._profilerData = profilerData; + + this._intrinsicSize = { + width: this._profilerData.duration, + height: EVENT_ROW_HEIGHT_FIXED, + }; + } + + desiredSize() { + return this._intrinsicSize; + } + + setHoveredEvent(hoveredEvent: ReactEvent | null) { + if (this._hoveredEvent === hoveredEvent) { + return; + } + this._hoveredEvent = hoveredEvent; + this.setNeedsDisplay(); + } + + /** + * Draw a single `ReactEvent` as a circle in the canvas. + */ + _drawSingleReactEvent( + context: CanvasRenderingContext2D, + rect: Rect, + event: ReactEvent, + baseY: number, + scaleFactor: number, + showHoverHighlight: boolean, + ) { + const {frame} = this; + const {timestamp, type} = event; + + const x = timestampToPosition(timestamp, scaleFactor, frame); + const radius = EVENT_DIAMETER / 2; + const eventRect: Rect = { + origin: { + x: x - radius, + y: baseY, + }, + size: {width: EVENT_DIAMETER, height: EVENT_DIAMETER}, + }; + if (!rectIntersectsRect(eventRect, rect)) { + return; // Not in view + } + + let fillStyle = null; + + switch (type) { + case 'schedule-render': + case 'schedule-state-update': + case 'schedule-force-update': + if (event.isCascading) { + fillStyle = showHoverHighlight + ? COLORS.REACT_SCHEDULE_CASCADING_HOVER + : COLORS.REACT_SCHEDULE_CASCADING; + } else { + fillStyle = showHoverHighlight + ? COLORS.REACT_SCHEDULE_HOVER + : COLORS.REACT_SCHEDULE; + } + break; + case 'suspense-suspend': + case 'suspense-resolved': + case 'suspense-rejected': + fillStyle = showHoverHighlight + ? COLORS.REACT_SUSPEND_HOVER + : COLORS.REACT_SUSPEND; + break; + default: + if (__DEV__) { + console.warn('Unexpected event type "%s"', type); + } + break; + } + + if (fillStyle !== null) { + const y = eventRect.origin.y + radius; + + context.beginPath(); + context.fillStyle = fillStyle; + context.arc(x, y, radius, 0, 2 * Math.PI); + context.fill(); + } + } + + draw(context: CanvasRenderingContext2D) { + const { + frame, + _profilerData: {events}, + _hoveredEvent, + visibleArea, + } = this; + + context.fillStyle = COLORS.BACKGROUND; + context.fillRect( + visibleArea.origin.x, + visibleArea.origin.y, + visibleArea.size.width, + visibleArea.size.height, + ); + + // Draw events + const baseY = frame.origin.y + EVENT_ROW_PADDING; + const scaleFactor = positioningScaleFactor( + this._intrinsicSize.width, + frame, + ); + + const highlightedEvents: ReactEvent[] = []; + + events.forEach(event => { + if ( + event === _hoveredEvent || + (_hoveredEvent && + isSuspenseEvent(event) && + isSuspenseEvent(_hoveredEvent) && + event.id === _hoveredEvent.id) + ) { + highlightedEvents.push(event); + return; + } + this._drawSingleReactEvent( + context, + visibleArea, + event, + baseY, + scaleFactor, + false, + ); + }); + + // Draw the highlighted items on top so they stand out. + // This is helpful if there are multiple (overlapping) items close to each other. + highlightedEvents.forEach(event => { + this._drawSingleReactEvent( + context, + visibleArea, + event, + baseY, + scaleFactor, + true, + ); + }); + + // Render bottom border. + // Propose border rect, check if intersects with `rect`, draw intersection. + const borderFrame: Rect = { + origin: { + x: frame.origin.x, + y: frame.origin.y + EVENT_ROW_HEIGHT_FIXED - BORDER_SIZE, + }, + size: { + width: frame.size.width, + height: BORDER_SIZE, + }, + }; + if (rectIntersectsRect(borderFrame, visibleArea)) { + const borderDrawableRect = intersectionOfRects(borderFrame, visibleArea); + context.fillStyle = COLORS.PRIORITY_BORDER; + context.fillRect( + borderDrawableRect.origin.x, + borderDrawableRect.origin.y, + borderDrawableRect.size.width, + borderDrawableRect.size.height, + ); + } + } + + /** + * @private + */ + _handleMouseMove(interaction: MouseMoveInteraction) { + const {frame, onHover, visibleArea} = this; + if (!onHover) { + return; + } + + const {location} = interaction.payload; + if (!rectContainsPoint(location, visibleArea)) { + onHover(null); + return; + } + + const { + _profilerData: {events}, + } = this; + const scaleFactor = positioningScaleFactor( + this._intrinsicSize.width, + frame, + ); + const hoverTimestamp = positionToTimestamp(location.x, scaleFactor, frame); + const eventTimestampAllowance = widthToDuration( + EVENT_DIAMETER / 2, + scaleFactor, + ); + + // Because data ranges may overlap, we want to find the last intersecting item. + // This will always be the one on "top" (the one the user is hovering over). + for (let index = events.length - 1; index >= 0; index--) { + const event = events[index]; + const {timestamp} = event; + + if ( + timestamp - eventTimestampAllowance <= hoverTimestamp && + hoverTimestamp <= timestamp + eventTimestampAllowance + ) { + onHover(event); + return; + } + } + + onHover(null); + } + + handleInteraction(interaction: Interaction) { + switch (interaction.type) { + case 'mousemove': + this._handleMouseMove(interaction); + break; + } + } +} diff --git a/packages/react-devtools-scheduling-profiler/src/content-views/ReactMeasuresView.js b/packages/react-devtools-scheduling-profiler/src/content-views/ReactMeasuresView.js new file mode 100644 index 0000000000..46fbe51bb8 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/content-views/ReactMeasuresView.js @@ -0,0 +1,318 @@ +/** + * 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 type {ReactLane, ReactMeasure, ReactProfilerData} from '../types'; +import type {Interaction, MouseMoveInteraction, Rect, Size} from '../view-base'; + +import { + durationToWidth, + positioningScaleFactor, + positionToTimestamp, + timestampToPosition, +} from './utils/positioning'; +import { + View, + Surface, + rectContainsPoint, + rectIntersectsRect, + intersectionOfRects, +} from '../view-base'; + +import {COLORS, BORDER_SIZE, REACT_MEASURE_HEIGHT} from './constants'; +import {REACT_TOTAL_NUM_LANES} from '../constants'; + +const REACT_LANE_HEIGHT = REACT_MEASURE_HEIGHT + BORDER_SIZE; + +function getMeasuresForLane( + allMeasures: ReactMeasure[], + lane: ReactLane, +): ReactMeasure[] { + return allMeasures.filter(measure => measure.lanes.includes(lane)); +} + +export class ReactMeasuresView extends View { + _profilerData: ReactProfilerData; + _intrinsicSize: Size; + + _lanesToRender: ReactLane[]; + _laneToMeasures: Map; + + _hoveredMeasure: ReactMeasure | null = null; + onHover: ((measure: ReactMeasure | null) => void) | null = null; + + constructor(surface: Surface, frame: Rect, profilerData: ReactProfilerData) { + super(surface, frame); + this._profilerData = profilerData; + this._performPreflightComputations(); + } + + _performPreflightComputations() { + this._lanesToRender = []; + this._laneToMeasures = new Map(); + + for (let lane: ReactLane = 0; lane < REACT_TOTAL_NUM_LANES; lane++) { + const measuresForLane = getMeasuresForLane( + this._profilerData.measures, + lane, + ); + // Only show lanes with measures + if (measuresForLane.length) { + this._lanesToRender.push(lane); + this._laneToMeasures.set(lane, measuresForLane); + } + } + + this._intrinsicSize = { + width: this._profilerData.duration, + height: this._lanesToRender.length * REACT_LANE_HEIGHT, + }; + } + + desiredSize() { + return this._intrinsicSize; + } + + setHoveredMeasure(hoveredMeasure: ReactMeasure | null) { + if (this._hoveredMeasure === hoveredMeasure) { + return; + } + this._hoveredMeasure = hoveredMeasure; + this.setNeedsDisplay(); + } + + /** + * Draw a single `ReactMeasure` as a bar in the canvas. + */ + _drawSingleReactMeasure( + context: CanvasRenderingContext2D, + rect: Rect, + measure: ReactMeasure, + baseY: number, + scaleFactor: number, + showGroupHighlight: boolean, + showHoverHighlight: boolean, + ) { + const {frame} = this; + const {timestamp, type, duration} = measure; + + let fillStyle = null; + let hoveredFillStyle = null; + let groupSelectedFillStyle = null; + + // We could change the max to 0 and just skip over rendering anything that small, + // but this has the effect of making the chart look very empty when zoomed out. + // So long as perf is okay- it might be best to err on the side of showing things. + const width = durationToWidth(duration, scaleFactor); + if (width <= 0) { + return; // Too small to render at this zoom level + } + + const x = timestampToPosition(timestamp, scaleFactor, frame); + const measureRect: Rect = { + origin: {x, y: baseY}, + size: {width, height: REACT_MEASURE_HEIGHT}, + }; + if (!rectIntersectsRect(measureRect, rect)) { + return; // Not in view + } + + switch (type) { + case 'commit': + fillStyle = COLORS.REACT_COMMIT; + hoveredFillStyle = COLORS.REACT_COMMIT_HOVER; + groupSelectedFillStyle = COLORS.REACT_COMMIT_SELECTED; + break; + case 'render-idle': + // We could render idle time as diagonal hashes. + // This looks nicer when zoomed in, but not so nice when zoomed out. + // color = context.createPattern(getIdlePattern(), 'repeat'); + fillStyle = COLORS.REACT_IDLE; + hoveredFillStyle = COLORS.REACT_IDLE_HOVER; + groupSelectedFillStyle = COLORS.REACT_IDLE_SELECTED; + break; + case 'render': + fillStyle = COLORS.REACT_RENDER; + hoveredFillStyle = COLORS.REACT_RENDER_HOVER; + groupSelectedFillStyle = COLORS.REACT_RENDER_SELECTED; + break; + case 'layout-effects': + fillStyle = COLORS.REACT_LAYOUT_EFFECTS; + hoveredFillStyle = COLORS.REACT_LAYOUT_EFFECTS_HOVER; + groupSelectedFillStyle = COLORS.REACT_LAYOUT_EFFECTS_SELECTED; + break; + case 'passive-effects': + fillStyle = COLORS.REACT_PASSIVE_EFFECTS; + hoveredFillStyle = COLORS.REACT_PASSIVE_EFFECTS_HOVER; + groupSelectedFillStyle = COLORS.REACT_PASSIVE_EFFECTS_SELECTED; + break; + default: + throw new Error(`Unexpected measure type "${type}"`); + } + + const drawableRect = intersectionOfRects(measureRect, rect); + context.fillStyle = showHoverHighlight + ? hoveredFillStyle + : showGroupHighlight + ? groupSelectedFillStyle + : fillStyle; + context.fillRect( + drawableRect.origin.x, + drawableRect.origin.y, + drawableRect.size.width, + drawableRect.size.height, + ); + } + + draw(context: CanvasRenderingContext2D) { + const { + frame, + _hoveredMeasure, + _lanesToRender, + _laneToMeasures, + visibleArea, + } = this; + + context.fillStyle = COLORS.PRIORITY_BACKGROUND; + context.fillRect( + visibleArea.origin.x, + visibleArea.origin.y, + visibleArea.size.width, + visibleArea.size.height, + ); + + const scaleFactor = positioningScaleFactor( + this._intrinsicSize.width, + frame, + ); + + for (let i = 0; i < _lanesToRender.length; i++) { + const lane = _lanesToRender[i]; + const baseY = frame.origin.y + i * REACT_LANE_HEIGHT; + const measuresForLane = _laneToMeasures.get(lane); + + if (!measuresForLane) { + throw new Error( + 'No measures found for a React lane! This is a bug in this profiler tool. Please file an issue.', + ); + } + + // Draw measures + for (let j = 0; j < measuresForLane.length; j++) { + const measure = measuresForLane[j]; + const showHoverHighlight = _hoveredMeasure === measure; + const showGroupHighlight = + !!_hoveredMeasure && _hoveredMeasure.batchUID === measure.batchUID; + + this._drawSingleReactMeasure( + context, + visibleArea, + measure, + baseY, + scaleFactor, + showGroupHighlight, + showHoverHighlight, + ); + } + + // Render bottom border + const borderFrame: Rect = { + origin: { + x: frame.origin.x, + y: frame.origin.y + (i + 1) * REACT_LANE_HEIGHT - BORDER_SIZE, + }, + size: { + width: frame.size.width, + height: BORDER_SIZE, + }, + }; + if (rectIntersectsRect(borderFrame, visibleArea)) { + const borderDrawableRect = intersectionOfRects( + borderFrame, + visibleArea, + ); + context.fillStyle = COLORS.PRIORITY_BORDER; + context.fillRect( + borderDrawableRect.origin.x, + borderDrawableRect.origin.y, + borderDrawableRect.size.width, + borderDrawableRect.size.height, + ); + } + } + } + + /** + * @private + */ + _handleMouseMove(interaction: MouseMoveInteraction) { + const { + frame, + _intrinsicSize, + _lanesToRender, + _laneToMeasures, + onHover, + visibleArea, + } = this; + if (!onHover) { + return; + } + + const {location} = interaction.payload; + if (!rectContainsPoint(location, visibleArea)) { + onHover(null); + return; + } + + // Identify the lane being hovered over + const adjustedCanvasMouseY = location.y - frame.origin.y; + const renderedLaneIndex = Math.floor( + adjustedCanvasMouseY / REACT_LANE_HEIGHT, + ); + if (renderedLaneIndex < 0 || renderedLaneIndex >= _lanesToRender.length) { + onHover(null); + return; + } + const lane = _lanesToRender[renderedLaneIndex]; + + // Find the measure in `lane` being hovered over. + // + // Because data ranges may overlap, we want to find the last intersecting item. + // This will always be the one on "top" (the one the user is hovering over). + const scaleFactor = positioningScaleFactor(_intrinsicSize.width, frame); + const hoverTimestamp = positionToTimestamp(location.x, scaleFactor, frame); + const measures = _laneToMeasures.get(lane); + if (!measures) { + onHover(null); + return; + } + + for (let index = measures.length - 1; index >= 0; index--) { + const measure = measures[index]; + const {duration, timestamp} = measure; + + if ( + hoverTimestamp >= timestamp && + hoverTimestamp <= timestamp + duration + ) { + onHover(measure); + return; + } + } + + onHover(null); + } + + handleInteraction(interaction: Interaction) { + switch (interaction.type) { + case 'mousemove': + this._handleMouseMove(interaction); + break; + } + } +} diff --git a/packages/react-devtools-scheduling-profiler/src/content-views/TimeAxisMarkersView.js b/packages/react-devtools-scheduling-profiler/src/content-views/TimeAxisMarkersView.js new file mode 100644 index 0000000000..4f55494282 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/content-views/TimeAxisMarkersView.js @@ -0,0 +1,163 @@ +/** + * 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 type {Rect, Size} from '../view-base'; + +import { + durationToWidth, + positioningScaleFactor, + positionToTimestamp, + timestampToPosition, +} from './utils/positioning'; +import { + View, + Surface, + rectIntersectsRect, + intersectionOfRects, +} from '../view-base'; +import { + COLORS, + INTERVAL_TIMES, + LABEL_SIZE, + MARKER_FONT_SIZE, + MARKER_HEIGHT, + MARKER_TEXT_PADDING, + MARKER_TICK_HEIGHT, + MIN_INTERVAL_SIZE_PX, + BORDER_SIZE, +} from './constants'; + +const HEADER_HEIGHT_FIXED = MARKER_HEIGHT + BORDER_SIZE; +const LABEL_FIXED_WIDTH = LABEL_SIZE + BORDER_SIZE; + +export class TimeAxisMarkersView extends View { + _totalDuration: number; + _intrinsicSize: Size; + + constructor(surface: Surface, frame: Rect, totalDuration: number) { + super(surface, frame); + this._totalDuration = totalDuration; + this._intrinsicSize = { + width: this._totalDuration, + height: HEADER_HEIGHT_FIXED, + }; + } + + desiredSize() { + return this._intrinsicSize; + } + + // Time mark intervals vary based on the current zoom range and the time it represents. + // In Chrome, these seem to range from 70-140 pixels wide. + // Time wise, they represent intervals of e.g. 1s, 500ms, 200ms, 100ms, 50ms, 20ms. + // Based on zoom, we should determine which amount to actually show. + _getTimeTickInterval(scaleFactor: number): number { + for (let i = 0; i < INTERVAL_TIMES.length; i++) { + const currentInterval = INTERVAL_TIMES[i]; + const intervalWidth = durationToWidth(currentInterval, scaleFactor); + if (intervalWidth > MIN_INTERVAL_SIZE_PX) { + return currentInterval; + } + } + return INTERVAL_TIMES[0]; + } + + draw(context: CanvasRenderingContext2D) { + const {frame, _intrinsicSize, visibleArea} = this; + const clippedFrame = { + origin: frame.origin, + size: { + width: frame.size.width, + height: _intrinsicSize.height, + }, + }; + const drawableRect = intersectionOfRects(clippedFrame, visibleArea); + + // Clear background + context.fillStyle = COLORS.BACKGROUND; + context.fillRect( + drawableRect.origin.x, + drawableRect.origin.y, + drawableRect.size.width, + drawableRect.size.height, + ); + + const scaleFactor = positioningScaleFactor( + _intrinsicSize.width, + clippedFrame, + ); + const interval = this._getTimeTickInterval(scaleFactor); + const firstIntervalTimestamp = + Math.ceil( + positionToTimestamp( + drawableRect.origin.x - LABEL_FIXED_WIDTH, + scaleFactor, + clippedFrame, + ) / interval, + ) * interval; + + for ( + let markerTimestamp = firstIntervalTimestamp; + true; + markerTimestamp += interval + ) { + if (markerTimestamp <= 0) { + continue; // Timestamps < are probably a bug; markers at 0 are ugly. + } + + const x = timestampToPosition(markerTimestamp, scaleFactor, clippedFrame); + if (x > drawableRect.origin.x + drawableRect.size.width) { + break; // Not in view + } + + const markerLabel = Math.round(markerTimestamp); + + context.fillStyle = COLORS.PRIORITY_BORDER; + context.fillRect( + x, + drawableRect.origin.y + MARKER_HEIGHT - MARKER_TICK_HEIGHT, + BORDER_SIZE, + MARKER_TICK_HEIGHT, + ); + + context.fillStyle = COLORS.TIME_MARKER_LABEL; + context.textAlign = 'right'; + context.textBaseline = 'middle'; + context.font = `${MARKER_FONT_SIZE}px sans-serif`; + context.fillText( + `${markerLabel}ms`, + x - MARKER_TEXT_PADDING, + MARKER_HEIGHT / 2, + ); + } + + // Render bottom border. + // Propose border rect, check if intersects with `rect`, draw intersection. + const borderFrame: Rect = { + origin: { + x: clippedFrame.origin.x, + y: clippedFrame.origin.y + clippedFrame.size.height - BORDER_SIZE, + }, + size: { + width: clippedFrame.size.width, + height: BORDER_SIZE, + }, + }; + if (rectIntersectsRect(borderFrame, visibleArea)) { + const borderDrawableRect = intersectionOfRects(borderFrame, visibleArea); + context.fillStyle = COLORS.PRIORITY_BORDER; + context.fillRect( + borderDrawableRect.origin.x, + borderDrawableRect.origin.y, + borderDrawableRect.size.width, + borderDrawableRect.size.height, + ); + } + } +} diff --git a/packages/react-devtools-scheduling-profiler/src/content-views/UserTimingMarksView.js b/packages/react-devtools-scheduling-profiler/src/content-views/UserTimingMarksView.js new file mode 100644 index 0000000000..1078ecc26b --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/content-views/UserTimingMarksView.js @@ -0,0 +1,230 @@ +/** + * 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 type {UserTimingMark} from '../types'; +import type {Interaction, MouseMoveInteraction, Rect, Size} from '../view-base'; + +import { + positioningScaleFactor, + timestampToPosition, + positionToTimestamp, + widthToDuration, +} from './utils/positioning'; +import { + View, + Surface, + rectContainsPoint, + rectIntersectsRect, + intersectionOfRects, +} from '../view-base'; +import { + COLORS, + EVENT_ROW_PADDING, + EVENT_DIAMETER, + BORDER_SIZE, +} from './constants'; + +const ROW_HEIGHT_FIXED = EVENT_ROW_PADDING + EVENT_DIAMETER + EVENT_ROW_PADDING; + +export class UserTimingMarksView extends View { + _marks: UserTimingMark[]; + _intrinsicSize: Size; + + _hoveredMark: UserTimingMark | null = null; + onHover: ((mark: UserTimingMark | null) => void) | null = null; + + constructor( + surface: Surface, + frame: Rect, + marks: UserTimingMark[], + duration: number, + ) { + super(surface, frame); + this._marks = marks; + + this._intrinsicSize = { + width: duration, + height: ROW_HEIGHT_FIXED, + }; + } + + desiredSize() { + return this._intrinsicSize; + } + + setHoveredMark(hoveredMark: UserTimingMark | null) { + if (this._hoveredMark === hoveredMark) { + return; + } + this._hoveredMark = hoveredMark; + this.setNeedsDisplay(); + } + + /** + * Draw a single `UserTimingMark` as a circle in the canvas. + */ + _drawSingleMark( + context: CanvasRenderingContext2D, + rect: Rect, + mark: UserTimingMark, + baseY: number, + scaleFactor: number, + showHoverHighlight: boolean, + ) { + const {frame} = this; + const {timestamp} = mark; + + const x = timestampToPosition(timestamp, scaleFactor, frame); + const radius = EVENT_DIAMETER / 2; + const markRect: Rect = { + origin: { + x: x - radius, + y: baseY, + }, + size: {width: EVENT_DIAMETER, height: EVENT_DIAMETER}, + }; + if (!rectIntersectsRect(markRect, rect)) { + return; // Not in view + } + + const fillStyle = showHoverHighlight + ? COLORS.USER_TIMING_HOVER + : COLORS.USER_TIMING; + + if (fillStyle !== null) { + const y = markRect.origin.y + radius; + + context.beginPath(); + context.fillStyle = fillStyle; + context.arc(x, y, radius, 0, 2 * Math.PI); + context.fill(); + } + } + + draw(context: CanvasRenderingContext2D) { + const {frame, _marks, _hoveredMark, visibleArea} = this; + + context.fillStyle = COLORS.BACKGROUND; + context.fillRect( + visibleArea.origin.x, + visibleArea.origin.y, + visibleArea.size.width, + visibleArea.size.height, + ); + + // Draw marks + const baseY = frame.origin.y + EVENT_ROW_PADDING; + const scaleFactor = positioningScaleFactor( + this._intrinsicSize.width, + frame, + ); + + _marks.forEach(mark => { + if (mark === _hoveredMark) { + return; + } + this._drawSingleMark( + context, + visibleArea, + mark, + baseY, + scaleFactor, + false, + ); + }); + + // Draw the hovered and/or selected items on top so they stand out. + // This is helpful if there are multiple (overlapping) items close to each other. + if (_hoveredMark !== null) { + this._drawSingleMark( + context, + visibleArea, + _hoveredMark, + baseY, + scaleFactor, + true, + ); + } + + // Render bottom border. + // Propose border rect, check if intersects with `rect`, draw intersection. + const borderFrame: Rect = { + origin: { + x: frame.origin.x, + y: frame.origin.y + ROW_HEIGHT_FIXED - BORDER_SIZE, + }, + size: { + width: frame.size.width, + height: BORDER_SIZE, + }, + }; + if (rectIntersectsRect(borderFrame, visibleArea)) { + const borderDrawableRect = intersectionOfRects(borderFrame, visibleArea); + context.fillStyle = COLORS.PRIORITY_BORDER; + context.fillRect( + borderDrawableRect.origin.x, + borderDrawableRect.origin.y, + borderDrawableRect.size.width, + borderDrawableRect.size.height, + ); + } + } + + /** + * @private + */ + _handleMouseMove(interaction: MouseMoveInteraction) { + const {frame, onHover, visibleArea} = this; + if (!onHover) { + return; + } + + const {location} = interaction.payload; + if (!rectContainsPoint(location, visibleArea)) { + onHover(null); + return; + } + + const {_marks} = this; + const scaleFactor = positioningScaleFactor( + this._intrinsicSize.width, + frame, + ); + const hoverTimestamp = positionToTimestamp(location.x, scaleFactor, frame); + const markTimestampAllowance = widthToDuration( + EVENT_DIAMETER / 2, + scaleFactor, + ); + + // Because data ranges may overlap, we want to find the last intersecting item. + // This will always be the one on "top" (the one the user is hovering over). + for (let index = _marks.length - 1; index >= 0; index--) { + const mark = _marks[index]; + const {timestamp} = mark; + + if ( + timestamp - markTimestampAllowance <= hoverTimestamp && + hoverTimestamp <= timestamp + markTimestampAllowance + ) { + onHover(mark); + return; + } + } + + onHover(null); + } + + handleInteraction(interaction: Interaction) { + switch (interaction.type) { + case 'mousemove': + this._handleMouseMove(interaction); + break; + } + } +} diff --git a/packages/react-devtools-scheduling-profiler/src/content-views/constants.js b/packages/react-devtools-scheduling-profiler/src/content-views/constants.js new file mode 100644 index 0000000000..468f223be2 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/content-views/constants.js @@ -0,0 +1,73 @@ +/** + * 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 + */ + +export const LABEL_SIZE = 80; +export const LABEL_FONT_SIZE = 11; +export const MARKER_HEIGHT = 20; +export const MARKER_TICK_HEIGHT = 8; +export const MARKER_FONT_SIZE = 10; +export const MARKER_TEXT_PADDING = 8; +export const COLOR_HOVER_DIM_DELTA = 5; + +export const INTERVAL_TIMES = [ + 1, + 2, + 5, + 10, + 20, + 50, + 100, + 200, + 500, + 1000, + 2000, + 5000, +]; +export const MIN_INTERVAL_SIZE_PX = 70; + +export const EVENT_ROW_PADDING = 4; +export const EVENT_DIAMETER = 6; +export const REACT_MEASURE_HEIGHT = 9; +export const BORDER_SIZE = 1; + +export const FLAMECHART_FONT_SIZE = 10; +export const FLAMECHART_FRAME_HEIGHT = 16; +export const FLAMECHART_TEXT_PADDING = 3; + +export const COLORS = Object.freeze({ + BACKGROUND: '#ffffff', + PRIORITY_BACKGROUND: '#ededf0', + PRIORITY_BORDER: '#d7d7db', + PRIORITY_LABEL: '#272727', + USER_TIMING: '#c9cacd', + USER_TIMING_HOVER: '#93959a', + REACT_IDLE: '#edf6ff', + REACT_IDLE_SELECTED: '#EDF6FF', + REACT_IDLE_HOVER: '#EDF6FF', + REACT_RENDER: '#9fc3f3', + REACT_RENDER_SELECTED: '#64A9F5', + REACT_RENDER_HOVER: '#2683E2', + REACT_COMMIT: '#ff718e', + REACT_COMMIT_SELECTED: '#FF5277', + REACT_COMMIT_HOVER: '#ed0030', + REACT_LAYOUT_EFFECTS: '#c88ff0', + REACT_LAYOUT_EFFECTS_SELECTED: '#934FC1', + REACT_LAYOUT_EFFECTS_HOVER: '#601593', + REACT_PASSIVE_EFFECTS: '#c88ff0', + REACT_PASSIVE_EFFECTS_SELECTED: '#934FC1', + REACT_PASSIVE_EFFECTS_HOVER: '#601593', + REACT_SCHEDULE: '#9fc3f3', + REACT_SCHEDULE_HOVER: '#2683E2', + REACT_SCHEDULE_CASCADING: '#ff718e', + REACT_SCHEDULE_CASCADING_HOVER: '#ed0030', + REACT_SUSPEND: '#a6e59f', + REACT_SUSPEND_HOVER: '#13bc00', + REACT_WORK_BORDER: '#ffffff', + TIME_MARKER_LABEL: '#18212b', +}); diff --git a/packages/react-devtools-scheduling-profiler/src/content-views/index.js b/packages/react-devtools-scheduling-profiler/src/content-views/index.js new file mode 100644 index 0000000000..e017b95e20 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/content-views/index.js @@ -0,0 +1,14 @@ +/** + * 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 + */ + +export * from './FlamechartView'; +export * from './ReactEventsView'; +export * from './ReactMeasuresView'; +export * from './TimeAxisMarkersView'; +export * from './UserTimingMarksView'; diff --git a/packages/react-devtools-scheduling-profiler/src/content-views/utils/__tests__/colors-test.js b/packages/react-devtools-scheduling-profiler/src/content-views/utils/__tests__/colors-test.js new file mode 100644 index 0000000000..dcbe900f98 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/content-views/utils/__tests__/colors-test.js @@ -0,0 +1,93 @@ +/** + * 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 {hslaColorToString, dimmedColor, ColorGenerator} from '../colors'; + +describe(hslaColorToString, () => { + it('should transform colors to strings', () => { + expect(hslaColorToString({h: 1, s: 2, l: 3, a: 4})).toEqual( + 'hsl(1deg 2% 3% / 4)', + ); + expect(hslaColorToString({h: 3.14, s: 6.28, l: 1.68, a: 100})).toEqual( + 'hsl(3.14deg 6.28% 1.68% / 100)', + ); + }); +}); + +describe(dimmedColor, () => { + it('should dim luminosity using delta', () => { + expect(dimmedColor({h: 1, s: 2, l: 3, a: 4}, 3)).toEqual({ + h: 1, + s: 2, + l: 0, + a: 4, + }); + expect(dimmedColor({h: 1, s: 2, l: 3, a: 4}, -3)).toEqual({ + h: 1, + s: 2, + l: 6, + a: 4, + }); + }); +}); + +describe(ColorGenerator, () => { + describe(ColorGenerator.prototype.colorForID, () => { + it('should generate a color for an ID', () => { + expect(new ColorGenerator().colorForID('123')).toMatchInlineSnapshot(` + Object { + "a": 1, + "h": 190, + "l": 80, + "s": 67, + } + `); + }); + + it('should generate colors deterministically given an ID', () => { + expect(new ColorGenerator().colorForID('id1')).toEqual( + new ColorGenerator().colorForID('id1'), + ); + expect(new ColorGenerator().colorForID('id2')).toEqual( + new ColorGenerator().colorForID('id2'), + ); + }); + + it('should generate different colors for different IDs', () => { + expect(new ColorGenerator().colorForID('id1')).not.toEqual( + new ColorGenerator().colorForID('id2'), + ); + }); + + it('should return colors that have been set manually', () => { + const generator = new ColorGenerator(); + const manualColor = {h: 1, s: 2, l: 3, a: 4}; + generator.setColorForID('id with set color', manualColor); + expect(generator.colorForID('id with set color')).toEqual(manualColor); + expect(generator.colorForID('some other id')).not.toEqual(manualColor); + }); + + it('should generate colors from fixed color spaces', () => { + const generator = new ColorGenerator(1, 2, 3, 4); + expect(generator.colorForID('123')).toEqual({h: 1, s: 2, l: 3, a: 4}); + expect(generator.colorForID('234')).toEqual({h: 1, s: 2, l: 3, a: 4}); + }); + + it('should generate colors from range color spaces', () => { + const generator = new ColorGenerator( + {min: 0, max: 360, count: 2}, + 2, + 3, + 4, + ); + expect(generator.colorForID('123')).toEqual({h: 0, s: 2, l: 3, a: 4}); + expect(generator.colorForID('234')).toEqual({h: 360, s: 2, l: 3, a: 4}); + }); + }); +}); diff --git a/packages/react-devtools-scheduling-profiler/src/content-views/utils/colors.js b/packages/react-devtools-scheduling-profiler/src/content-views/utils/colors.js new file mode 100644 index 0000000000..a1ad49b588 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/content-views/utils/colors.js @@ -0,0 +1,113 @@ +/** + * 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 + */ + +type ColorSpace = number | {|min: number, max: number, count?: number|}; + +// Docstrings from https://www.w3schools.com/css/css_colors_hsl.asp +type HslaColor = $ReadOnly<{| + /** Hue is a degree on the color wheel from 0 to 360. 0 is red, 120 is green, and 240 is blue. */ + h: number, + /** Saturation is a percentage value, 0% means a shade of gray, and 100% is the full color. */ + s: number, + /** Lightness is a percentage, 0% is black, 50% is neither light or dark, 100% is white. */ + l: number, + /** Alpha is a percentage, 0% is fully transparent, and 100 is not transparent at all. */ + a: number, +|}>; + +export function hslaColorToString({h, s, l, a}: HslaColor): string { + return `hsl(${h}deg ${s}% ${l}% / ${a})`; +} + +export function dimmedColor(color: HslaColor, dimDelta: number): HslaColor { + return { + ...color, + l: color.l - dimDelta, + }; +} + +// Source: https://source.chromium.org/chromium/chromium/src/+/master:out/Debug/gen/devtools/platform/utilities.js;l=120 +function hashCode(string: string): number { + // Hash algorithm for substrings is described in "Über die Komplexität der Multiplikation in + // eingeschränkten Branchingprogrammmodellen" by Woelfe. + // http://opendatastructures.org/versions/edition-0.1d/ods-java/node33.html#SECTION00832000000000000000 + const p = (1 << 30) * 4 - 5; // prime: 2^32 - 5 + const z = 0x5033d967; // 32 bits from random.org + const z2 = 0x59d2f15d; // random odd 32 bit number + let s = 0; + let zi = 1; + for (let i = 0; i < string.length; i++) { + const xi = string.charCodeAt(i) * z2; + s = (s + zi * xi) % p; + zi = (zi * z) % p; + } + s = (s + zi * (p - 1)) % p; + return Math.abs(s | 0); +} + +function indexToValueInSpace(index: number, space: ColorSpace): number { + if (typeof space === 'number') { + return space; + } + const count = space.count || space.max - space.min; + index %= count; + return ( + space.min + Math.floor((index / (count - 1)) * (space.max - space.min)) + ); +} + +/** + * Deterministic color generator. + * + * Adapted from: https://source.chromium.org/chromium/chromium/src/+/master:out/Debug/gen/devtools/common/Color.js + */ +export class ColorGenerator { + _hueSpace: ColorSpace; + _satSpace: ColorSpace; + _lightnessSpace: ColorSpace; + _alphaSpace: ColorSpace; + _colors: Map; + + constructor( + hueSpace?: ColorSpace, + satSpace?: ColorSpace, + lightnessSpace?: ColorSpace, + alphaSpace?: ColorSpace, + ) { + this._hueSpace = hueSpace || {min: 0, max: 360}; + this._satSpace = satSpace || 67; + this._lightnessSpace = lightnessSpace || 80; + this._alphaSpace = alphaSpace || 1; + this._colors = new Map(); + } + + setColorForID(id: string, color: HslaColor) { + this._colors.set(id, color); + } + + colorForID(id: string): HslaColor { + const cachedColor = this._colors.get(id); + if (cachedColor) { + return cachedColor; + } + const color = this._generateColorForID(id); + this._colors.set(id, color); + return color; + } + + _generateColorForID(id: string): HslaColor { + const hash = hashCode(id); + return { + h: indexToValueInSpace(hash, this._hueSpace), + s: indexToValueInSpace(hash >> 8, this._satSpace), + l: indexToValueInSpace(hash >> 16, this._lightnessSpace), + a: indexToValueInSpace(hash >> 24, this._alphaSpace), + }; + } +} diff --git a/packages/react-devtools-scheduling-profiler/src/content-views/utils/positioning.js b/packages/react-devtools-scheduling-profiler/src/content-views/utils/positioning.js new file mode 100644 index 0000000000..8cea48ced9 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/content-views/utils/positioning.js @@ -0,0 +1,41 @@ +/** + * 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 type {Rect} from '../../view-base'; + +export function positioningScaleFactor( + intrinsicWidth: number, + frame: Rect, +): number { + return frame.size.width / intrinsicWidth; +} + +export function timestampToPosition( + timestamp: number, + scaleFactor: number, + frame: Rect, +): number { + return frame.origin.x + timestamp * scaleFactor; +} + +export function positionToTimestamp( + position: number, + scaleFactor: number, + frame: Rect, +): number { + return (position - frame.origin.x) / scaleFactor; +} + +export function durationToWidth(duration: number, scaleFactor: number): number { + return duration * scaleFactor; +} + +export function widthToDuration(width: number, scaleFactor: number): number { + return width / scaleFactor; +} diff --git a/packages/react-devtools-scheduling-profiler/src/context/ContextMenu.css b/packages/react-devtools-scheduling-profiler/src/context/ContextMenu.css new file mode 100644 index 0000000000..60848641f4 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/context/ContextMenu.css @@ -0,0 +1,10 @@ +.ContextMenu { + position: absolute; + border-radius: 0.125rem; + background-color: #ffffff; + border: 1px solid #ccc; + box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.2); + font-size: 11px; + overflow: hidden; + z-index: 10000002; +} diff --git a/packages/react-devtools-scheduling-profiler/src/context/ContextMenu.js b/packages/react-devtools-scheduling-profiler/src/context/ContextMenu.js new file mode 100644 index 0000000000..8b09ef1510 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/context/ContextMenu.js @@ -0,0 +1,143 @@ +/** + * 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 type {RegistryContextType} from './Contexts'; + +import * as React from 'react'; +import {useContext, useEffect, useLayoutEffect, useRef, useState} from 'react'; +import {createPortal} from 'react-dom'; +import {RegistryContext} from './Contexts'; + +import styles from './ContextMenu.css'; + +function repositionToFit(element: HTMLElement, pageX: number, pageY: number) { + const ownerWindow = element.ownerDocument.defaultView; + if (element !== null) { + if (pageY + element.offsetHeight >= ownerWindow.innerHeight) { + if (pageY - element.offsetHeight > 0) { + element.style.top = `${pageY - element.offsetHeight}px`; + } else { + element.style.top = '0px'; + } + } else { + element.style.top = `${pageY}px`; + } + + if (pageX + element.offsetWidth >= ownerWindow.innerWidth) { + if (pageX - element.offsetWidth > 0) { + element.style.left = `${pageX - element.offsetWidth}px`; + } else { + element.style.left = '0px'; + } + } else { + element.style.left = `${pageX}px`; + } + } +} + +const HIDDEN_STATE = { + data: null, + isVisible: false, + pageX: 0, + pageY: 0, +}; + +type Props = {| + children: (data: Object) => React$Node, + id: string, +|}; + +export default function ContextMenu({children, id}: Props) { + const {hideMenu, registerMenu} = useContext( + RegistryContext, + ); + + const [state, setState] = useState(HIDDEN_STATE); + + const bodyAccessorRef = useRef(null); + const containerRef = useRef(null); + const menuRef = useRef(null); + + useEffect(() => { + if (!bodyAccessorRef.current) { + return; + } + const ownerDocument = bodyAccessorRef.current.ownerDocument; + containerRef.current = ownerDocument.createElement('div'); + if (ownerDocument.body) { + ownerDocument.body.appendChild(containerRef.current); + } + return () => { + if (ownerDocument.body && containerRef.current) { + ownerDocument.body.removeChild(containerRef.current); + } + }; + }, [bodyAccessorRef, containerRef]); + + useEffect(() => { + const showMenuFn = ({data, pageX, pageY}) => { + setState({data, isVisible: true, pageX, pageY}); + }; + const hideMenuFn = () => setState(HIDDEN_STATE); + return registerMenu(id, showMenuFn, hideMenuFn); + }, [id]); + + useLayoutEffect(() => { + if (!state.isVisible || !containerRef.current) { + return; + } + + const menu = menuRef.current; + if (!menu) { + return; + } + + const hideUnlessContains: MouseEventHandler & + TouchEventHandler & + KeyboardEventHandler = event => { + if (event.target instanceof HTMLElement && !menu.contains(event.target)) { + hideMenu(); + } + }; + + const ownerDocument = containerRef.current.ownerDocument; + ownerDocument.addEventListener('mousedown', hideUnlessContains); + ownerDocument.addEventListener('touchstart', hideUnlessContains); + ownerDocument.addEventListener('keydown', hideUnlessContains); + + const ownerWindow = ownerDocument.defaultView; + ownerWindow.addEventListener('resize', hideMenu); + + repositionToFit(menu, state.pageX, state.pageY); + + return () => { + ownerDocument.removeEventListener('mousedown', hideUnlessContains); + ownerDocument.removeEventListener('touchstart', hideUnlessContains); + ownerDocument.removeEventListener('keydown', hideUnlessContains); + + ownerWindow.removeEventListener('resize', hideMenu); + }; + }, [state]); + + if (!state.isVisible) { + return

; + } else { + const container = containerRef.current; + if (container !== null) { + return createPortal( +
+ {children(state.data)} +
, + container, + ); + } else { + return null; + } + } +} diff --git a/packages/react-devtools-scheduling-profiler/src/context/ContextMenuItem.css b/packages/react-devtools-scheduling-profiler/src/context/ContextMenuItem.css new file mode 100644 index 0000000000..19fd8284a4 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/context/ContextMenuItem.css @@ -0,0 +1,20 @@ +.ContextMenuItem { + display: flex; + align-items: center; + color: #333; + padding: 0.5rem 0.75rem; + cursor: default; + border-top: 1px solid #ccc; +} +.ContextMenuItem:first-of-type { + border-top: none; +} +.ContextMenuItem:hover, +.ContextMenuItem:focus { + outline: 0; + background-color: rgba(0, 136, 250, 0.1); +} +.ContextMenuItem:active { + background-color: #0088fa; + color: #fff; +} diff --git a/packages/react-devtools-scheduling-profiler/src/context/ContextMenuItem.js b/packages/react-devtools-scheduling-profiler/src/context/ContextMenuItem.js new file mode 100644 index 0000000000..5750bd90cd --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/context/ContextMenuItem.js @@ -0,0 +1,40 @@ +/** + * 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 type {RegistryContextType} from './Contexts'; + +import * as React from 'react'; +import {useContext} from 'react'; +import {RegistryContext} from './Contexts'; + +import styles from './ContextMenuItem.css'; + +type Props = {| + children: React$Node, + onClick: () => void, + title: string, +|}; + +export default function ContextMenuItem({children, onClick, title}: Props) { + const {hideMenu} = useContext(RegistryContext); + + const handleClick: MouseEventHandler = event => { + onClick(); + hideMenu(); + }; + + return ( +
+ {children} +
+ ); +} diff --git a/packages/react-devtools-scheduling-profiler/src/context/Contexts.js b/packages/react-devtools-scheduling-profiler/src/context/Contexts.js new file mode 100644 index 0000000000..46c742e06d --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/context/Contexts.js @@ -0,0 +1,87 @@ +/** + * 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 {createContext} from 'react'; + +export type ShowFn = ({|data: Object, pageX: number, pageY: number|}) => void; +export type HideFn = () => void; +export type OnChangeFn = boolean => void; + +const idToShowFnMap = new Map(); +const idToHideFnMap = new Map(); + +let currentHideFn: ?HideFn = null; +let currentOnChange: ?OnChangeFn = null; + +function hideMenu() { + if (typeof currentHideFn === 'function') { + currentHideFn(); + + if (typeof currentOnChange === 'function') { + currentOnChange(false); + } + } + + currentHideFn = null; + currentOnChange = null; +} + +function showMenu({ + data, + id, + onChange, + pageX, + pageY, +}: {| + data: Object, + id: string, + onChange?: OnChangeFn, + pageX: number, + pageY: number, +|}) { + const showFn = idToShowFnMap.get(id); + if (typeof showFn === 'function') { + // Prevent open menus from being left hanging. + hideMenu(); + + currentHideFn = idToHideFnMap.get(id); + showFn({data, pageX, pageY}); + + if (typeof onChange === 'function') { + currentOnChange = onChange; + onChange(true); + } + } +} + +function registerMenu(id: string, showFn: ShowFn, hideFn: HideFn) { + if (idToShowFnMap.has(id)) { + throw Error(`Context menu with id "${id}" already registered.`); + } + + idToShowFnMap.set(id, showFn); + idToHideFnMap.set(id, hideFn); + + return function unregisterMenu() { + idToShowFnMap.delete(id); + idToHideFnMap.delete(id); + }; +} + +export type RegistryContextType = {| + hideMenu: typeof hideMenu, + showMenu: typeof showMenu, + registerMenu: typeof registerMenu, +|}; + +export const RegistryContext = createContext({ + hideMenu, + showMenu, + registerMenu, +}); diff --git a/packages/react-devtools-scheduling-profiler/src/context/index.js b/packages/react-devtools-scheduling-profiler/src/context/index.js new file mode 100644 index 0000000000..c903d4f886 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/context/index.js @@ -0,0 +1,15 @@ +/** + * 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 {RegistryContext} from './Contexts'; +import ContextMenu from './ContextMenu'; +import ContextMenuItem from './ContextMenuItem'; +import useContextMenu from './useContextMenu'; + +export {RegistryContext, ContextMenu, ContextMenuItem, useContextMenu}; diff --git a/packages/react-devtools-scheduling-profiler/src/context/useContextMenu.js b/packages/react-devtools-scheduling-profiler/src/context/useContextMenu.js new file mode 100644 index 0000000000..467c138f62 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/context/useContextMenu.js @@ -0,0 +1,52 @@ +/** + * 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 type {OnChangeFn, RegistryContextType} from './Contexts'; + +import {useContext, useEffect} from 'react'; +import {RegistryContext} from './Contexts'; + +export default function useContextMenu({ + data, + id, + onChange, + ref, +}: {| + data: T, + id: string, + onChange: OnChangeFn, + ref: {+current: HTMLElement | null}, +|}) { + const {showMenu} = useContext(RegistryContext); + + useEffect(() => { + if (ref.current !== null) { + const handleContextMenu = (event: MouseEvent | TouchEvent) => { + event.preventDefault(); + event.stopPropagation(); + + const pageX = + (event: any).pageX || + (event.touches && (event: any).touches[0].pageX); + const pageY = + (event: any).pageY || + (event.touches && (event: any).touches[0].pageY); + + showMenu({data, id, onChange, pageX, pageY}); + }; + + const trigger = ref.current; + trigger.addEventListener('contextmenu', handleContextMenu); + + return () => { + trigger.removeEventListener('contextmenu', handleContextMenu); + }; + } + }, [data, id, showMenu]); +} diff --git a/packages/react-devtools-scheduling-profiler/src/index.css b/packages/react-devtools-scheduling-profiler/src/index.css new file mode 100644 index 0000000000..ec2585e8c0 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/index.css @@ -0,0 +1,13 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} diff --git a/packages/react-devtools-scheduling-profiler/src/index.js b/packages/react-devtools-scheduling-profiler/src/index.js new file mode 100644 index 0000000000..ffb1a8d501 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/index.js @@ -0,0 +1,30 @@ +/** + * 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 * as React from 'react'; +// $FlowFixMe Flow does not yet know about createRoot() +import {unstable_createRoot as createRoot} from 'react-dom'; +import nullthrows from 'nullthrows'; +import App from './App'; + +import './index.css'; + +const container = document.createElement('div'); +container.id = 'root'; + +const body = nullthrows(document.body, 'Expect document.body to exist'); +body.appendChild(container); + +createRoot(container).render( + + + , +); diff --git a/packages/react-devtools-scheduling-profiler/src/types.js b/packages/react-devtools-scheduling-profiler/src/types.js new file mode 100644 index 0000000000..a5a1c0d43b --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/types.js @@ -0,0 +1,135 @@ +/** + * 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 + */ + +// Type utilities + +// Source: https://github.com/facebook/flow/issues/4002#issuecomment-323612798 +// eslint-disable-next-line no-unused-vars +type Return_) => R> = R; +/** Get return type of a function. */ +export type Return = Return_<*, T>; + +// Project types + +export type Milliseconds = number; + +export type ReactLane = number; + +type BaseReactEvent = {| + +componentName?: string, + +componentStack?: string, + +timestamp: Milliseconds, +|}; + +type BaseReactScheduleEvent = {| + ...BaseReactEvent, + +lanes: ReactLane[], +|}; +export type ReactScheduleRenderEvent = {| + ...BaseReactScheduleEvent, + type: 'schedule-render', +|}; +export type ReactScheduleStateUpdateEvent = {| + ...BaseReactScheduleEvent, + type: 'schedule-state-update', + isCascading: boolean, +|}; +export type ReactScheduleForceUpdateEvent = {| + ...BaseReactScheduleEvent, + type: 'schedule-force-update', + isCascading: boolean, +|}; + +type BaseReactSuspenseEvent = {| + ...BaseReactEvent, + id: string, +|}; +export type ReactSuspenseSuspendEvent = {| + ...BaseReactSuspenseEvent, + type: 'suspense-suspend', +|}; +export type ReactSuspenseResolvedEvent = {| + ...BaseReactSuspenseEvent, + type: 'suspense-resolved', +|}; +export type ReactSuspenseRejectedEvent = {| + ...BaseReactSuspenseEvent, + type: 'suspense-rejected', +|}; + +export type ReactEvent = + | ReactScheduleRenderEvent + | ReactScheduleStateUpdateEvent + | ReactScheduleForceUpdateEvent + | ReactSuspenseSuspendEvent + | ReactSuspenseResolvedEvent + | ReactSuspenseRejectedEvent; +export type ReactEventType = $PropertyType; + +export type ReactMeasureType = + | 'commit' + // render-idle: A measure spanning the time when a render starts, through all + // yields and restarts, and ends when commit stops OR render is cancelled. + | 'render-idle' + | 'render' + | 'layout-effects' + | 'passive-effects'; + +export type BatchUID = number; + +export type ReactMeasure = {| + +type: ReactMeasureType, + +lanes: ReactLane[], + +timestamp: Milliseconds, + +duration: Milliseconds, + +batchUID: BatchUID, + +depth: number, +|}; + +/** + * A flamechart stack frame belonging to a stack trace. + */ +export type FlamechartStackFrame = {| + name: string, + timestamp: Milliseconds, + duration: Milliseconds, + scriptUrl?: string, + locationLine?: number, + locationColumn?: number, +|}; + +export type UserTimingMark = {| + name: string, + timestamp: Milliseconds, +|}; + +/** + * A "layer" of stack frames in the profiler UI, i.e. all stack frames of the + * same depth across all stack traces. Displayed as a flamechart row in the UI. + */ +export type FlamechartStackLayer = FlamechartStackFrame[]; + +export type Flamechart = FlamechartStackLayer[]; + +export type ReactProfilerData = {| + startTime: number, + duration: number, + events: ReactEvent[], + measures: ReactMeasure[], + flamechart: Flamechart, + otherUserTimingMarks: UserTimingMark[], +|}; + +export type ReactHoverContextInfo = {| + event: ReactEvent | null, + measure: ReactMeasure | null, + data: $ReadOnly | null, + flamechartStackFrame: FlamechartStackFrame | null, + userTimingMark: UserTimingMark | null, +|}; diff --git a/packages/react-devtools-scheduling-profiler/src/utils/__tests__/__snapshots__/preprocessData-test.js.snap b/packages/react-devtools-scheduling-profiler/src/utils/__tests__/__snapshots__/preprocessData-test.js.snap new file mode 100644 index 0000000000..4055b6d45e --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/utils/__tests__/__snapshots__/preprocessData-test.js.snap @@ -0,0 +1,1380 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`preprocessData should process complete set of events (page load sample data) 1`] = ` +Object { + "duration": 2586.895, + "events": Array [ + Object { + "componentStack": "", + "lanes": Array [ + 9, + ], + "timestamp": 278.073, + "type": "schedule-render", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "0", + "timestamp": 658.345, + "type": "suspense-suspend", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "1", + "timestamp": 660.739, + "type": "suspense-suspend", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "2", + "timestamp": 662.554, + "type": "suspense-suspend", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "3", + "timestamp": 663.455, + "type": "suspense-suspend", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "4", + "timestamp": 664.202, + "type": "suspense-suspend", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "0", + "timestamp": 666.68, + "type": "suspense-suspend", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "1", + "timestamp": 667.201, + "type": "suspense-suspend", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "2", + "timestamp": 667.74, + "type": "suspense-suspend", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "3", + "timestamp": 668.282, + "type": "suspense-suspend", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "4", + "timestamp": 668.848, + "type": "suspense-suspend", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at SuspenseList + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "0", + "timestamp": 671.335, + "type": "suspense-suspend", + }, + Object { + "componentName": "ForceUpdateDemo_ForceUpdateDemo", + "componentStack": " + at ForceUpdateDemo_ForceUpdateDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:18:98) + at App", + "isCascading": true, + "lanes": Array [ + 0, + ], + "timestamp": 679.438, + "type": "schedule-state-update", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "1", + "timestamp": 1794.922, + "type": "suspense-resolved", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "1", + "timestamp": 1795.374, + "type": "suspense-resolved", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "0", + "timestamp": 1797.166, + "type": "suspense-suspend", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "2", + "timestamp": 1797.811, + "type": "suspense-suspend", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "3", + "timestamp": 1798.163, + "type": "suspense-suspend", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "4", + "timestamp": 1798.505, + "type": "suspense-suspend", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "2", + "timestamp": 1866.249, + "type": "suspense-resolved", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "2", + "timestamp": 1866.542, + "type": "suspense-resolved", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "2", + "timestamp": 1866.858, + "type": "suspense-resolved", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "0", + "timestamp": 1867.816, + "type": "suspense-suspend", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "3", + "timestamp": 1868.638, + "type": "suspense-suspend", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "4", + "timestamp": 1868.966, + "type": "suspense-suspend", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "3", + "timestamp": 1980.273, + "type": "suspense-resolved", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "3", + "timestamp": 1980.522, + "type": "suspense-resolved", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "3", + "timestamp": 1980.946, + "type": "suspense-resolved", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "3", + "timestamp": 1981.164, + "type": "suspense-resolved", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "0", + "timestamp": 1982.079, + "type": "suspense-suspend", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "4", + "timestamp": 1983.17, + "type": "suspense-suspend", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "4", + "timestamp": 2379.587, + "type": "suspense-resolved", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "4", + "timestamp": 2379.895, + "type": "suspense-resolved", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "4", + "timestamp": 2380.242, + "type": "suspense-resolved", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "4", + "timestamp": 2380.487, + "type": "suspense-resolved", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "4", + "timestamp": 2380.735, + "type": "suspense-resolved", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "0", + "timestamp": 2381.772, + "type": "suspense-suspend", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "0", + "timestamp": 2579.186, + "type": "suspense-resolved", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "0", + "timestamp": 2579.487, + "type": "suspense-resolved", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at SuspenseList + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "0", + "timestamp": 2579.735, + "type": "suspense-resolved", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "0", + "timestamp": 2580.042, + "type": "suspense-resolved", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "0", + "timestamp": 2580.29, + "type": "suspense-resolved", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "0", + "timestamp": 2580.546, + "type": "suspense-resolved", + }, + Object { + "componentName": "ResourceButton", + "componentStack": " + at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295) + at Suspense + at div + at div + at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713) + at App", + "id": "0", + "timestamp": 2580.8, + "type": "suspense-resolved", + }, + ], + "flamechart": Array [], + "measures": Array [ + Object { + "batchUID": 0, + "depth": 0, + "duration": 402.58299999999997, + "lanes": Array [ + 9, + ], + "timestamp": 285.293, + "type": "render-idle", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 5.235000000000014, + "lanes": Array [ + 9, + ], + "timestamp": 285.293, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 8.16799999999995, + "lanes": Array [ + 9, + ], + "timestamp": 297.708, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 4.989000000000033, + "lanes": Array [ + 9, + ], + "timestamp": 310.457, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 6.470000000000027, + "lanes": Array [ + 9, + ], + "timestamp": 357.767, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 6.633000000000038, + "lanes": Array [ + 9, + ], + "timestamp": 371.486, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 9.889999999999986, + "lanes": Array [ + 9, + ], + "timestamp": 385.05, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.8589999999999804, + "lanes": Array [ + 9, + ], + "timestamp": 398.06, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.261000000000024, + "lanes": Array [ + 9, + ], + "timestamp": 406.666, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.700999999999965, + "lanes": Array [ + 9, + ], + "timestamp": 414.218, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.793000000000006, + "lanes": Array [ + 9, + ], + "timestamp": 422.141, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.706999999999994, + "lanes": Array [ + 9, + ], + "timestamp": 430.185, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.406999999999982, + "lanes": Array [ + 9, + ], + "timestamp": 439.552, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.649000000000001, + "lanes": Array [ + 9, + ], + "timestamp": 447.294, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.736000000000047, + "lanes": Array [ + 9, + ], + "timestamp": 455.215, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.6959999999999695, + "lanes": Array [ + 9, + ], + "timestamp": 463.259, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.700999999999965, + "lanes": Array [ + 9, + ], + "timestamp": 471.247, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.55499999999995, + "lanes": Array [ + 9, + ], + "timestamp": 479.362, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.7900000000000205, + "lanes": Array [ + 9, + ], + "timestamp": 487.195, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.617000000000019, + "lanes": Array [ + 9, + ], + "timestamp": 495.354, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.241999999999962, + "lanes": Array [ + 9, + ], + "timestamp": 503.706, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.81899999999996, + "lanes": Array [ + 9, + ], + "timestamp": 513.106, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 9.269999999999982, + "lanes": Array [ + 9, + ], + "timestamp": 521.667, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.758000000000038, + "lanes": Array [ + 9, + ], + "timestamp": 531.185, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.735000000000014, + "lanes": Array [ + 9, + ], + "timestamp": 539.213, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.730000000000018, + "lanes": Array [ + 9, + ], + "timestamp": 547.223, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.697000000000003, + "lanes": Array [ + 9, + ], + "timestamp": 555.234, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.715000000000032, + "lanes": Array [ + 9, + ], + "timestamp": 563.232, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.760999999999967, + "lanes": Array [ + 9, + ], + "timestamp": 571.212, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.618000000000052, + "lanes": Array [ + 9, + ], + "timestamp": 579.324, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.737999999999943, + "lanes": Array [ + 9, + ], + "timestamp": 587.201, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.739000000000033, + "lanes": Array [ + 9, + ], + "timestamp": 595.209, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.732000000000085, + "lanes": Array [ + 9, + ], + "timestamp": 603.194, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.712999999999965, + "lanes": Array [ + 9, + ], + "timestamp": 611.212, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.3700000000000045, + "lanes": Array [ + 9, + ], + "timestamp": 619.574, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.73599999999999, + "lanes": Array [ + 9, + ], + "timestamp": 627.188, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.729000000000042, + "lanes": Array [ + 9, + ], + "timestamp": 635.193, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.908999999999992, + "lanes": Array [ + 9, + ], + "timestamp": 643.206, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 7.6159999999999854, + "lanes": Array [ + 9, + ], + "timestamp": 651.339, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 5.334999999999923, + "lanes": Array [ + 9, + ], + "timestamp": 659.445, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 5.5890000000000555, + "lanes": Array [ + 9, + ], + "timestamp": 664.952, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 1.58400000000006, + "lanes": Array [ + 9, + ], + "timestamp": 670.784, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 14.552000000000021, + "lanes": Array [ + 9, + ], + "timestamp": 673.324, + "type": "commit", + }, + Object { + "batchUID": 0, + "depth": 1, + "duration": 2.6889999999999645, + "lanes": Array [ + 9, + ], + "timestamp": 677.236, + "type": "layout-effects", + }, + Object { + "batchUID": 1, + "depth": 1, + "duration": 3.5559999999999263, + "lanes": Array [ + 0, + ], + "timestamp": 684.104, + "type": "render-idle", + }, + Object { + "batchUID": 1, + "depth": 1, + "duration": 2.2169999999999845, + "lanes": Array [ + 0, + ], + "timestamp": 684.104, + "type": "render", + }, + Object { + "batchUID": 1, + "depth": 1, + "duration": 1.3120000000000118, + "lanes": Array [ + 0, + ], + "timestamp": 686.348, + "type": "commit", + }, + Object { + "batchUID": 1, + "depth": 2, + "duration": 0.020999999999958163, + "lanes": Array [ + 0, + ], + "timestamp": 687.267, + "type": "layout-effects", + }, + Object { + "batchUID": 2, + "depth": 0, + "duration": 6.119000000000142, + "lanes": Array [ + 10, + ], + "timestamp": 1796.042, + "type": "render-idle", + }, + Object { + "batchUID": 2, + "depth": 0, + "duration": 3.4329999999999927, + "lanes": Array [ + 10, + ], + "timestamp": 1796.042, + "type": "render", + }, + Object { + "batchUID": 2, + "depth": 0, + "duration": 2.589000000000169, + "lanes": Array [ + 10, + ], + "timestamp": 1799.572, + "type": "commit", + }, + Object { + "batchUID": 2, + "depth": 1, + "duration": 0.020999999999958163, + "lanes": Array [ + 10, + ], + "timestamp": 1801.605, + "type": "layout-effects", + }, + Object { + "batchUID": 3, + "depth": 0, + "duration": 4.116999999999962, + "lanes": Array [ + 11, + ], + "timestamp": 1866.998, + "type": "render-idle", + }, + Object { + "batchUID": 3, + "depth": 0, + "duration": 2.6679999999998927, + "lanes": Array [ + 11, + ], + "timestamp": 1866.998, + "type": "render", + }, + Object { + "batchUID": 3, + "depth": 0, + "duration": 1.4200000000000728, + "lanes": Array [ + 11, + ], + "timestamp": 1869.695, + "type": "commit", + }, + Object { + "batchUID": 3, + "depth": 1, + "duration": 0.020000000000209184, + "lanes": Array [ + 11, + ], + "timestamp": 1870.764, + "type": "layout-effects", + }, + Object { + "batchUID": 4, + "depth": 0, + "duration": 3.7679999999998017, + "lanes": Array [ + 12, + ], + "timestamp": 1981.361, + "type": "render-idle", + }, + Object { + "batchUID": 4, + "depth": 0, + "duration": 2.438999999999851, + "lanes": Array [ + 12, + ], + "timestamp": 1981.361, + "type": "render", + }, + Object { + "batchUID": 4, + "depth": 0, + "duration": 1.2579999999998108, + "lanes": Array [ + 12, + ], + "timestamp": 1983.871, + "type": "commit", + }, + Object { + "batchUID": 4, + "depth": 1, + "duration": 0.027000000000043656, + "lanes": Array [ + 12, + ], + "timestamp": 1984.931, + "type": "layout-effects", + }, + Object { + "batchUID": 5, + "depth": 0, + "duration": 3.8899999999998727, + "lanes": Array [ + 13, + ], + "timestamp": 2380.969, + "type": "render-idle", + }, + Object { + "batchUID": 5, + "depth": 0, + "duration": 2.3099999999999454, + "lanes": Array [ + 13, + ], + "timestamp": 2380.969, + "type": "render", + }, + Object { + "batchUID": 5, + "depth": 0, + "duration": 1.5209999999997308, + "lanes": Array [ + 13, + ], + "timestamp": 2383.338, + "type": "commit", + }, + Object { + "batchUID": 5, + "depth": 1, + "duration": 0.01999999999998181, + "lanes": Array [ + 13, + ], + "timestamp": 2384.535, + "type": "layout-effects", + }, + Object { + "batchUID": 6, + "depth": 0, + "duration": 5.942999999999756, + "lanes": Array [ + 14, + ], + "timestamp": 2580.952, + "type": "render-idle", + }, + Object { + "batchUID": 6, + "depth": 0, + "duration": 3.424999999999727, + "lanes": Array [ + 14, + ], + "timestamp": 2580.952, + "type": "render", + }, + Object { + "batchUID": 6, + "depth": 0, + "duration": 2.4470000000001164, + "lanes": Array [ + 14, + ], + "timestamp": 2584.448, + "type": "commit", + }, + Object { + "batchUID": 6, + "depth": 1, + "duration": 0.027000000000043656, + "lanes": Array [ + 14, + ], + "timestamp": 2586.122, + "type": "layout-effects", + }, + ], + "otherUserTimingMarks": Array [ + Object { + "name": "navigationStart", + "timestamp": -29.357, + }, + Object { + "name": "fetchStart", + "timestamp": -26.92, + }, + Object { + "name": "requestStart", + "timestamp": -21.171, + }, + Object { + "name": "responseEnd", + "timestamp": -15.655, + }, + Object { + "name": "unloadEventStart", + "timestamp": -0.74, + }, + Object { + "name": "unloadEventEnd", + "timestamp": 39.608, + }, + Object { + "name": "requestStart", + "timestamp": 107.649, + }, + Object { + "name": "requestStart", + "timestamp": 108.385, + }, + Object { + "name": "loadEventStart", + "timestamp": 307.056, + }, + Object { + "name": "loadEventEnd", + "timestamp": 308.242, + }, + Object { + "name": "requestStart", + "timestamp": 341.329, + }, + Object { + "name": "requestStart", + "timestamp": 344.02, + }, + Object { + "name": "requestStart", + "timestamp": 387.585, + }, + ], + "startTime": 8993778496, +} +`; + +exports[`preprocessData should process forced update event 1`] = ` +Object { + "duration": 67.461, + "events": Array [ + Object { + "componentName": "ForceUpdateDemo_ForceUpdateDemo", + "componentStack": " + at ForceUpdateDemo_ForceUpdateDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:18:98) + at App", + "isCascading": false, + "lanes": Array [ + 4, + ], + "timestamp": 63.355, + "type": "schedule-force-update", + }, + ], + "flamechart": Array [], + "measures": Array [ + Object { + "batchUID": 0, + "depth": 0, + "duration": 2.1910000000000025, + "lanes": Array [ + 4, + ], + "timestamp": 65.27, + "type": "render-idle", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 0.9770000000000039, + "lanes": Array [ + 4, + ], + "timestamp": 65.27, + "type": "render", + }, + Object { + "batchUID": 0, + "depth": 0, + "duration": 1.1670000000000016, + "lanes": Array [ + 4, + ], + "timestamp": 66.294, + "type": "commit", + }, + Object { + "batchUID": 0, + "depth": 1, + "duration": 0.018000000000000682, + "lanes": Array [ + 4, + ], + "timestamp": 67.325, + "type": "layout-effects", + }, + ], + "otherUserTimingMarks": Array [], + "startTime": 40806924876, +} +`; diff --git a/packages/react-devtools-scheduling-profiler/src/utils/__tests__/preprocessData-test.js b/packages/react-devtools-scheduling-profiler/src/utils/__tests__/preprocessData-test.js new file mode 100644 index 0000000000..8626252cee --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/utils/__tests__/preprocessData-test.js @@ -0,0 +1,352 @@ +/** + * 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. + */ + +'use strict'; + +import preprocessData, { + getLanesFromTransportDecimalBitmask, +} from '../preprocessData'; +import {REACT_TOTAL_NUM_LANES} from '../../constants'; + +// Disable quotes rule in the whole file as we paste raw JSON as test inputs and +// Prettier will already format the remaining quotes. +/* eslint-disable quotes */ + +describe(getLanesFromTransportDecimalBitmask, () => { + it('should return array of lane numbers from bitmask string', () => { + expect(getLanesFromTransportDecimalBitmask('1')).toEqual([0]); + expect(getLanesFromTransportDecimalBitmask('512')).toEqual([9]); + expect(getLanesFromTransportDecimalBitmask('3')).toEqual([0, 1]); + expect(getLanesFromTransportDecimalBitmask('1234')).toEqual([ + 1, + 4, + 6, + 7, + 10, + ]); // 2 + 16 + 64 + 128 + 1024 + expect( + getLanesFromTransportDecimalBitmask('1073741824'), // 0b1000000000000000000000000000000 + ).toEqual([30]); + expect( + getLanesFromTransportDecimalBitmask('2147483647'), // 0b1111111111111111111111111111111 + ).toEqual(Array.from(Array(31).keys())); + }); + + it('should return empty array if laneBitmaskString is not a bitmask', () => { + expect(getLanesFromTransportDecimalBitmask('')).toEqual([]); + expect(getLanesFromTransportDecimalBitmask('hello')).toEqual([]); + expect(getLanesFromTransportDecimalBitmask('-1')).toEqual([]); + expect(getLanesFromTransportDecimalBitmask('-0')).toEqual([]); + }); + + it('should ignore lanes outside REACT_TOTAL_NUM_LANES', () => { + // Sanity check; this test may need to be updated when the no. of fiber + // lanes are changed. + expect(REACT_TOTAL_NUM_LANES).toBe(31); + + expect( + getLanesFromTransportDecimalBitmask( + '4294967297', // 2^32 + 1 + ), + ).toEqual([0]); + }); +}); + +describe(preprocessData, () => { + it('should throw given an empty timeline', () => { + expect(() => preprocessData([])).toThrow(); + }); + + it('should throw given a timeline with no Profile event', () => { + expect(() => + // prettier-ignore + preprocessData([ + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--schedule-render-512-","ph":"R","pid":9312,"tid":10252,"ts":8994056569,"tts":1816966}, + ]), + ).toThrow(); + }); + + it('should return empty data given a timeline with no React scheduling profiling marks', () => { + expect( + // prettier-ignore + preprocessData([ + {"args":{"data":{"startTime":8993778496}},"cat":"disabled-by-default-v8.cpu_profiler","id":"0x1","name":"Profile","ph":"P","pid":9312,"tid":10252,"ts":8993778520,"tts":1614266}, + {"pid":57632,"tid":38659,"ts":874860756135,"ph":"X","cat":"disabled-by-default-devtools.timeline","name":"RunTask","dur":18,"tdur":19,"tts":8700284918,"args":{}}, + {"pid":57632,"tid":38659,"ts":874860756158,"ph":"X","cat":"disabled-by-default-devtools.timeline","name":"RunTask","dur":30,"tdur":30,"tts":8700284941,"args":{}}, + {"pid":57632,"tid":38659,"ts":874860756192,"ph":"X","cat":"disabled-by-default-devtools.timeline","name":"RunTask","dur":21,"tdur":20,"tts":8700284976,"args":{}}, + {"pid":57632,"tid":38659,"ts":874860756216,"ph":"X","cat":"disabled-by-default-devtools.timeline","name":"RunTask","dur":6,"tdur":5,"tts":8700285000,"args":{}}, + {"pid":57632,"tid":38659,"ts":874860756224,"ph":"X","cat":"disabled-by-default-devtools.timeline","name":"RunTask","dur":7,"tdur":6,"tts":8700285008,"args":{}}, + {"pid":57632,"tid":38659,"ts":874860756233,"ph":"X","cat":"disabled-by-default-devtools.timeline","name":"RunTask","dur":5,"tdur":4,"tts":8700285017,"args":{}}, + ]), + ).toEqual({ + startTime: 8993778496, + duration: 865866977.737, + events: [], + measures: [], + flamechart: [], + otherUserTimingMarks: [], + }); + }); + + it('should error if events and measures are incomplete', () => { + const error = spyOnDevAndProd(console, 'error'); + // prettier-ignore + preprocessData([ + {"args":{"data":{"startTime":8993778496}},"cat":"disabled-by-default-v8.cpu_profiler","id":"0x1","name":"Profile","ph":"P","pid":9312,"tid":10252,"ts":8993778520,"tts":1614266}, + {"args":{"data":{"navigationId":"1065756F5FDAD64BE45CA86B0BBC1F8B"}},"cat":"blink.user_timing","name":"--render-start-8","ph":"R","pid":1852,"tid":12484,"ts":42351664678,"tts":1512475}, + ]); + expect(error).toHaveBeenCalled(); + }); + + it('should error if work is completed without being started', () => { + const error = spyOnDevAndProd(console, 'error'); + // prettier-ignore + preprocessData([ + {"args":{"data":{"startTime":8993778496}},"cat":"disabled-by-default-v8.cpu_profiler","id":"0x1","name":"Profile","ph":"P","pid":9312,"tid":10252,"ts":8993778520,"tts":1614266}, + {"args":{"data":{"navigationId":"E082C30FBDA3ACEE0E7B5FD75F8B7F0D"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":17232,"tid":13628,"ts":264686513020,"tts":4082554}, + ]); + expect(error).toHaveBeenCalled(); + }); + + it('should process complete set of events (page load sample data)', () => { + expect( + // prettier-ignore + preprocessData([ + {"args":{"data":{"documentLoaderURL":"https://concurrent-demo.now.sh/","isLoadingMainFrame":true,"navigationId":"43BC238A4FB7548146D3CD739C9C9434"},"frame":"FD65D9AFD04B1295CEA36B883F0FA82F"},"cat":"blink.user_timing","name":"navigationStart","ph":"R","pid":9312,"tid":10252,"ts":8993749139,"tts":1646191}, + {"args":{"data":{"startTime":8993778496}},"cat":"disabled-by-default-v8.cpu_profiler","id":"0x1","name":"Profile","ph":"P","pid":9312,"tid":10252,"ts":8993778520,"tts":1614266}, + {"args":{"frame":"FD65D9AFD04B1295CEA36B883F0FA82F"},"cat":"blink.user_timing","name":"fetchStart","ph":"R","pid":9312,"tid":10252,"ts":8993751576,"tts":1646197}, + {"args":{},"cat":"blink.user_timing","name":"requestStart","ph":"R","pid":9312,"tid":10252,"ts":8993757325,"tts":1612760}, + {"args":{"frame":"FD65D9AFD04B1295CEA36B883F0FA82F"},"cat":"blink.user_timing","name":"responseEnd","ph":"R","pid":9312,"tid":10252,"ts":8993762841,"tts":1652151}, + {"args":{"frame":"FD65D9AFD04B1295CEA36B883F0FA82F"},"cat":"blink.user_timing","name":"unloadEventStart","ph":"R","pid":9312,"tid":10252,"ts":8993777756,"tts":1646416}, + {"args":{"frame":"FD65D9AFD04B1295CEA36B883F0FA82F"},"cat":"blink.user_timing","name":"unloadEventEnd","ph":"R","pid":9312,"tid":10252,"ts":8993818104,"tts":1646419}, + {"args":{"frame":"FD65D9AFD04B1295CEA36B883F0FA82F"},"cat":"blink.user_timing,rail","name":"domLoading","ph":"R","pid":9312,"tid":10252,"ts":8993820215,"tts":1647488}, + {"args":{},"cat":"blink.user_timing","name":"requestStart","ph":"R","pid":9312,"tid":10252,"ts":8993886145,"tts":1771277}, + {"args":{},"cat":"blink.user_timing","name":"requestStart","ph":"R","pid":9312,"tid":10252,"ts":8993886881,"tts":1778953}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--schedule-render-512-","ph":"R","pid":9312,"tid":10252,"ts":8994056569,"tts":1816966}, + {"args":{"frame":"FD65D9AFD04B1295CEA36B883F0FA82F"},"cat":"blink.user_timing,rail","name":"domInteractive","ph":"R","pid":9312,"tid":10252,"ts":8994058638,"tts":1818851}, + {"args":{"frame":"FD65D9AFD04B1295CEA36B883F0FA82F"},"cat":"blink.user_timing,rail","name":"domContentLoadedEventStart","ph":"R","pid":9312,"tid":10252,"ts":8994058898,"tts":1819078}, + {"args":{"frame":"FD65D9AFD04B1295CEA36B883F0FA82F"},"cat":"blink.user_timing,rail","name":"domContentLoadedEventEnd","ph":"R","pid":9312,"tid":10252,"ts":8994060045,"tts":1820100}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994063789,"tts":1823183}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994069024,"tts":1826507}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994076204,"tts":1830657}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994084372,"tts":1837590}, + {"args":{"frame":"FD65D9AFD04B1295CEA36B883F0FA82F"},"cat":"blink.user_timing,rail","name":"domComplete","ph":"R","pid":9312,"tid":10252,"ts":8994085517,"tts":1838615}, + {"args":{"frame":"FD65D9AFD04B1295CEA36B883F0FA82F"},"cat":"blink.user_timing","name":"loadEventStart","ph":"R","pid":9312,"tid":10252,"ts":8994085552,"tts":1838649}, + {"args":{"frame":"FD65D9AFD04B1295CEA36B883F0FA82F"},"cat":"blink.user_timing","name":"loadEventEnd","ph":"R","pid":9312,"tid":10252,"ts":8994086738,"tts":1839690}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994088953,"tts":1840749}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994093942,"tts":1844455}, + {"args":{},"cat":"blink.user_timing","name":"requestStart","ph":"R","pid":9312,"tid":10252,"ts":8994119825,"tts":1877483}, + {"args":{},"cat":"blink.user_timing","name":"requestStart","ph":"R","pid":9312,"tid":10252,"ts":8994122516,"tts":1886344}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994136263,"tts":1871212}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994142733,"tts":1875838}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994149982,"tts":1880208}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994156615,"tts":1885309}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994163546,"tts":1887453}, + {"args":{},"cat":"blink.user_timing","name":"requestStart","ph":"R","pid":9312,"tid":10252,"ts":8994166081,"tts":1895751}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994173436,"tts":1894442}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994176556,"tts":1897176}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994184415,"tts":1904263}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994185162,"tts":1904938}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994192423,"tts":1910624}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994192714,"tts":1910872}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994200415,"tts":1917859}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994200637,"tts":1918062}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994208430,"tts":1924894}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994208681,"tts":1925124}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994216388,"tts":1932117}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994218048,"tts":1933622}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994225455,"tts":1940076}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994225790,"tts":1940391}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994233439,"tts":1947224}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994233711,"tts":1947473}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994241447,"tts":1954160}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994241755,"tts":1954426}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994249451,"tts":1961213}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994249743,"tts":1961494}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994257444,"tts":1968141}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994257858,"tts":1968525}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994265413,"tts":1975172}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994265691,"tts":1975416}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994273481,"tts":1981826}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994273850,"tts":1982112}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994281467,"tts":1988537}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994282202,"tts":1988894}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994289444,"tts":1994103}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994291602,"tts":1995165}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994299421,"tts":2000342}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994300163,"tts":2001009}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994309433,"tts":2005662}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994309681,"tts":2005897}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994317439,"tts":2012641}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994317709,"tts":2012890}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994325444,"tts":2019235}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994325719,"tts":2019477}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994333449,"tts":2026367}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994333730,"tts":2026617}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994341427,"tts":2033147}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994341728,"tts":2033411}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994349443,"tts":2040163}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994349708,"tts":2040409}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994357469,"tts":2047136}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994357820,"tts":2047465}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994365438,"tts":2054203}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994365697,"tts":2054434}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994373435,"tts":2061259}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994373705,"tts":2061502}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994381444,"tts":2068300}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994381690,"tts":2068529}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994389422,"tts":2075272}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994389708,"tts":2075518}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994397421,"tts":2082156}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994398070,"tts":2082726}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994405440,"tts":2088860}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994405684,"tts":2089091}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994413420,"tts":2095780}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994413689,"tts":2096021}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994421418,"tts":2102791}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994421702,"tts":2103047}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994429611,"tts":2110142}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994429835,"tts":2110349}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-suspend-0-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8994436841,"tts":2115594}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994437451,"tts":2116087}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994437941,"tts":2116287}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-suspend-1-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8994439235,"tts":2117153}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-suspend-2-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8994441050,"tts":2118088}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-suspend-3-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8994441951,"tts":2118783}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-suspend-4-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8994442698,"tts":2119371}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994443276,"tts":2119875}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994443448,"tts":2120040}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-suspend-0-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8994445176,"tts":2121499}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-suspend-1-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8994445697,"tts":2121968}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-suspend-2-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8994446236,"tts":2122460}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-suspend-3-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8994446778,"tts":2122951}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-suspend-4-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8994447344,"tts":2123444}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-yield","ph":"R","pid":9312,"tid":10252,"ts":8994449037,"tts":2124925}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994449280,"tts":2125142}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-suspend-0-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at SuspenseList\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8994449831,"tts":2125639}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-stop","ph":"R","pid":9312,"tid":10252,"ts":8994450864,"tts":2126555}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--commit-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994451820,"tts":2127417}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--layout-effects-start-512","ph":"R","pid":9312,"tid":10252,"ts":8994455732,"tts":2130777}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--schedule-state-update-1-ForceUpdateDemo_ForceUpdateDemo-\n at ForceUpdateDemo_ForceUpdateDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:18:98)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8994457934,"tts":2132671}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--layout-effects-stop","ph":"R","pid":9312,"tid":10252,"ts":8994458421,"tts":2133089}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-1","ph":"R","pid":9312,"tid":10252,"ts":8994462600,"tts":2136847}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-stop","ph":"R","pid":9312,"tid":10252,"ts":8994464817,"tts":2138817}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--commit-start-1","ph":"R","pid":9312,"tid":10252,"ts":8994464844,"tts":2138843}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--layout-effects-start-1","ph":"R","pid":9312,"tid":10252,"ts":8994465763,"tts":2139664}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--layout-effects-stop","ph":"R","pid":9312,"tid":10252,"ts":8994465784,"tts":2139686}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--commit-stop","ph":"R","pid":9312,"tid":10252,"ts":8994466156,"tts":2140023}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--commit-stop","ph":"R","pid":9312,"tid":10252,"ts":8994466372,"tts":2140208}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-resolved-1-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8995573418,"tts":2205582}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-resolved-1-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8995573870,"tts":2205980}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-1024","ph":"R","pid":9312,"tid":10252,"ts":8995574538,"tts":2206568}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-suspend-0-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8995575662,"tts":2207534}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-suspend-2-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8995576307,"tts":2208142}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-suspend-3-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8995576659,"tts":2208445}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-suspend-4-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8995577001,"tts":2208736}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-stop","ph":"R","pid":9312,"tid":10252,"ts":8995577971,"tts":2209602}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--commit-start-1024","ph":"R","pid":9312,"tid":10252,"ts":8995578068,"tts":2209689}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--layout-effects-start-1024","ph":"R","pid":9312,"tid":10252,"ts":8995580101,"tts":2211495}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--layout-effects-stop","ph":"R","pid":9312,"tid":10252,"ts":8995580122,"tts":2211515}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--commit-stop","ph":"R","pid":9312,"tid":10252,"ts":8995580657,"tts":2211995}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-resolved-2-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8995644745,"tts":2217336}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-resolved-2-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8995645038,"tts":2217571}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-resolved-2-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8995645354,"tts":2217861}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-2048","ph":"R","pid":9312,"tid":10252,"ts":8995645494,"tts":2217999}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-suspend-0-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8995646312,"tts":2218721}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-suspend-3-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8995647134,"tts":2219450}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-suspend-4-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8995647462,"tts":2219740}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-stop","ph":"R","pid":9312,"tid":10252,"ts":8995648162,"tts":2220335}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--commit-start-2048","ph":"R","pid":9312,"tid":10252,"ts":8995648191,"tts":2220363}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--layout-effects-start-2048","ph":"R","pid":9312,"tid":10252,"ts":8995649260,"tts":2221320}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--layout-effects-stop","ph":"R","pid":9312,"tid":10252,"ts":8995649280,"tts":2221340}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--commit-stop","ph":"R","pid":9312,"tid":10252,"ts":8995649611,"tts":2221636}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-resolved-3-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8995758769,"tts":2228839}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-resolved-3-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8995759018,"tts":2229064}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-resolved-3-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8995759442,"tts":2229424}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-resolved-3-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8995759660,"tts":2229627}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-4096","ph":"R","pid":9312,"tid":10252,"ts":8995759857,"tts":2229803}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-suspend-0-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8995760575,"tts":2230456}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-suspend-4-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8995761666,"tts":2231399}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-stop","ph":"R","pid":9312,"tid":10252,"ts":8995762296,"tts":2231965}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--commit-start-4096","ph":"R","pid":9312,"tid":10252,"ts":8995762367,"tts":2232017}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--layout-effects-start-4096","ph":"R","pid":9312,"tid":10252,"ts":8995763427,"tts":2232966}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--layout-effects-stop","ph":"R","pid":9312,"tid":10252,"ts":8995763454,"tts":2232993}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--commit-stop","ph":"R","pid":9312,"tid":10252,"ts":8995763625,"tts":2233154}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-resolved-4-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8996158083,"tts":2252466}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-resolved-4-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8996158391,"tts":2252730}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-resolved-4-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8996158738,"tts":2253038}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-resolved-4-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8996158983,"tts":2253239}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-resolved-4-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8996159231,"tts":2253447}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-8192","ph":"R","pid":9312,"tid":10252,"ts":8996159465,"tts":2253624}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-suspend-0-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8996160268,"tts":2254312}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-stop","ph":"R","pid":9312,"tid":10252,"ts":8996161775,"tts":2255670}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--commit-start-8192","ph":"R","pid":9312,"tid":10252,"ts":8996161834,"tts":2255716}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--layout-effects-start-8192","ph":"R","pid":9312,"tid":10252,"ts":8996163031,"tts":2256754}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--layout-effects-stop","ph":"R","pid":9312,"tid":10252,"ts":8996163051,"tts":2256773}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--commit-stop","ph":"R","pid":9312,"tid":10252,"ts":8996163355,"tts":2257045}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-resolved-0-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8996357682,"tts":2267920}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-resolved-0-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8996357983,"tts":2268179}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-resolved-0-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at SuspenseList\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8996358231,"tts":2268389}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-resolved-0-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8996358538,"tts":2268679}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-resolved-0-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8996358786,"tts":2268867}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-resolved-0-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8996359042,"tts":2269066}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--suspense-resolved-0-ResourceButton-\n at ResourceButton (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:295)\n at Suspense\n at div\n at div\n at SuspenseDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:36:713)\n at App","ph":"R","pid":9312,"tid":10252,"ts":8996359296,"tts":2269264}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-start-16384","ph":"R","pid":9312,"tid":10252,"ts":8996359448,"tts":2269412}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--render-stop","ph":"R","pid":9312,"tid":10252,"ts":8996362873,"tts":2272363}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--commit-start-16384","ph":"R","pid":9312,"tid":10252,"ts":8996362944,"tts":2272420}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--layout-effects-start-16384","ph":"R","pid":9312,"tid":10252,"ts":8996364618,"tts":2273869}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--layout-effects-stop","ph":"R","pid":9312,"tid":10252,"ts":8996364645,"tts":2273895}, + {"args":{"data":{"navigationId":"43BC238A4FB7548146D3CD739C9C9434"}},"cat":"blink.user_timing","name":"--commit-stop","ph":"R","pid":9312,"tid":10252,"ts":8996365391,"tts":2274547}, + ]), + ).toMatchSnapshot(); + }); + + it('should process forced update event', () => { + expect( + // prettier-ignore + preprocessData([ + {"args":{"data":{"startTime":40806924876}},"cat":"disabled-by-default-v8.cpu_profiler","id":"0x2","name":"Profile","ph":"P","pid":1852,"tid":12484,"ts":40806924880,"tts":996658}, + {"args":{"data":{"navigationId":"1065756F5FDAD64BE45CA86B0BBC1F8B"}},"cat":"blink.user_timing","name":"--schedule-forced-update-16-ForceUpdateDemo_ForceUpdateDemo-\n at ForceUpdateDemo_ForceUpdateDemo (https://concurrent-demo.now.sh/static/js/main.c9f122eb.chunk.js:18:98)\n at App","ph":"R","pid":1852,"tid":12484,"ts":40806988231,"tts":1037762}, + {"args":{"data":{"navigationId":"1065756F5FDAD64BE45CA86B0BBC1F8B"}},"cat":"blink.user_timing","name":"--render-start-16","ph":"R","pid":1852,"tid":12484,"ts":40806990146,"tts":1038890}, + {"args":{"data":{"navigationId":"1065756F5FDAD64BE45CA86B0BBC1F8B"}},"cat":"blink.user_timing","name":"--render-stop","ph":"R","pid":1852,"tid":12484,"ts":40806991123,"tts":1039401}, + {"args":{"data":{"navigationId":"1065756F5FDAD64BE45CA86B0BBC1F8B"}},"cat":"blink.user_timing","name":"--commit-start-16","ph":"R","pid":1852,"tid":12484,"ts":40806991170,"tts":1039447}, + {"args":{"data":{"navigationId":"1065756F5FDAD64BE45CA86B0BBC1F8B"}},"cat":"blink.user_timing","name":"--layout-effects-start-16","ph":"R","pid":1852,"tid":12484,"ts":40806992201,"tts":1040023}, + {"args":{"data":{"navigationId":"1065756F5FDAD64BE45CA86B0BBC1F8B"}},"cat":"blink.user_timing","name":"--layout-effects-stop","ph":"R","pid":1852,"tid":12484,"ts":40806992219,"tts":1040041}, + {"args":{"data":{"navigationId":"1065756F5FDAD64BE45CA86B0BBC1F8B"}},"cat":"blink.user_timing","name":"--commit-stop","ph":"R","pid":1852,"tid":12484,"ts":40806992337,"tts":1040149}, + ]), + ).toMatchSnapshot(); + }); + + it('should populate other user timing marks', () => { + expect( + // prettier-ignore + preprocessData([ + {"args":{},"cat":"blink.user_timing","id":"0xcdf75f7c","name":"VCWithoutImage: root","ph":"n","pid":55132,"scope":"blink.user_timing","tid":775,"ts":458734963394}, + {"args":{"data":{"startTime":458738069897}},"cat":"disabled-by-default-v8.cpu_profiler","id":"0x4","name":"Profile","ph":"P","pid":55132,"tid":775,"ts":458738069898,"tts":27896428}, + {"args":{"data":{"navigationId":"B8774C733A75946C099FE21F8A0E8D38"}},"cat":"blink.user_timing","name":"--a-mark-that-looks-like-one-of-ours","ph":"R","pid":55132,"tid":775,"ts":458738256356,"tts":28082555}, + {"args":{"data":{"navigationId":"B8774C733A75946C099FE21F8A0E8D38"}},"cat":"blink.user_timing","name":"Some other mark","ph":"R","pid":55132,"tid":775,"ts":458738261491,"tts":28087691}, + ]).otherUserTimingMarks, + ).toMatchInlineSnapshot(` + Array [ + Object { + "name": "VCWithoutImage: root", + "timestamp": -3106.503, + }, + Object { + "name": "--a-mark-that-looks-like-one-of-ours", + "timestamp": 186.459, + }, + Object { + "name": "Some other mark", + "timestamp": 191.594, + }, + ] + `); + }); + + // TODO: Add test for flamechart parsing +}); diff --git a/packages/react-devtools-scheduling-profiler/src/utils/getBatchRange.js b/packages/react-devtools-scheduling-profiler/src/utils/getBatchRange.js new file mode 100644 index 0000000000..cba61a4716 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/utils/getBatchRange.js @@ -0,0 +1,44 @@ +/** + * 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 memoize from 'memoize-one'; + +import type {BatchUID, Milliseconds, ReactProfilerData} from '../types'; + +function unmemoizedGetBatchRange( + batchUID: BatchUID, + data: ReactProfilerData, +): [Milliseconds, Milliseconds] { + const {measures} = data; + + let startTime = 0; + let stopTime = Infinity; + + let i = 0; + + for (i; i < measures.length; i++) { + const measure = measures[i]; + if (measure.batchUID === batchUID) { + startTime = measure.timestamp; + break; + } + } + + for (i; i < measures.length; i++) { + const measure = measures[i]; + stopTime = measure.timestamp; + if (measure.batchUID !== batchUID) { + break; + } + } + + return [startTime, stopTime]; +} + +export const getBatchRange = memoize(unmemoizedGetBatchRange); diff --git a/packages/react-devtools-scheduling-profiler/src/utils/preprocessData.js b/packages/react-devtools-scheduling-profiler/src/utils/preprocessData.js new file mode 100644 index 0000000000..402ff9ea15 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/utils/preprocessData.js @@ -0,0 +1,461 @@ +/** + * 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 { + importFromChromeTimeline, + Flamechart as SpeedscopeFlamechart, +} from '@elg/speedscope'; +import type {TimelineEvent} from '@elg/speedscope'; +import type { + Milliseconds, + BatchUID, + Flamechart, + ReactLane, + ReactMeasureType, + ReactProfilerData, +} from '../types'; + +import {REACT_TOTAL_NUM_LANES} from '../constants'; + +type MeasureStackElement = {| + type: ReactMeasureType, + depth: number, + index: number, + startTime: Milliseconds, + stopTime?: Milliseconds, +|}; + +type ProcessorState = {| + nextRenderShouldGenerateNewBatchID: boolean, + batchUID: BatchUID, + uidCounter: BatchUID, + measureStack: MeasureStackElement[], +|}; + +// Exported for tests +export function getLanesFromTransportDecimalBitmask( + laneBitmaskString: string, +): ReactLane[] { + const laneBitmask = parseInt(laneBitmaskString, 10); + + // As negative numbers are stored in two's complement format, our bitmask + // checks will be thrown off by them. + if (laneBitmask < 0) { + return []; + } + + const lanes = []; + let powersOfTwo = 0; + while (powersOfTwo <= REACT_TOTAL_NUM_LANES) { + if ((1 << powersOfTwo) & laneBitmask) { + lanes.push(powersOfTwo); + } + powersOfTwo++; + } + return lanes; +} + +function getLastType(stack: $PropertyType) { + if (stack.length > 0) { + const {type} = stack[stack.length - 1]; + return type; + } + return null; +} + +function getDepth(stack: $PropertyType) { + if (stack.length > 0) { + const {depth, type} = stack[stack.length - 1]; + return type === 'render-idle' ? depth : depth + 1; + } + return 0; +} + +function markWorkStarted( + type: ReactMeasureType, + startTime: Milliseconds, + lanes: ReactLane[], + currentProfilerData: ReactProfilerData, + state: ProcessorState, +) { + const {batchUID, measureStack} = state; + const index = currentProfilerData.measures.length; + const depth = getDepth(measureStack); + + state.measureStack.push({depth, index, startTime, type}); + + currentProfilerData.measures.push({ + type, + batchUID, + depth, + lanes, + timestamp: startTime, + duration: 0, + }); +} + +function markWorkCompleted( + type: ReactMeasureType, + stopTime: Milliseconds, + currentProfilerData: ReactProfilerData, + stack: $PropertyType, +) { + if (stack.length === 0) { + console.error( + 'Unexpected type "%s" completed at %sms while stack is empty.', + type, + stopTime, + ); + // Ignore work "completion" user timing mark that doesn't complete anything + return; + } + + const last = stack[stack.length - 1]; + if (last.type !== type) { + console.error( + 'Unexpected type "%s" completed at %sms before "%s" completed.', + type, + stopTime, + last.type, + ); + } + + const {index, startTime} = stack.pop(); + const measure = currentProfilerData.measures[index]; + if (!measure) { + console.error('Could not find matching measure for type "%s".', type); + } + + // $FlowFixMe This property should not be writable outside of this function. + measure.duration = stopTime - startTime; +} + +function throwIfIncomplete( + type: ReactMeasureType, + stack: $PropertyType, +) { + const lastIndex = stack.length - 1; + if (lastIndex >= 0) { + const last = stack[lastIndex]; + if (last.stopTime === undefined && last.type === type) { + throw new Error( + `Unexpected type "${type}" started before "${last.type}" completed.`, + ); + } + } +} + +function processTimelineEvent( + event: TimelineEvent, + /** Finalized profiler data up to `event`. May be mutated. */ + currentProfilerData: ReactProfilerData, + /** Intermediate processor state. May be mutated. */ + state: ProcessorState, +) { + const {cat, name, ts, ph} = event; + if (cat !== 'blink.user_timing') { + return; + } + + const startTime = (ts - currentProfilerData.startTime) / 1000; + + // React Events - schedule + if (name.startsWith('--schedule-render-')) { + const [laneBitmaskString, ...splitComponentStack] = name + .substr(18) + .split('-'); + currentProfilerData.events.push({ + type: 'schedule-render', + lanes: getLanesFromTransportDecimalBitmask(laneBitmaskString), + componentStack: splitComponentStack.join('-'), + timestamp: startTime, + }); + } else if (name.startsWith('--schedule-forced-update-')) { + const [ + laneBitmaskString, + componentName, + ...splitComponentStack + ] = name.substr(25).split('-'); + const isCascading = !!state.measureStack.find( + ({type}) => type === 'commit', + ); + currentProfilerData.events.push({ + type: 'schedule-force-update', + lanes: getLanesFromTransportDecimalBitmask(laneBitmaskString), + componentName, + componentStack: splitComponentStack.join('-'), + timestamp: startTime, + isCascading, + }); + } else if (name.startsWith('--schedule-state-update-')) { + const [ + laneBitmaskString, + componentName, + ...splitComponentStack + ] = name.substr(24).split('-'); + const isCascading = !!state.measureStack.find( + ({type}) => type === 'commit', + ); + currentProfilerData.events.push({ + type: 'schedule-state-update', + lanes: getLanesFromTransportDecimalBitmask(laneBitmaskString), + componentName, + componentStack: splitComponentStack.join('-'), + timestamp: startTime, + isCascading, + }); + } // eslint-disable-line brace-style + + // React Events - suspense + else if (name.startsWith('--suspense-suspend-')) { + const [id, componentName, ...splitComponentStack] = name + .substr(19) + .split('-'); + currentProfilerData.events.push({ + type: 'suspense-suspend', + id, + componentName, + componentStack: splitComponentStack.join('-'), + timestamp: startTime, + }); + } else if (name.startsWith('--suspense-resolved-')) { + const [id, componentName, ...splitComponentStack] = name + .substr(20) + .split('-'); + currentProfilerData.events.push({ + type: 'suspense-resolved', + id, + componentName, + componentStack: splitComponentStack.join('-'), + timestamp: startTime, + }); + } else if (name.startsWith('--suspense-rejected-')) { + const [id, componentName, ...splitComponentStack] = name + .substr(20) + .split('-'); + currentProfilerData.events.push({ + type: 'suspense-rejected', + id, + componentName, + componentStack: splitComponentStack.join('-'), + timestamp: startTime, + }); + } // eslint-disable-line brace-style + + // React Measures - render + else if (name.startsWith('--render-start-')) { + if (state.nextRenderShouldGenerateNewBatchID) { + state.nextRenderShouldGenerateNewBatchID = false; + state.batchUID = ((state.uidCounter++: any): BatchUID); + } + const laneBitmaskString = name.substr(15); + const lanes = getLanesFromTransportDecimalBitmask(laneBitmaskString); + throwIfIncomplete('render', state.measureStack); + if (getLastType(state.measureStack) !== 'render-idle') { + markWorkStarted( + 'render-idle', + startTime, + lanes, + currentProfilerData, + state, + ); + } + markWorkStarted('render', startTime, lanes, currentProfilerData, state); + } else if ( + name.startsWith('--render-stop') || + name.startsWith('--render-yield') + ) { + markWorkCompleted( + 'render', + startTime, + currentProfilerData, + state.measureStack, + ); + } else if (name.startsWith('--render-cancel')) { + state.nextRenderShouldGenerateNewBatchID = true; + markWorkCompleted( + 'render', + startTime, + currentProfilerData, + state.measureStack, + ); + markWorkCompleted( + 'render-idle', + startTime, + currentProfilerData, + state.measureStack, + ); + } // eslint-disable-line brace-style + + // React Measures - commits + else if (name.startsWith('--commit-start-')) { + state.nextRenderShouldGenerateNewBatchID = true; + const laneBitmaskString = name.substr(15); + const lanes = getLanesFromTransportDecimalBitmask(laneBitmaskString); + markWorkStarted('commit', startTime, lanes, currentProfilerData, state); + } else if (name.startsWith('--commit-stop')) { + markWorkCompleted( + 'commit', + startTime, + currentProfilerData, + state.measureStack, + ); + markWorkCompleted( + 'render-idle', + startTime, + currentProfilerData, + state.measureStack, + ); + } // eslint-disable-line brace-style + + // React Measures - layout effects + else if (name.startsWith('--layout-effects-start-')) { + const laneBitmaskString = name.substr(23); + const lanes = getLanesFromTransportDecimalBitmask(laneBitmaskString); + markWorkStarted( + 'layout-effects', + startTime, + lanes, + currentProfilerData, + state, + ); + } else if (name.startsWith('--layout-effects-stop')) { + markWorkCompleted( + 'layout-effects', + startTime, + currentProfilerData, + state.measureStack, + ); + } // eslint-disable-line brace-style + + // React Measures - passive effects + else if (name.startsWith('--passive-effects-start-')) { + const laneBitmaskString = name.substr(24); + const lanes = getLanesFromTransportDecimalBitmask(laneBitmaskString); + markWorkStarted( + 'passive-effects', + startTime, + lanes, + currentProfilerData, + state, + ); + } else if (name.startsWith('--passive-effects-stop')) { + markWorkCompleted( + 'passive-effects', + startTime, + currentProfilerData, + state.measureStack, + ); + } // eslint-disable-line brace-style + + // Other user timing marks/measures + else if (ph === 'R' || ph === 'n') { + // User Timing mark + currentProfilerData.otherUserTimingMarks.push({ + name, + timestamp: startTime, + }); + } else if (ph === 'b') { + // TODO: Begin user timing measure + } else if (ph === 'e') { + // TODO: End user timing measure + } // eslint-disable-line brace-style + + // Unrecognized event + else { + throw new Error( + `Unrecognized event ${JSON.stringify( + event, + )}! This is likely a bug in this profiler tool.`, + ); + } +} + +function preprocessFlamechart(rawData: TimelineEvent[]): Flamechart { + const parsedData = importFromChromeTimeline(rawData, 'react-devtools'); + const profile = parsedData.profiles[0]; // TODO: Choose the main CPU thread only + + const speedscopeFlamechart = new SpeedscopeFlamechart({ + getTotalWeight: profile.getTotalWeight.bind(profile), + forEachCall: profile.forEachCall.bind(profile), + formatValue: profile.formatValue.bind(profile), + getColorBucketForFrame: () => 0, + }); + + const flamechart: Flamechart = speedscopeFlamechart.getLayers().map(layer => + layer.map(({start, end, node: {frame: {name, file, line, col}}}) => ({ + name, + timestamp: start / 1000, + duration: (end - start) / 1000, + scriptUrl: file, + locationLine: line, + locationColumn: col, + })), + ); + + return flamechart; +} + +export default function preprocessData( + timeline: TimelineEvent[], +): ReactProfilerData { + const flamechart = preprocessFlamechart(timeline); + + const profilerData: ReactProfilerData = { + startTime: 0, + duration: 0, + events: [], + measures: [], + flamechart, + otherUserTimingMarks: [], + }; + + // Sort `timeline`. JSON Array Format trace events need not be ordered. See: + // https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/preview#heading=h.f2f0yd51wi15 + timeline = timeline.filter(Boolean).sort((a, b) => (a.ts > b.ts ? 1 : -1)); + + // Events displayed in flamechart have timestamps relative to the profile + // event's startTime. Source: https://github.com/v8/v8/blob/44bd8fd7/src/inspector/js_protocol.json#L1486 + // + // We'll thus expect there to be a 'Profile' event; if there is not one, we + // can deduce that there are no flame chart events. As we expect React + // scheduling profiling user timing marks to be recorded together with browser + // flame chart events, we can futher deduce that the data is invalid and we + // don't bother finding React events. + const indexOfProfileEvent = timeline.findIndex( + event => event.name === 'Profile', + ); + if (indexOfProfileEvent === -1) { + return profilerData; + } + + // Use Profile event's `startTime` as the start time to align with flame chart. + // TODO: Remove assumption that there'll only be 1 'Profile' event. If this + // assumption does not hold, the chart may start at the wrong time. + profilerData.startTime = timeline[indexOfProfileEvent].args.data.startTime; + profilerData.duration = + (timeline[timeline.length - 1].ts - profilerData.startTime) / 1000; + + const state: ProcessorState = { + batchUID: 0, + uidCounter: 0, + nextRenderShouldGenerateNewBatchID: true, + measureStack: [], + }; + + timeline.forEach(event => processTimelineEvent(event, profilerData, state)); + + // Validate that all events and measures are complete + const {measureStack} = state; + if (measureStack.length > 0) { + console.error('Incomplete events or measures', measureStack); + } + + return profilerData; +} diff --git a/packages/react-devtools-scheduling-profiler/src/utils/readInputData.js b/packages/react-devtools-scheduling-profiler/src/utils/readInputData.js new file mode 100644 index 0000000000..fe46337f0f --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/utils/readInputData.js @@ -0,0 +1,36 @@ +/** + * 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 nullthrows from 'nullthrows'; + +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', + ), + ); + } + + const fileReader = new FileReader(); + + return new Promise((resolve, reject) => { + fileReader.onload = () => { + const result = nullthrows(fileReader.result); + if (typeof result === 'string') { + resolve(result); + } + reject(new Error('Input file was not read as a string')); + }; + + fileReader.onerror = () => reject(fileReader.error); + + fileReader.readAsText(file); + }); +}; diff --git a/packages/react-devtools-scheduling-profiler/src/utils/useSmartTooltip.js b/packages/react-devtools-scheduling-profiler/src/utils/useSmartTooltip.js new file mode 100644 index 0000000000..8acfd882cb --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/utils/useSmartTooltip.js @@ -0,0 +1,70 @@ +/** + * 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 {useLayoutEffect, useRef} from 'react'; + +const TOOLTIP_OFFSET = 4; + +export default function useSmartTooltip({ + mouseX, + mouseY, +}: { + mouseX: number, + mouseY: number, +}) { + const ref = useRef(null); + + useLayoutEffect(() => { + const element = ref.current; + if (element !== null) { + // Let's check the vertical position. + if ( + mouseY + TOOLTIP_OFFSET + element.offsetHeight >= + window.innerHeight + ) { + // The tooltip doesn't fit below the mouse cursor (which is our + // default strategy). Therefore we try to position it either above the + // mouse cursor or finally aligned with the window's top edge. + if (mouseY - TOOLTIP_OFFSET - element.offsetHeight > 0) { + // We position the tooltip above the mouse cursor if it fits there. + element.style.top = `${mouseY - + element.offsetHeight - + TOOLTIP_OFFSET}px`; + } else { + // Otherwise we align the tooltip with the window's top edge. + element.style.top = '0px'; + } + } else { + element.style.top = `${mouseY + TOOLTIP_OFFSET}px`; + } + + // Now let's check the horizontal position. + if (mouseX + TOOLTIP_OFFSET + element.offsetWidth >= window.innerWidth) { + // The tooltip doesn't fit at the right of the mouse cursor (which is + // our default strategy). Therefore we try to position it either at the + // left of the mouse cursor or finally aligned with the window's left + // edge. + if (mouseX - TOOLTIP_OFFSET - element.offsetWidth > 0) { + // We position the tooltip at the left of the mouse cursor if it fits + // there. + element.style.left = `${mouseX - + element.offsetWidth - + TOOLTIP_OFFSET}px`; + } else { + // Otherwise, align the tooltip with the window's left edge. + element.style.left = '0px'; + } + } else { + element.style.left = `${mouseX + TOOLTIP_OFFSET}px`; + } + } + }, [mouseX, mouseY, ref]); + + return ref; +} diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/ColorView.js b/packages/react-devtools-scheduling-profiler/src/view-base/ColorView.js new file mode 100644 index 0000000000..551814eab6 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/view-base/ColorView.js @@ -0,0 +1,44 @@ +/** + * 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 type {Rect} from './geometry'; + +import {Surface} from './Surface'; +import {View} from './View'; + +/** + * View that fills its visible area with a CSS color. + */ +export class ColorView extends View { + _color: string; + + constructor(surface: Surface, frame: Rect, color: string) { + super(surface, frame); + this._color = color; + } + + setColor(color: string) { + if (this._color === color) { + return; + } + this._color = color; + this.setNeedsDisplay(); + } + + draw(context: CanvasRenderingContext2D) { + const {_color, visibleArea} = this; + context.fillStyle = _color; + context.fillRect( + visibleArea.origin.x, + visibleArea.origin.y, + visibleArea.size.width, + visibleArea.size.height, + ); + } +} diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/HorizontalPanAndZoomView.js b/packages/react-devtools-scheduling-profiler/src/view-base/HorizontalPanAndZoomView.js new file mode 100644 index 0000000000..3bed528ef6 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/view-base/HorizontalPanAndZoomView.js @@ -0,0 +1,342 @@ +/** + * 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 type { + Interaction, + MouseDownInteraction, + MouseMoveInteraction, + MouseUpInteraction, + WheelPlainInteraction, + WheelWithShiftInteraction, + WheelWithControlInteraction, + WheelWithMetaInteraction, +} from './useCanvasInteraction'; +import type {Rect} from './geometry'; + +import {Surface} from './Surface'; +import {View} from './View'; +import {rectContainsPoint} from './geometry'; +import {clamp} from './utils/clamp'; +import { + MIN_ZOOM_LEVEL, + MAX_ZOOM_LEVEL, + MOVE_WHEEL_DELTA_THRESHOLD, +} from './constants'; + +type HorizontalPanAndZoomState = $ReadOnly<{| + /** Horizontal offset; positive in the left direction */ + offsetX: number, + zoomLevel: number, +|}>; + +export type HorizontalPanAndZoomViewOnChangeCallback = ( + state: HorizontalPanAndZoomState, + view: HorizontalPanAndZoomView, +) => void; + +function panAndZoomStatesAreEqual( + state1: HorizontalPanAndZoomState, + state2: HorizontalPanAndZoomState, +): boolean { + return ( + state1.offsetX === state2.offsetX && state1.zoomLevel === state2.zoomLevel + ); +} + +function zoomLevelAndIntrinsicWidthToFrameWidth( + zoomLevel: number, + intrinsicWidth: number, +): number { + return intrinsicWidth * zoomLevel; +} + +export class HorizontalPanAndZoomView extends View { + _intrinsicContentWidth: number; + + _panAndZoomState: HorizontalPanAndZoomState = { + offsetX: 0, + zoomLevel: 0.25, + }; + + _isPanning = false; + + _onStateChange: HorizontalPanAndZoomViewOnChangeCallback = () => {}; + + constructor( + surface: Surface, + frame: Rect, + contentView: View, + intrinsicContentWidth: number, + onStateChange?: HorizontalPanAndZoomViewOnChangeCallback, + ) { + super(surface, frame); + this.addSubview(contentView); + this._intrinsicContentWidth = intrinsicContentWidth; + if (onStateChange) this._onStateChange = onStateChange; + } + + setFrame(newFrame: Rect) { + super.setFrame(newFrame); + + // Revalidate panAndZoomState + this._setStateAndInformCallbacksIfChanged(this._panAndZoomState); + } + + setPanAndZoomState(proposedState: HorizontalPanAndZoomState) { + this._setPanAndZoomState(proposedState); + } + + /** + * Just sets pan and zoom state. Use `_setStateAndInformCallbacksIfChanged` + * if this view's callbacks should also be called. + * + * @returns Whether state was changed + * @private + */ + _setPanAndZoomState(proposedState: HorizontalPanAndZoomState): boolean { + const clampedState = this._clampedProposedState(proposedState); + if (panAndZoomStatesAreEqual(clampedState, this._panAndZoomState)) { + return false; + } + this._panAndZoomState = clampedState; + this.setNeedsDisplay(); + return true; + } + + /** + * @private + */ + _setStateAndInformCallbacksIfChanged( + proposedState: HorizontalPanAndZoomState, + ) { + if (this._setPanAndZoomState(proposedState)) { + this._onStateChange(this._panAndZoomState, this); + } + } + + desiredSize() { + return this._contentView.desiredSize(); + } + + /** + * Reference to the content view. This view is also the only view in + * `this.subviews`. + */ + get _contentView() { + return this.subviews[0]; + } + + layoutSubviews() { + const {offsetX, zoomLevel} = this._panAndZoomState; + const proposedFrame = { + origin: { + x: this.frame.origin.x + offsetX, + y: this.frame.origin.y, + }, + size: { + width: zoomLevelAndIntrinsicWidthToFrameWidth( + zoomLevel, + this._intrinsicContentWidth, + ), + height: this.frame.size.height, + }, + }; + this._contentView.setFrame(proposedFrame); + super.layoutSubviews(); + } + + /** + * Zoom to a specific range of the content specified as a range of the + * content view's intrinsic content size. + * + * Does not inform callbacks of state change since this is a public API. + */ + zoomToRange(startX: number, endX: number) { + // Zoom and offset must be done separately, so that if the zoom level is + // clamped the offset will still be correct (unless it gets clamped too). + const zoomClampedState = this._clampedProposedStateZoomLevel({ + ...this._panAndZoomState, + // Let: + // I = intrinsic content width, i = zoom range = (endX - startX). + // W = contentView's final zoomed width, w = this view's width + // Goal: we want the visible width w to only contain the requested range i. + // Derivation: + // (1) i/I = w/W (by intuitive definition of variables) + // (2) W = zoomLevel * I (definition of zoomLevel) + // => zoomLevel = W/I (algebraic manipulation) + // = w/i (rearranging (1)) + zoomLevel: this.frame.size.width / (endX - startX), + }); + const offsetAdjustedState = this._clampedProposedStateOffsetX({ + ...zoomClampedState, + offsetX: -startX * zoomClampedState.zoomLevel, + }); + this._setPanAndZoomState(offsetAdjustedState); + } + + _handleMouseDown(interaction: MouseDownInteraction) { + if (rectContainsPoint(interaction.payload.location, this.frame)) { + this._isPanning = true; + } + } + + _handleMouseMove(interaction: MouseMoveInteraction) { + if (!this._isPanning) { + return; + } + const {offsetX} = this._panAndZoomState; + const {movementX} = interaction.payload.event; + this._setStateAndInformCallbacksIfChanged({ + ...this._panAndZoomState, + offsetX: offsetX + movementX, + }); + } + + _handleMouseUp(interaction: MouseUpInteraction) { + if (this._isPanning) { + this._isPanning = false; + } + } + + _handleWheelPlain(interaction: WheelPlainInteraction) { + const { + location, + delta: {deltaX, deltaY}, + } = interaction.payload; + if (!rectContainsPoint(location, this.frame)) { + return; // Not scrolling on view + } + + const absDeltaX = Math.abs(deltaX); + const absDeltaY = Math.abs(deltaY); + if (absDeltaY > absDeltaX) { + return; // Scrolling vertically + } + + if (absDeltaX < MOVE_WHEEL_DELTA_THRESHOLD) { + return; + } + + this._setStateAndInformCallbacksIfChanged({ + ...this._panAndZoomState, + offsetX: this._panAndZoomState.offsetX - deltaX, + }); + } + + _handleWheelZoom( + interaction: + | WheelWithShiftInteraction + | WheelWithControlInteraction + | WheelWithMetaInteraction, + ) { + const { + location, + delta: {deltaY}, + } = interaction.payload; + if (!rectContainsPoint(location, this.frame)) { + return; // Not scrolling on view + } + + const absDeltaY = Math.abs(deltaY); + if (absDeltaY < MOVE_WHEEL_DELTA_THRESHOLD) { + return; + } + + const zoomClampedState = this._clampedProposedStateZoomLevel({ + ...this._panAndZoomState, + zoomLevel: this._panAndZoomState.zoomLevel * (1 + 0.005 * -deltaY), + }); + + // Determine where the mouse is, and adjust the offset so that point stays + // centered after zooming. + const oldMouseXInFrame = location.x - zoomClampedState.offsetX; + const fractionalMouseX = + oldMouseXInFrame / this._contentView.frame.size.width; + const newContentWidth = zoomLevelAndIntrinsicWidthToFrameWidth( + zoomClampedState.zoomLevel, + this._intrinsicContentWidth, + ); + const newMouseXInFrame = fractionalMouseX * newContentWidth; + + const offsetAdjustedState = this._clampedProposedStateOffsetX({ + ...zoomClampedState, + offsetX: location.x - newMouseXInFrame, + }); + + this._setStateAndInformCallbacksIfChanged(offsetAdjustedState); + } + + handleInteraction(interaction: Interaction) { + switch (interaction.type) { + case 'mousedown': + this._handleMouseDown(interaction); + break; + case 'mousemove': + this._handleMouseMove(interaction); + break; + case 'mouseup': + this._handleMouseUp(interaction); + break; + case 'wheel-plain': + this._handleWheelPlain(interaction); + break; + case 'wheel-shift': + case 'wheel-control': + case 'wheel-meta': + this._handleWheelZoom(interaction); + break; + } + } + + /** + * @private + */ + _clampedProposedStateZoomLevel( + proposedState: HorizontalPanAndZoomState, + ): HorizontalPanAndZoomState { + // Content-based min zoom level to ensure that contentView's width >= our width. + const minContentBasedZoomLevel = + this.frame.size.width / this._intrinsicContentWidth; + const minZoomLevel = Math.max(MIN_ZOOM_LEVEL, minContentBasedZoomLevel); + return { + ...proposedState, + zoomLevel: clamp(minZoomLevel, MAX_ZOOM_LEVEL, proposedState.zoomLevel), + }; + } + + /** + * @private + */ + _clampedProposedStateOffsetX( + proposedState: HorizontalPanAndZoomState, + ): HorizontalPanAndZoomState { + const newContentWidth = zoomLevelAndIntrinsicWidthToFrameWidth( + proposedState.zoomLevel, + this._intrinsicContentWidth, + ); + return { + ...proposedState, + offsetX: clamp( + -(newContentWidth - this.frame.size.width), + 0, + proposedState.offsetX, + ), + }; + } + + /** + * @private + */ + _clampedProposedState( + proposedState: HorizontalPanAndZoomState, + ): HorizontalPanAndZoomState { + const zoomClampedState = this._clampedProposedStateZoomLevel(proposedState); + return this._clampedProposedStateOffsetX(zoomClampedState); + } +} diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/ResizableSplitView.js b/packages/react-devtools-scheduling-profiler/src/view-base/ResizableSplitView.js new file mode 100644 index 0000000000..314be68e28 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/view-base/ResizableSplitView.js @@ -0,0 +1,311 @@ +/** + * 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 type { + Interaction, + MouseDownInteraction, + MouseMoveInteraction, + MouseUpInteraction, +} from './useCanvasInteraction'; +import type {Rect, Size} from './geometry'; + +import nullthrows from 'nullthrows'; +import {Surface} from './Surface'; +import {View} from './View'; +import {rectContainsPoint} from './geometry'; +import {layeredLayout, noopLayout} from './layouter'; +import {ColorView} from './ColorView'; +import {clamp} from './utils/clamp'; + +type ResizeBarState = 'normal' | 'hovered' | 'dragging'; + +type ResizingState = $ReadOnly<{| + /** Distance between top of resize bar and mouseY */ + cursorOffsetInBarFrame: number, + /** Mouse's vertical coordinates relative to canvas */ + mouseY: number, +|}>; + +type LayoutState = $ReadOnly<{| + /** Resize bar's vertical position relative to resize view's frame.origin.y */ + barOffsetY: number, +|}>; + +function getColorForBarState(state: ResizeBarState): string { + // Colors obtained from Firefox Profiler + switch (state) { + case 'normal': + return '#ccc'; + case 'hovered': + return '#bbb'; + case 'dragging': + return '#aaa'; + } + throw new Error(`Unknown resize bar state ${state}`); +} + +class ResizeBar extends View { + _intrinsicContentSize: Size = { + width: 0, + height: 5, + }; + + _interactionState: ResizeBarState = 'normal'; + + constructor(surface: Surface, frame: Rect) { + super(surface, frame, layeredLayout); + this.addSubview(new ColorView(surface, frame, '')); + this._updateColor(); + } + + desiredSize() { + return this._intrinsicContentSize; + } + + _getColorView(): ColorView { + return (this.subviews[0]: any); + } + + _updateColor() { + this._getColorView().setColor(getColorForBarState(this._interactionState)); + } + + _setInteractionState(state: ResizeBarState) { + if (this._interactionState === state) { + return; + } + this._interactionState = state; + this._updateColor(); + } + + _handleMouseDown(interaction: MouseDownInteraction) { + const cursorInView = rectContainsPoint( + interaction.payload.location, + this.frame, + ); + if (cursorInView) { + this._setInteractionState('dragging'); + } + } + + _handleMouseMove(interaction: MouseMoveInteraction) { + const cursorInView = rectContainsPoint( + interaction.payload.location, + this.frame, + ); + if (this._interactionState === 'dragging') { + return; + } + this._setInteractionState(cursorInView ? 'hovered' : 'normal'); + } + + _handleMouseUp(interaction: MouseUpInteraction) { + const cursorInView = rectContainsPoint( + interaction.payload.location, + this.frame, + ); + if (this._interactionState === 'dragging') { + this._setInteractionState(cursorInView ? 'hovered' : 'normal'); + } + } + + handleInteraction(interaction: Interaction) { + switch (interaction.type) { + case 'mousedown': + this._handleMouseDown(interaction); + return; + case 'mousemove': + this._handleMouseMove(interaction); + return; + case 'mouseup': + this._handleMouseUp(interaction); + return; + } + } +} + +export class ResizableSplitView extends View { + _resizingState: ResizingState | null = null; + _layoutState: LayoutState; + + constructor( + surface: Surface, + frame: Rect, + topSubview: View, + bottomSubview: View, + ) { + super(surface, frame, noopLayout); + + this.addSubview(topSubview); + this.addSubview(new ResizeBar(surface, frame)); + this.addSubview(bottomSubview); + + const topSubviewDesiredSize = topSubview.desiredSize(); + this._layoutState = { + barOffsetY: topSubviewDesiredSize ? topSubviewDesiredSize.height : 0, + }; + } + + _getTopSubview(): View { + return this.subviews[0]; + } + + _getResizeBar(): View { + return this.subviews[1]; + } + + _getBottomSubview(): View { + return this.subviews[2]; + } + + _getResizeBarDesiredSize(): Size { + return nullthrows( + this._getResizeBar().desiredSize(), + 'Resize bar must have desired size', + ); + } + + desiredSize() { + const topSubviewDesiredSize = this._getTopSubview().desiredSize(); + const resizeBarDesiredSize = this._getResizeBarDesiredSize(); + const bottomSubviewDesiredSize = this._getBottomSubview().desiredSize(); + + const topSubviewDesiredWidth = topSubviewDesiredSize + ? topSubviewDesiredSize.width + : 0; + const bottomSubviewDesiredWidth = bottomSubviewDesiredSize + ? bottomSubviewDesiredSize.width + : 0; + + const topSubviewDesiredHeight = topSubviewDesiredSize + ? topSubviewDesiredSize.height + : 0; + const bottomSubviewDesiredHeight = bottomSubviewDesiredSize + ? bottomSubviewDesiredSize.height + : 0; + + return { + width: Math.max( + topSubviewDesiredWidth, + resizeBarDesiredSize.width, + bottomSubviewDesiredWidth, + ), + height: + topSubviewDesiredHeight + + resizeBarDesiredSize.height + + bottomSubviewDesiredHeight, + }; + } + + layoutSubviews() { + this._updateLayoutState(); + this._updateSubviewFrames(); + super.layoutSubviews(); + } + + _updateLayoutState() { + const {frame, visibleArea, _resizingState} = this; + + const resizeBarDesiredSize = this._getResizeBarDesiredSize(); + // Allow bar to travel to bottom of the visible area of this view but no further + const maxPossibleBarOffset = + visibleArea.size.height - resizeBarDesiredSize.height; + const topSubviewDesiredSize = this._getTopSubview().desiredSize(); + const maxBarOffset = topSubviewDesiredSize + ? Math.min(maxPossibleBarOffset, topSubviewDesiredSize.height) + : maxPossibleBarOffset; + + let proposedBarOffsetY = this._layoutState.barOffsetY; + // Update bar offset if dragging bar + if (_resizingState) { + const {mouseY, cursorOffsetInBarFrame} = _resizingState; + proposedBarOffsetY = mouseY - frame.origin.y - cursorOffsetInBarFrame; + } + + this._layoutState = { + ...this._layoutState, + barOffsetY: clamp(0, maxBarOffset, proposedBarOffsetY), + }; + } + + _updateSubviewFrames() { + const { + frame: { + origin: {x, y}, + size: {width, height}, + }, + _layoutState: {barOffsetY}, + } = this; + + const resizeBarDesiredSize = this._getResizeBarDesiredSize(); + + let currentY = y; + + this._getTopSubview().setFrame({ + origin: {x, y: currentY}, + size: {width, height: barOffsetY}, + }); + currentY += this._getTopSubview().frame.size.height; + + this._getResizeBar().setFrame({ + origin: {x, y: currentY}, + size: {width, height: resizeBarDesiredSize.height}, + }); + currentY += this._getResizeBar().frame.size.height; + + this._getBottomSubview().setFrame({ + origin: {x, y: currentY}, + // Fill remaining height + size: {width, height: height + y - currentY}, + }); + } + + _handleMouseDown(interaction: MouseDownInteraction) { + const cursorLocation = interaction.payload.location; + const resizeBarFrame = this._getResizeBar().frame; + if (rectContainsPoint(cursorLocation, resizeBarFrame)) { + const mouseY = cursorLocation.y; + this._resizingState = { + cursorOffsetInBarFrame: mouseY - resizeBarFrame.origin.y, + mouseY, + }; + } + } + + _handleMouseMove(interaction: MouseMoveInteraction) { + const {_resizingState} = this; + if (_resizingState) { + this._resizingState = { + ..._resizingState, + mouseY: interaction.payload.location.y, + }; + this.setNeedsDisplay(); + } + } + + _handleMouseUp(interaction: MouseUpInteraction) { + if (this._resizingState) { + this._resizingState = null; + } + } + + handleInteraction(interaction: Interaction) { + switch (interaction.type) { + case 'mousedown': + this._handleMouseDown(interaction); + return; + case 'mousemove': + this._handleMouseMove(interaction); + return; + case 'mouseup': + this._handleMouseUp(interaction); + return; + } + } +} diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/Surface.js b/packages/react-devtools-scheduling-profiler/src/view-base/Surface.js new file mode 100644 index 0000000000..eb4285da7b --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/view-base/Surface.js @@ -0,0 +1,89 @@ +/** + * 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 type {Interaction} from './useCanvasInteraction'; +import type {Size} from './geometry'; + +import memoize from 'memoize-one'; + +import {View} from './View'; +import {zeroPoint} from './geometry'; + +// hidpi canvas: https://www.html5rocks.com/en/tutorials/canvas/hidpi/ +function configureRetinaCanvas(canvas, height, width) { + const dpr: number = window.devicePixelRatio || 1; + canvas.width = width * dpr; + canvas.height = height * dpr; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + return dpr; +} + +const getCanvasContext = memoize( + ( + canvas: HTMLCanvasElement, + height: number, + width: number, + scaleCanvas: boolean = true, + ): CanvasRenderingContext2D => { + const context = canvas.getContext('2d', {alpha: false}); + if (scaleCanvas) { + const dpr = configureRetinaCanvas(canvas, height, width); + // Scale all drawing operations by the dpr, so you don't have to worry about the difference. + context.scale(dpr, dpr); + } + return context; + }, +); + +/** + * Represents the canvas surface and a view heirarchy. A surface is also the + * place where all interactions enter the view heirarchy. + */ +export class Surface { + rootView: ?View; + _context: ?CanvasRenderingContext2D; + _canvasSize: ?Size; + + setCanvas(canvas: HTMLCanvasElement, canvasSize: Size) { + this._context = getCanvasContext( + canvas, + canvasSize.height, + canvasSize.width, + ); + this._canvasSize = canvasSize; + + if (this.rootView) { + this.rootView.setNeedsDisplay(); + } + } + + displayIfNeeded() { + const {rootView, _canvasSize, _context} = this; + if (!rootView || !_context || !_canvasSize) { + return; + } + rootView.setFrame({ + origin: zeroPoint, + size: _canvasSize, + }); + rootView.setVisibleArea({ + origin: zeroPoint, + size: _canvasSize, + }); + rootView.displayIfNeeded(_context); + } + + handleInteraction(interaction: Interaction) { + if (!this.rootView) { + return; + } + this.rootView.handleInteractionAndPropagateToSubviews(interaction); + } +} diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/VerticalScrollView.js b/packages/react-devtools-scheduling-profiler/src/view-base/VerticalScrollView.js new file mode 100644 index 0000000000..24a0612fc9 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/view-base/VerticalScrollView.js @@ -0,0 +1,181 @@ +/** + * 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 type { + Interaction, + MouseDownInteraction, + MouseMoveInteraction, + MouseUpInteraction, + WheelPlainInteraction, +} from './useCanvasInteraction'; +import type {Rect} from './geometry'; + +import {Surface} from './Surface'; +import {View} from './View'; +import {rectContainsPoint} from './geometry'; +import {clamp} from './utils/clamp'; +import {MOVE_WHEEL_DELTA_THRESHOLD} from './constants'; + +type VerticalScrollState = $ReadOnly<{| + offsetY: number, +|}>; + +function scrollStatesAreEqual( + state1: VerticalScrollState, + state2: VerticalScrollState, +): boolean { + return state1.offsetY === state2.offsetY; +} + +export class VerticalScrollView extends View { + _scrollState: VerticalScrollState = { + offsetY: 0, + }; + + _isPanning = false; + + constructor(surface: Surface, frame: Rect, contentView: View) { + super(surface, frame); + this.addSubview(contentView); + } + + setFrame(newFrame: Rect) { + super.setFrame(newFrame); + + // Revalidate scrollState + this._updateState(this._scrollState); + } + + desiredSize() { + return this._contentView.desiredSize(); + } + + /** + * Reference to the content view. This view is also the only view in + * `this.subviews`. + */ + get _contentView() { + return this.subviews[0]; + } + + layoutSubviews() { + const {offsetY} = this._scrollState; + const desiredSize = this._contentView.desiredSize(); + + const minimumHeight = this.frame.size.height; + const desiredHeight = desiredSize ? desiredSize.height : 0; + // Force view to take up at least all remaining vertical space. + const height = Math.max(desiredHeight, minimumHeight); + + const proposedFrame = { + origin: { + x: this.frame.origin.x, + y: this.frame.origin.y + offsetY, + }, + size: { + width: this.frame.size.width, + height, + }, + }; + this._contentView.setFrame(proposedFrame); + super.layoutSubviews(); + } + + _handleMouseDown(interaction: MouseDownInteraction) { + if (rectContainsPoint(interaction.payload.location, this.frame)) { + this._isPanning = true; + } + } + + _handleMouseMove(interaction: MouseMoveInteraction) { + if (!this._isPanning) { + return; + } + const {offsetY} = this._scrollState; + const {movementY} = interaction.payload.event; + this._updateState({ + ...this._scrollState, + offsetY: offsetY + movementY, + }); + } + + _handleMouseUp(interaction: MouseUpInteraction) { + if (this._isPanning) { + this._isPanning = false; + } + } + + _handleWheelPlain(interaction: WheelPlainInteraction) { + const { + location, + delta: {deltaX, deltaY}, + } = interaction.payload; + if (!rectContainsPoint(location, this.frame)) { + return; // Not scrolling on view + } + + const absDeltaX = Math.abs(deltaX); + const absDeltaY = Math.abs(deltaY); + if (absDeltaX > absDeltaY) { + return; // Scrolling horizontally + } + + if (absDeltaY < MOVE_WHEEL_DELTA_THRESHOLD) { + return; + } + + this._updateState({ + ...this._scrollState, + offsetY: this._scrollState.offsetY - deltaY, + }); + } + + handleInteraction(interaction: Interaction) { + switch (interaction.type) { + case 'mousedown': + this._handleMouseDown(interaction); + break; + case 'mousemove': + this._handleMouseMove(interaction); + break; + case 'mouseup': + this._handleMouseUp(interaction); + break; + case 'wheel-plain': + this._handleWheelPlain(interaction); + break; + } + } + + /** + * @private + */ + _updateState(proposedState: VerticalScrollState) { + const clampedState = this._clampedProposedState(proposedState); + if (!scrollStatesAreEqual(clampedState, this._scrollState)) { + this._scrollState = clampedState; + this.setNeedsDisplay(); + } + } + + /** + * @private + */ + _clampedProposedState( + proposedState: VerticalScrollState, + ): VerticalScrollState { + return { + offsetY: clamp( + -(this._contentView.frame.size.height - this.frame.size.height), + 0, + proposedState.offsetY, + ), + }; + } +} diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/View.js b/packages/react-devtools-scheduling-profiler/src/view-base/View.js new file mode 100644 index 0000000000..d92a55b6fe --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/view-base/View.js @@ -0,0 +1,274 @@ +/** + * 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 type {Interaction} from './useCanvasInteraction'; +import type {Rect, Size} from './geometry'; +import type {Layouter} from './layouter'; + +import {Surface} from './Surface'; +import { + rectEqualToRect, + intersectionOfRects, + rectIntersectsRect, + sizeIsEmpty, + sizeIsValid, + unionOfRects, + zeroRect, +} from './geometry'; +import {noopLayout, viewsToLayout, collapseLayoutIntoViews} from './layouter'; + +/** + * Base view class that can be subclassed to draw custom content or manage + * subclasses. + */ +export class View { + surface: Surface; + + frame: Rect; + visibleArea: Rect; + + superview: ?View; + subviews: View[] = []; + + /** + * An injected function that lays out our subviews. + * @private + */ + _layouter: Layouter; + + /** + * Whether this view needs to be drawn. + * + * NOTE: Do not set directly! Use `setNeedsDisplay`. + * + * @see setNeedsDisplay + * @private + */ + _needsDisplay = true; + + /** + * Whether the heirarchy below this view has subviews that need display. + * + * NOTE: Do not set directly! Use `setSubviewsNeedDisplay`. + * + * @see setSubviewsNeedDisplay + * @private + */ + _subviewsNeedDisplay = false; + + constructor( + surface: Surface, + frame: Rect, + layouter: Layouter = noopLayout, + visibleArea: Rect = frame, + ) { + this.surface = surface; + this.frame = frame; + this._layouter = layouter; + this.visibleArea = visibleArea; + } + + /** + * Invalidates view's contents. + * + * Downward propagating; once called, all subviews of this view should also + * be invalidated. + */ + setNeedsDisplay() { + this._needsDisplay = true; + if (this.superview) { + this.superview._setSubviewsNeedDisplay(); + } + this.subviews.forEach(subview => subview.setNeedsDisplay()); + } + + /** + * Informs superview that it has subviews that need to be drawn. + * + * Upward propagating; once called, all superviews of this view should also + * have `subviewsNeedDisplay` = true. + * + * @private + */ + _setSubviewsNeedDisplay() { + this._subviewsNeedDisplay = true; + if (this.superview) { + this.superview._setSubviewsNeedDisplay(); + } + } + + setFrame(newFrame: Rect) { + if (!rectEqualToRect(this.frame, newFrame)) { + this.frame = newFrame; + if (sizeIsValid(newFrame.size)) { + this.frame = newFrame; + } else { + this.frame = zeroRect; + } + this.setNeedsDisplay(); + } + } + + setVisibleArea(newVisibleArea: Rect) { + if (!rectEqualToRect(this.visibleArea, newVisibleArea)) { + if (sizeIsValid(newVisibleArea.size)) { + this.visibleArea = newVisibleArea; + } else { + this.visibleArea = zeroRect; + } + this.setNeedsDisplay(); + } + } + + /** + * A size that can be used as a hint by layout functions. + * + * Implementations should typically return the intrinsic content size or a + * size that fits all the view's content. + * + * The default implementation returns a size that fits all the view's + * subviews. + * + * Can be overridden by subclasses. + */ + desiredSize(): ?Size { + if (this._needsDisplay) { + this.layoutSubviews(); + } + const frames = this.subviews.map(subview => subview.frame); + return unionOfRects(...frames).size; + } + + /** + * Appends `view` to the list of this view's `subviews`. + */ + addSubview(view: View) { + if (this.subviews.includes(view)) { + return; + } + this.subviews.push(view); + view.superview = this; + } + + /** + * Breaks the subview-superview relationship between `view` and this view, if + * `view` is a subview of this view. + */ + removeSubview(view: View) { + const subviewIndex = this.subviews.indexOf(view); + if (subviewIndex === -1) { + return; + } + view.superview = undefined; + this.subviews.splice(subviewIndex, 1); + } + + /** + * Removes all subviews from this view. + */ + removeAllSubviews() { + this.subviews.forEach(subview => (subview.superview = undefined)); + this.subviews = []; + } + + /** + * Executes the display flow if this view needs to be drawn. + * + * 1. Lays out subviews with `layoutSubviews`. + * 2. Draws content with `draw`. + */ + displayIfNeeded(context: CanvasRenderingContext2D) { + if ( + (this._needsDisplay || this._subviewsNeedDisplay) && + rectIntersectsRect(this.frame, this.visibleArea) && + !sizeIsEmpty(this.visibleArea.size) + ) { + this.layoutSubviews(); + if (this._needsDisplay) this._needsDisplay = false; + if (this._subviewsNeedDisplay) this._subviewsNeedDisplay = false; + this.draw(context); + } + } + + /** + * Layout self and subviews. + * + * Implementations should call `setNeedsDisplay` if a draw is required. + * + * The default implementation uses `this.layouter` to lay out subviews. + * + * Can be overwritten by subclasses that wish to manually manage their + * subviews' layout. + * + * NOTE: Do not call directly! Use `displayIfNeeded`. + * + * @see displayIfNeeded + */ + layoutSubviews() { + const {frame, _layouter, subviews, visibleArea} = this; + const existingLayout = viewsToLayout(subviews); + const newLayout = _layouter(existingLayout, frame); + collapseLayoutIntoViews(newLayout); + + subviews.forEach((subview, subviewIndex) => { + if (rectIntersectsRect(visibleArea, subview.frame)) { + subview.setVisibleArea(intersectionOfRects(visibleArea, subview.frame)); + } else { + subview.setVisibleArea(zeroRect); + } + }); + } + + /** + * Draw the contents of this view in the given canvas `context`. + * + * Defaults to drawing this view's `subviews`. + * + * To be overwritten by subclasses that wish to draw custom content. + * + * NOTE: Do not call directly! Use `displayIfNeeded`. + * + * @see displayIfNeeded + */ + draw(context: CanvasRenderingContext2D) { + const {subviews, visibleArea} = this; + subviews.forEach(subview => { + if (rectIntersectsRect(visibleArea, subview.visibleArea)) { + subview.displayIfNeeded(context); + } + }); + } + + /** + * Handle an `interaction`. + * + * To be overwritten by subclasses that wish to handle interactions. + */ + // Internal note: Do not call directly! Use + // `handleInteractionAndPropagateToSubviews` so that interactions are + // propagated to subviews. + handleInteraction(interaction: Interaction) {} + + /** + * Handle an `interaction` and propagates it to all of this view's + * `subviews`. + * + * NOTE: Should not be overridden! Subclasses should override + * `handleInteraction` instead. + * + * @see handleInteraction + * @protected + */ + handleInteractionAndPropagateToSubviews(interaction: Interaction) { + this.handleInteraction(interaction); + this.subviews.forEach(subview => + subview.handleInteractionAndPropagateToSubviews(interaction), + ); + } +} diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/__tests__/geometry-test.js b/packages/react-devtools-scheduling-profiler/src/view-base/__tests__/geometry-test.js new file mode 100644 index 0000000000..2042651b69 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/view-base/__tests__/geometry-test.js @@ -0,0 +1,272 @@ +/** + * 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 { + pointEqualToPoint, + sizeEqualToSize, + rectEqualToRect, + sizeIsValid, + sizeIsEmpty, + rectIntersectsRect, + intersectionOfRects, + rectContainsPoint, + unionOfRects, +} from '../geometry'; + +describe(pointEqualToPoint, () => { + it('should return true when 2 points have the same values', () => { + expect(pointEqualToPoint({x: 1, y: 1}, {x: 1, y: 1})).toBe(true); + expect(pointEqualToPoint({x: -1, y: 2}, {x: -1, y: 2})).toBe(true); + expect( + pointEqualToPoint({x: 3.14159, y: 0.26535}, {x: 3.14159, y: 0.26535}), + ).toBe(true); + }); + + it('should return false when 2 points have different values', () => { + expect(pointEqualToPoint({x: 1, y: 1}, {x: 1, y: 0})).toBe(false); + expect(pointEqualToPoint({x: -1, y: 2}, {x: 0, y: 1})).toBe(false); + expect( + pointEqualToPoint({x: 3.1416, y: 0.26534}, {x: 3.14159, y: 0.26535}), + ).toBe(false); + }); +}); + +describe(sizeEqualToSize, () => { + it('should return true when 2 sizes have the same values', () => { + expect(sizeEqualToSize({width: 1, height: 1}, {width: 1, height: 1})).toBe( + true, + ); + expect( + sizeEqualToSize({width: -1, height: 2}, {width: -1, height: 2}), + ).toBe(true); + expect( + sizeEqualToSize( + {width: 3.14159, height: 0.26535}, + {width: 3.14159, height: 0.26535}, + ), + ).toBe(true); + }); + + it('should return false when 2 sizes have different values', () => { + expect(sizeEqualToSize({width: 1, height: 1}, {width: 1, height: 0})).toBe( + false, + ); + expect(sizeEqualToSize({width: -1, height: 2}, {width: 0, height: 1})).toBe( + false, + ); + expect( + sizeEqualToSize( + {width: 3.1416, height: 0.26534}, + {width: 3.14159, height: 0.26535}, + ), + ).toBe(false); + }); +}); + +describe(rectEqualToRect, () => { + it('should return true when 2 rects have the same values', () => { + expect( + rectEqualToRect( + {origin: {x: 1, y: 1}, size: {width: 1, height: 1}}, + {origin: {x: 1, y: 1}, size: {width: 1, height: 1}}, + ), + ).toBe(true); + expect( + rectEqualToRect( + {origin: {x: 1, y: 2}, size: {width: 3.14, height: 4}}, + {origin: {x: 1, y: 2}, size: {width: 3.14, height: 4}}, + ), + ).toBe(true); + }); + + it('should return false when 2 rects have different values', () => { + expect( + rectEqualToRect( + {origin: {x: 1, y: 1}, size: {width: 1, height: 1}}, + {origin: {x: 0, y: 1}, size: {width: 1, height: 1}}, + ), + ).toBe(false); + expect( + rectEqualToRect( + {origin: {x: 1, y: 2}, size: {width: 3.14, height: 4}}, + {origin: {x: 1, y: 2}, size: {width: 3.15, height: 4}}, + ), + ).toBe(false); + }); +}); + +describe(sizeIsValid, () => { + it('should return true when the size has non-negative width and height', () => { + expect(sizeIsValid({width: 1, height: 1})).toBe(true); + expect(sizeIsValid({width: 0, height: 0})).toBe(true); + }); + + it('should return false when the size has negative width or height', () => { + expect(sizeIsValid({width: 0, height: -1})).toBe(false); + expect(sizeIsValid({width: -1, height: 0})).toBe(false); + expect(sizeIsValid({width: -1, height: -1})).toBe(false); + }); +}); + +describe(sizeIsEmpty, () => { + it('should return true when the size has negative area', () => { + expect(sizeIsEmpty({width: 1, height: -1})).toBe(true); + expect(sizeIsEmpty({width: -1, height: -1})).toBe(true); + }); + + it('should return true when the size has zero area', () => { + expect(sizeIsEmpty({width: 0, height: 0})).toBe(true); + expect(sizeIsEmpty({width: 0, height: 1})).toBe(true); + expect(sizeIsEmpty({width: 1, height: 0})).toBe(true); + }); + + it('should return false when the size has positive area', () => { + expect(sizeIsEmpty({width: 1, height: 1})).toBe(false); + expect(sizeIsEmpty({width: 2, height: 1})).toBe(false); + }); +}); + +describe(rectIntersectsRect, () => { + it('should return true when 2 rects intersect', () => { + // Rects touch + expect( + rectIntersectsRect( + {origin: {x: 0, y: 0}, size: {width: 1, height: 1}}, + {origin: {x: 1, y: 1}, size: {width: 1, height: 1}}, + ), + ).toEqual(true); + + // Rects overlap + expect( + rectIntersectsRect( + {origin: {x: 0, y: 0}, size: {width: 2, height: 1}}, + {origin: {x: 1, y: -2}, size: {width: 0.5, height: 5}}, + ), + ).toEqual(true); + + // Rects are equal + expect( + rectIntersectsRect( + {origin: {x: 1, y: 2}, size: {width: 3.14, height: 4}}, + {origin: {x: 1, y: 2}, size: {width: 3.14, height: 4}}, + ), + ).toEqual(true); + }); + + it('should return false when 2 rects do not intersect', () => { + expect( + rectIntersectsRect( + {origin: {x: 0, y: 1}, size: {width: 1, height: 1}}, + {origin: {x: 0, y: 10}, size: {width: 1, height: 1}}, + ), + ).toBe(false); + expect( + rectIntersectsRect( + {origin: {x: 1, y: 2}, size: {width: 3.14, height: 4}}, + {origin: {x: -4, y: 2}, size: {width: 3.15, height: 4}}, + ), + ).toBe(false); + }); +}); + +describe(intersectionOfRects, () => { + // NOTE: Undefined behavior if rects do not intersect + + it('should return intersection when 2 rects intersect', () => { + // Rects touch + expect( + intersectionOfRects( + {origin: {x: 0, y: 0}, size: {width: 1, height: 1}}, + {origin: {x: 1, y: 1}, size: {width: 1, height: 1}}, + ), + ).toEqual({origin: {x: 1, y: 1}, size: {width: 0, height: 0}}); + + // Rects overlap + expect( + intersectionOfRects( + {origin: {x: 0, y: 0}, size: {width: 2, height: 1}}, + {origin: {x: 1, y: -2}, size: {width: 0.5, height: 5}}, + ), + ).toEqual({origin: {x: 1, y: 0}, size: {width: 0.5, height: 1}}); + + // Rects are equal + expect( + intersectionOfRects( + {origin: {x: 1, y: 2}, size: {width: 9.24, height: 4}}, + {origin: {x: 1, y: 2}, size: {width: 9.24, height: 4}}, + ), + ).toEqual({origin: {x: 1, y: 2}, size: {width: 9.24, height: 4}}); + }); +}); + +describe(rectContainsPoint, () => { + it("should return true if point is on the rect's edge", () => { + expect( + rectContainsPoint( + {x: 0, y: 0}, + {origin: {x: 0, y: 0}, size: {width: 1, height: 1}}, + ), + ).toBe(true); + expect( + rectContainsPoint( + {x: 5, y: 0}, + {origin: {x: 0, y: 0}, size: {width: 10, height: 1}}, + ), + ).toBe(true); + expect( + rectContainsPoint( + {x: 1, y: 1}, + {origin: {x: 0, y: 0}, size: {width: 1, height: 1}}, + ), + ).toBe(true); + }); + + it('should return true if point is in rect', () => { + expect( + rectContainsPoint( + {x: 5, y: 50}, + {origin: {x: 0, y: 0}, size: {width: 10, height: 100}}, + ), + ).toBe(true); + }); + + it('should return false if point is not in rect', () => { + expect( + rectContainsPoint( + {x: -1, y: 0}, + {origin: {x: 0, y: 0}, size: {width: 1, height: 1}}, + ), + ).toBe(false); + }); +}); + +describe(unionOfRects, () => { + it('should return zero rect if no rects are provided', () => { + expect(unionOfRects()).toEqual({ + origin: {x: 0, y: 0}, + size: {width: 0, height: 0}, + }); + }); + + it('should return rect if 1 rect is provided', () => { + expect( + unionOfRects({origin: {x: 1, y: 2}, size: {width: 3, height: 4}}), + ).toEqual({origin: {x: 1, y: 2}, size: {width: 3, height: 4}}); + }); + + it('should return union of rects if more than one rect is provided', () => { + expect( + unionOfRects( + {origin: {x: 1, y: 2}, size: {width: 3, height: 4}}, + {origin: {x: 100, y: 200}, size: {width: 3, height: 4}}, + {origin: {x: -10, y: -20}, size: {width: 50, height: 60}}, + ), + ).toEqual({origin: {x: -10, y: -20}, size: {width: 113, height: 224}}); + }); +}); diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/constants.js b/packages/react-devtools-scheduling-profiler/src/view-base/constants.js new file mode 100644 index 0000000000..0886dc024a --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/view-base/constants.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 + */ + +export const MOVE_WHEEL_DELTA_THRESHOLD = 1; +export const ZOOM_WHEEL_DELTA_THRESHOLD = 1; +export const MIN_ZOOM_LEVEL = 0.25; +export const MAX_ZOOM_LEVEL = 1000; diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/geometry.js b/packages/react-devtools-scheduling-profiler/src/view-base/geometry.js new file mode 100644 index 0000000000..b027b708f0 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/view-base/geometry.js @@ -0,0 +1,128 @@ +/** + * 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 + */ + +export type Point = $ReadOnly<{|x: number, y: number|}>; +export type Size = $ReadOnly<{|width: number, height: number|}>; +export type Rect = $ReadOnly<{|origin: Point, size: Size|}>; + +/** + * Alternative representation of `Rect`. + * A tuple of (`top`, `right`, `bottom`, `left`) coordinates. + */ +type Box = [number, number, number, number]; + +export const zeroPoint: Point = Object.freeze({x: 0, y: 0}); +export const zeroSize: Size = Object.freeze({width: 0, height: 0}); +export const zeroRect: Rect = Object.freeze({ + origin: zeroPoint, + size: zeroSize, +}); + +export function pointEqualToPoint(point1: Point, point2: Point): boolean { + return point1.x === point2.x && point1.y === point2.y; +} + +export function sizeEqualToSize(size1: Size, size2: Size): boolean { + return size1.width === size2.width && size1.height === size2.height; +} + +export function rectEqualToRect(rect1: Rect, rect2: Rect): boolean { + return ( + pointEqualToPoint(rect1.origin, rect2.origin) && + sizeEqualToSize(rect1.size, rect2.size) + ); +} + +export function sizeIsValid({width, height}: Size): boolean { + return width >= 0 && height >= 0; +} + +export function sizeIsEmpty({width, height}: Size): boolean { + return width <= 0 || height <= 0; +} + +function rectToBox(rect: Rect): Box { + const top = rect.origin.y; + const right = rect.origin.x + rect.size.width; + const bottom = rect.origin.y + rect.size.height; + const left = rect.origin.x; + return [top, right, bottom, left]; +} + +function boxToRect(box: Box): Rect { + const [top, right, bottom, left] = box; + return { + origin: { + x: left, + y: top, + }, + size: { + width: right - left, + height: bottom - top, + }, + }; +} + +export function rectIntersectsRect(rect1: Rect, rect2: Rect): boolean { + const [top1, right1, bottom1, left1] = rectToBox(rect1); + const [top2, right2, bottom2, left2] = rectToBox(rect2); + return !( + right1 < left2 || + right2 < left1 || + bottom1 < top2 || + bottom2 < top1 + ); +} + +/** + * Returns the intersection of the 2 rectangles. + * + * Prerequisite: `rect1` must intersect with `rect2`. + */ +export function intersectionOfRects(rect1: Rect, rect2: Rect): Rect { + const [top1, right1, bottom1, left1] = rectToBox(rect1); + const [top2, right2, bottom2, left2] = rectToBox(rect2); + return boxToRect([ + Math.max(top1, top2), + Math.min(right1, right2), + Math.min(bottom1, bottom2), + Math.max(left1, left2), + ]); +} + +export function rectContainsPoint({x, y}: Point, rect: Rect): boolean { + const [top, right, bottom, left] = rectToBox(rect); + return left <= x && x <= right && top <= y && y <= bottom; +} + +/** + * Returns the smallest rectangle that contains all provided rects. + * + * @returns Union of `rects`. If `rects` is empty, returns `zeroRect`. + */ +export function unionOfRects(...rects: Rect[]): Rect { + if (rects.length === 0) { + return zeroRect; + } + + const [firstRect, ...remainingRects] = rects; + const boxUnion = remainingRects + .map(rectToBox) + .reduce((intermediateUnion, nextBox): Box => { + const [unionTop, unionRight, unionBottom, unionLeft] = intermediateUnion; + const [nextTop, nextRight, nextBottom, nextLeft] = nextBox; + return [ + Math.min(unionTop, nextTop), + Math.max(unionRight, nextRight), + Math.max(unionBottom, nextBottom), + Math.min(unionLeft, nextLeft), + ]; + }, rectToBox(firstRect)); + return boxToRect(boxUnion); +} diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/index.js b/packages/react-devtools-scheduling-profiler/src/view-base/index.js new file mode 100644 index 0000000000..9d432bed57 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/view-base/index.js @@ -0,0 +1,18 @@ +/** + * 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 + */ + +export * from './ColorView'; +export * from './HorizontalPanAndZoomView'; +export * from './ResizableSplitView'; +export * from './Surface'; +export * from './VerticalScrollView'; +export * from './View'; +export * from './geometry'; +export * from './layouter'; +export * from './useCanvasInteraction'; diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/layouter.js b/packages/react-devtools-scheduling-profiler/src/view-base/layouter.js new file mode 100644 index 0000000000..ca5ba1ebfd --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/view-base/layouter.js @@ -0,0 +1,269 @@ +/** + * 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 type {Rect} from './geometry'; +import type {View} from './View'; + +export type LayoutInfo = {|view: View, frame: Rect|}; +export type Layout = LayoutInfo[]; + +/** + * A function that takes a list of subviews, currently laid out in + * `existingLayout`, and lays them out into `containingFrame`. + */ +export type Layouter = ( + existingLayout: Layout, + containingFrame: Rect, +) => Layout; + +function viewToLayoutInfo(view: View): LayoutInfo { + return {view, frame: view.frame}; +} + +export function viewsToLayout(views: View[]): Layout { + return views.map(viewToLayoutInfo); +} + +/** + * Applies `layout`'s `frame`s to its corresponding `view`. + */ +export function collapseLayoutIntoViews(layout: Layout) { + layout.forEach(({view, frame}) => view.setFrame(frame)); +} + +/** + * A no-operation layout; does not modify the layout. + */ +export const noopLayout: Layouter = layout => layout; + +/** + * Layer views on top of each other. All views' frames will be set to + * `containerFrame`. + * + * Equivalent to composing: + * - `alignToContainerXLayout`, + * - `alignToContainerYLayout`, + * - `containerWidthLayout`, and + * - `containerHeightLayout`. + */ +export const layeredLayout: Layouter = (layout, containerFrame) => + layout.map(layoutInfo => ({...layoutInfo, frame: containerFrame})); + +/** + * Stacks `views` vertically in `frame`. All views in `views` will have their + * widths set to the frame's width. + */ +export const verticallyStackedLayout: Layouter = (layout, containerFrame) => { + let currentY = containerFrame.origin.y; + return layout.map(layoutInfo => { + const desiredSize = layoutInfo.view.desiredSize(); + const height = desiredSize + ? desiredSize.height + : containerFrame.origin.y + containerFrame.size.height - currentY; + const proposedFrame = { + origin: {x: containerFrame.origin.x, y: currentY}, + size: {width: containerFrame.size.width, height}, + }; + currentY += height; + return { + ...layoutInfo, + frame: proposedFrame, + }; + }); +}; + +/** + * A layouter that aligns all frames' lefts to the container frame's left. + */ +export const alignToContainerXLayout: Layouter = (layout, containerFrame) => { + return layout.map(layoutInfo => ({ + ...layoutInfo, + frame: { + origin: { + x: containerFrame.origin.x, + y: layoutInfo.frame.origin.y, + }, + size: layoutInfo.frame.size, + }, + })); +}; + +/** + * A layouter that aligns all frames' tops to the container frame's top. + */ +export const alignToContainerYLayout: Layouter = (layout, containerFrame) => { + return layout.map(layoutInfo => ({ + ...layoutInfo, + frame: { + origin: { + x: layoutInfo.frame.origin.x, + y: containerFrame.origin.y, + }, + size: layoutInfo.frame.size, + }, + })); +}; + +/** + * A layouter that sets all frames' widths to `containerFrame.size.width`. + */ +export const containerWidthLayout: Layouter = (layout, containerFrame) => { + return layout.map(layoutInfo => ({ + ...layoutInfo, + frame: { + origin: layoutInfo.frame.origin, + size: { + width: containerFrame.size.width, + height: layoutInfo.frame.size.height, + }, + }, + })); +}; + +/** + * A layouter that sets all frames' heights to `containerFrame.size.height`. + */ +export const containerHeightLayout: Layouter = (layout, containerFrame) => { + return layout.map(layoutInfo => ({ + ...layoutInfo, + frame: { + origin: layoutInfo.frame.origin, + size: { + width: layoutInfo.frame.size.width, + height: containerFrame.size.height, + }, + }, + })); +}; + +/** + * A layouter that sets all frames' heights to the desired height of its view. + * If the view has no desired size, the frame's height is set to 0. + */ +export const desiredHeightLayout: Layouter = layout => { + return layout.map(layoutInfo => { + const desiredSize = layoutInfo.view.desiredSize(); + const height = desiredSize ? desiredSize.height : 0; + return { + ...layoutInfo, + frame: { + origin: layoutInfo.frame.origin, + size: { + width: layoutInfo.frame.size.width, + height, + }, + }, + }; + }); +}; + +/** + * A layouter that sets all frames' heights to the height of the tallest frame. + */ +export const uniformMaxSubviewHeightLayout: Layouter = layout => { + const maxHeight = Math.max( + ...layout.map(layoutInfo => layoutInfo.frame.size.height), + ); + return layout.map(layoutInfo => ({ + ...layoutInfo, + frame: { + origin: layoutInfo.frame.origin, + size: { + width: layoutInfo.frame.size.width, + height: maxHeight, + }, + }, + })); +}; + +/** + * A layouter that sets heights in this fashion: + * - If a frame's height >= `containerFrame.size.height`, the frame is left unchanged. + * - Otherwise, sets the frame's height to `containerFrame.size.height`. + */ +export const atLeastContainerHeightLayout: Layouter = ( + layout, + containerFrame, +) => { + return layout.map(layoutInfo => ({ + ...layoutInfo, + frame: { + origin: layoutInfo.frame.origin, + size: { + width: layoutInfo.frame.size.width, + height: Math.max( + containerFrame.size.height, + layoutInfo.frame.size.height, + ), + }, + }, + })); +}; + +/** + * Forces last view to take up the space below the second-last view. + * Intended to be used with a vertical stack layout. + */ +export const lastViewTakesUpRemainingSpaceLayout: Layouter = ( + layout, + containerFrame, +) => { + if (layout.length === 0) { + // Nothing to do + return layout; + } + + if (layout.length === 1) { + // No second-last view; the view should just take up the container height + return containerHeightLayout(layout, containerFrame); + } + + const layoutInfoToPassThrough = layout.slice(0, layout.length - 1); + const secondLastLayoutInfo = + layoutInfoToPassThrough[layoutInfoToPassThrough.length - 1]; + + const remainingHeight = + containerFrame.size.height - + secondLastLayoutInfo.frame.origin.y - + secondLastLayoutInfo.frame.size.height; + const height = Math.max(remainingHeight, 0); // Prevent negative heights + + const lastLayoutInfo = layout[layout.length - 1]; + return [ + ...layoutInfoToPassThrough, + { + ...lastLayoutInfo, + frame: { + origin: lastLayoutInfo.frame.origin, + size: { + width: lastLayoutInfo.frame.size.width, + height, + }, + }, + }, + ]; +}; + +/** + * Create a layouter that applies each layouter in `layouters` in sequence. + */ +export function createComposedLayout(...layouters: Layouter[]): Layouter { + if (layouters.length === 0) { + return noopLayout; + } + + const composedLayout: Layouter = (layout, containerFrame) => { + return layouters.reduce( + (intermediateLayout, layouter) => + layouter(intermediateLayout, containerFrame), + layout, + ); + }; + return composedLayout; +} diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/useCanvasInteraction.js b/packages/react-devtools-scheduling-profiler/src/view-base/useCanvasInteraction.js new file mode 100644 index 0000000000..b08d9bbbbb --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/view-base/useCanvasInteraction.js @@ -0,0 +1,192 @@ +/** + * 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 type {NormalizedWheelDelta} from './utils/normalizeWheel'; +import type {Point} from './geometry'; + +import {useEffect} from 'react'; +import {normalizeWheel} from './utils/normalizeWheel'; + +export type MouseDownInteraction = {| + type: 'mousedown', + payload: {| + event: MouseEvent, + location: Point, + |}, +|}; +export type MouseMoveInteraction = {| + type: 'mousemove', + payload: {| + event: MouseEvent, + location: Point, + |}, +|}; +export type MouseUpInteraction = {| + type: 'mouseup', + payload: {| + event: MouseEvent, + location: Point, + |}, +|}; +export type WheelPlainInteraction = {| + type: 'wheel-plain', + payload: {| + event: WheelEvent, + location: Point, + delta: NormalizedWheelDelta, + |}, +|}; +export type WheelWithShiftInteraction = {| + type: 'wheel-shift', + payload: {| + event: WheelEvent, + location: Point, + delta: NormalizedWheelDelta, + |}, +|}; +export type WheelWithControlInteraction = {| + type: 'wheel-control', + payload: {| + event: WheelEvent, + location: Point, + delta: NormalizedWheelDelta, + |}, +|}; +export type WheelWithMetaInteraction = {| + type: 'wheel-meta', + payload: {| + event: WheelEvent, + location: Point, + delta: NormalizedWheelDelta, + |}, +|}; + +export type Interaction = + | MouseDownInteraction + | MouseMoveInteraction + | MouseUpInteraction + | WheelPlainInteraction + | WheelWithShiftInteraction + | WheelWithControlInteraction + | WheelWithMetaInteraction; + +let canvasBoundingRectCache = null; +function cacheFirstGetCanvasBoundingRect( + canvas: HTMLCanvasElement, +): ClientRect { + if ( + canvasBoundingRectCache && + canvas.width === canvasBoundingRectCache.width && + canvas.height === canvasBoundingRectCache.height + ) { + return canvasBoundingRectCache.rect; + } + canvasBoundingRectCache = { + width: canvas.width, + height: canvas.height, + rect: canvas.getBoundingClientRect(), + }; + return canvasBoundingRectCache.rect; +} + +export function useCanvasInteraction( + canvasRef: {|current: HTMLCanvasElement | null|}, + interactor: (interaction: Interaction) => void, +) { + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + + function localToCanvasCoordinates(localCoordinates: Point): Point { + const canvasRect = cacheFirstGetCanvasBoundingRect(canvas); + return { + x: localCoordinates.x - canvasRect.left, + y: localCoordinates.y - canvasRect.top, + }; + } + + const onCanvasMouseDown: MouseEventHandler = event => { + interactor({ + type: 'mousedown', + payload: { + event, + location: localToCanvasCoordinates({x: event.x, y: event.y}), + }, + }); + }; + + const onDocumentMouseMove: MouseEventHandler = event => { + interactor({ + type: 'mousemove', + payload: { + event, + location: localToCanvasCoordinates({x: event.x, y: event.y}), + }, + }); + }; + + const onDocumentMouseUp: MouseEventHandler = event => { + interactor({ + type: 'mouseup', + payload: { + event, + location: localToCanvasCoordinates({x: event.x, y: event.y}), + }, + }); + }; + + const onCanvasWheel: WheelEventHandler = event => { + event.preventDefault(); + event.stopPropagation(); + + const location = localToCanvasCoordinates({x: event.x, y: event.y}); + const delta = normalizeWheel(event); + + if (event.shiftKey) { + interactor({ + type: 'wheel-shift', + payload: {event, location, delta}, + }); + } else if (event.ctrlKey) { + interactor({ + type: 'wheel-control', + payload: {event, location, delta}, + }); + } else if (event.metaKey) { + interactor({ + type: 'wheel-meta', + payload: {event, location, delta}, + }); + } else { + interactor({ + type: 'wheel-plain', + payload: {event, location, delta}, + }); + } + + return false; + }; + + document.addEventListener('mousemove', onDocumentMouseMove); + document.addEventListener('mouseup', onDocumentMouseUp); + + canvas.addEventListener('mousedown', onCanvasMouseDown); + canvas.addEventListener('wheel', onCanvasWheel); + + return () => { + document.removeEventListener('mousemove', onDocumentMouseMove); + document.removeEventListener('mouseup', onDocumentMouseUp); + + canvas.removeEventListener('mousedown', onCanvasMouseDown); + canvas.removeEventListener('wheel', onCanvasWheel); + }; + }, [canvasRef, interactor]); +} diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/utils/clamp.js b/packages/react-devtools-scheduling-profiler/src/view-base/utils/clamp.js new file mode 100644 index 0000000000..1e915d3ccb --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/view-base/utils/clamp.js @@ -0,0 +1,17 @@ +/** + * 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 + */ + +export function clamp(min: number, max: number, value: number): number { + if (Number.isNaN(min) || Number.isNaN(max) || Number.isNaN(value)) { + throw new Error( + `Clamp was called with NaN. Args: min: ${min}, max: ${max}, value: ${value}.`, + ); + } + return Math.min(max, Math.max(min, value)); +} diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/utils/normalizeWheel.js b/packages/react-devtools-scheduling-profiler/src/view-base/utils/normalizeWheel.js new file mode 100644 index 0000000000..6e783a0fc0 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/view-base/utils/normalizeWheel.js @@ -0,0 +1,91 @@ +/** + * 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 + */ + +// Adapted from: https://github.com/facebookarchive/fixed-data-table/blob/master/src/vendor_upstream/dom/normalizeWheel.js + +export type NormalizedWheelDelta = {| + deltaX: number, + deltaY: number, +|}; + +// Reasonable defaults +const LINE_HEIGHT = 40; +const PAGE_HEIGHT = 800; + +/** + * Mouse wheel (and 2-finger trackpad) support on the web sucks. It is + * complicated, thus this doc is long and (hopefully) detailed enough to answer + * your questions. + * + * If you need to react to the mouse wheel in a predictable way, this code is + * like your bestest friend. * hugs * + * + * In your event callback, use this code to get sane interpretation of the + * deltas. This code will return an object with properties: + * + * - deltaX -- normalized distance (to pixels) - x plane + * - deltaY -- " - y plane + * + * Wheel values are provided by the browser assuming you are using the wheel to + * scroll a web page by a number of lines or pixels (or pages). Values can vary + * significantly on different platforms and browsers, forgetting that you can + * scroll at different speeds. Some devices (like trackpads) emit more events + * at smaller increments with fine granularity, and some emit massive jumps with + * linear speed or acceleration. + * + * This code does its best to normalize the deltas for you: + * + * - delta* is normalizing the desired scroll delta in pixel units. + * + * - positive value indicates scrolling DOWN/RIGHT, negative UP/LEFT. This + * should translate to positive value zooming IN, negative zooming OUT. + * This matches the 'wheel' event. + * + * Implementation info: + * + * The basics of the standard 'wheel' event is that it includes a unit, + * deltaMode (pixels, lines, pages), and deltaX, deltaY and deltaZ. + * See: http://www.w3.org/TR/DOM-Level-3-Events/#events-wheelevents + * + * Examples of 'wheel' event if you scroll slowly (down) by one step with an + * average mouse: + * + * OS X + Chrome (mouse) - 4 pixel delta (wheelDelta -120) + * OS X + Safari (mouse) - N/A pixel delta (wheelDelta -12) + * OS X + Firefox (mouse) - 0.1 line delta (wheelDelta N/A) + * Win8 + Chrome (mouse) - 100 pixel delta (wheelDelta -120) + * Win8 + Firefox (mouse) - 3 line delta (wheelDelta -120) + * + * On the trackpad: + * + * OS X + Chrome (trackpad) - 2 pixel delta (wheelDelta -6) + * OS X + Firefox (trackpad) - 1 pixel delta (wheelDelta N/A) + */ +export function normalizeWheel(event: WheelEvent): NormalizedWheelDelta { + let deltaX = event.deltaX; + let deltaY = event.deltaY; + + if ( + // $FlowFixMe DOM_DELTA_LINE missing from WheelEvent + event.deltaMode === WheelEvent.DOM_DELTA_LINE + ) { + // delta in LINE units + deltaX *= LINE_HEIGHT; + deltaY *= LINE_HEIGHT; + } else if ( + // $FlowFixMe DOM_DELTA_PAGE missing from WheelEvent + event.deltaMode === WheelEvent.DOM_DELTA_PAGE + ) { + // delta in PAGE units + deltaX *= PAGE_HEIGHT; + deltaY *= PAGE_HEIGHT; + } + + return {deltaX, deltaY}; +} diff --git a/packages/react-devtools-scheduling-profiler/webpack.config.js b/packages/react-devtools-scheduling-profiler/webpack.config.js new file mode 100644 index 0000000000..91d6a141c2 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/webpack.config.js @@ -0,0 +1,104 @@ +'use strict'; + +const {resolve} = require('path'); +const {DefinePlugin} = require('webpack'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); + +const NODE_ENV = process.env.NODE_ENV; +if (!NODE_ENV) { + console.error('NODE_ENV not set'); + process.exit(1); +} + +const TARGET = process.env.TARGET; +if (!TARGET) { + console.error('TARGET not set'); + process.exit(1); +} + +const builtModulesDir = resolve(__dirname, '..', '..', 'build', 'node_modules'); + +const __DEV__ = NODE_ENV === 'development'; + +const imageInlineSizeLimit = 10000; + +const config = { + mode: __DEV__ ? 'development' : 'production', + devtool: __DEV__ ? 'cheap-module-eval-source-map' : false, + entry: { + app: './src/index.js', + }, + resolve: { + alias: { + react: resolve(builtModulesDir, 'react'), + 'react-dom': resolve(builtModulesDir, 'react-dom'), + }, + }, + plugins: [ + new DefinePlugin({ + __DEV__, + __PROFILE__: false, + __EXPERIMENTAL__: true, + }), + new HtmlWebpackPlugin({ + title: 'React Concurrent Mode Profiler', + }), + ], + module: { + rules: [ + { + test: /\.js$/, + loader: 'babel-loader', + options: { + configFile: resolve( + __dirname, + '..', + 'react-devtools-shared', + 'babel.config.js', + ), + }, + }, + { + test: /\.css$/, + use: [ + { + loader: 'style-loader', + }, + { + loader: 'css-loader', + options: { + sourceMap: true, + modules: { + localIdentName: '[local]___[hash:base64:5]', + }, + }, + }, + ], + }, + { + test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/], + loader: 'url-loader', + options: { + limit: imageInlineSizeLimit, + name: 'static/media/[name].[hash:8].[ext]', + }, + }, + ], + }, +}; + +if (TARGET === 'local') { + config.devServer = { + hot: true, + port: 8080, + clientLogLevel: 'warning', + stats: 'errors-only', + }; +} else { + config.output = { + path: resolve(__dirname, 'dist'), + filename: '[name].js', + }; +} + +module.exports = config; diff --git a/yarn.lock b/yarn.lock index c164ef3f18..90b4219bda 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1572,6 +1572,14 @@ global-agent "^2.0.2" global-tunnel-ng "^2.7.1" +"@elg/speedscope@1.9.0-a6f84db": + version "1.9.0-a6f84db" + resolved "https://registry.yarnpkg.com/@elg/speedscope/-/speedscope-1.9.0-a6f84db.tgz#36079096390b9b396dfec5aeac12e971feb46084" + integrity sha512-AoGBS1H5s8jrqIh51P8tZnO9i02w7jBJsd0W6+nrF2tLce502zb2rOyot4iv4fnvhmjnM6VFPBLjE2DfElg8Dw== + dependencies: + opn "5.3.0" + react "^16.13.1" + "@istanbuljs/load-nyc-config@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.0.0.tgz#10602de5570baea82f8afbfa2630b24e7a8cfe5b" @@ -1903,6 +1911,11 @@ dependencies: defer-to-connect "^1.0.1" +"@types/anymatch@*": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@types/anymatch/-/anymatch-1.3.1.tgz#336badc1beecb9dacc38bea2cf32adf627a8421a" + integrity sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA== + "@types/babel__core@^7.1.0": version "7.1.2" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.2.tgz#608c74f55928033fce18b99b213c16be4b3d114f" @@ -1965,6 +1978,11 @@ "@types/minimatch" "*" "@types/node" "*" +"@types/html-minifier-terser@^5.0.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-5.1.0.tgz#551a4589b6ee2cc9c1dff08056128aec29b94880" + integrity sha512-iYCgjm1dGPRuo12+BStjd1HiVQqhlRhWDOQigNxn023HcjnhsiFz9pc6CzJj4HwDCSQca9bxTL4PxJDbkdm3PA== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.1" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#42995b446db9a48a11a07ec083499a860e9138ff" @@ -1990,6 +2008,11 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.3.tgz#bdfd69d61e464dcc81b25159c270d75a73c1a636" integrity sha512-Il2DtDVRGDcqjDtE+rF8iqg1CArehSK84HZJCT7AMITlyXRBpuPhqGLDQMowraqqu1coEaimg4ZOqggt6L6L+A== +"@types/json-schema@^7.0.4": + version "7.0.5" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.5.tgz#dcce4430e64b443ba8945f0290fb564ad5bac6dd" + integrity sha512-7+2BITlgjgDhH0vvwZU/HZJVyk+2XUlvxXe8dFMedNX/aMkaOq++rMAFXc0tM7ij15QaWlbdQASBR9dihi+bDQ== + "@types/minimatch@*", "@types/minimatch@^3.0.3": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" @@ -2010,11 +2033,49 @@ resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-1.19.1.tgz#33509849f8e679e4add158959fdb086440e9553f" integrity sha512-5qOlnZscTn4xxM5MeGXAMOsIOIKIbh9e85zJWfBRVPlRMEVawzoPhINYbRGkBZCI8LxvBe7tJCdWiarA99OZfQ== +"@types/source-list-map@*": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.2.tgz#0078836063ffaf17412349bba364087e0ac02ec9" + integrity sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA== + "@types/stack-utils@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw== +"@types/tapable@*", "@types/tapable@^1.0.5": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.6.tgz#a9ca4b70a18b270ccb2bc0aaafefd1d486b7ea74" + integrity sha512-W+bw9ds02rAQaMvaLYxAbJ6cvguW/iJXNT6lTssS1ps6QdrMKttqEAMEG/b5CR8TZl3/L7/lH0ZV5nNR1LXikA== + +"@types/uglify-js@*": + version "3.9.3" + resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.9.3.tgz#d94ed608e295bc5424c9600e6b8565407b6b4b6b" + integrity sha512-KswB5C7Kwduwjj04Ykz+AjvPcfgv/37Za24O2EDzYNbwyzOo8+ydtvzUfZ5UMguiVu29Gx44l1A6VsPPcmYu9w== + dependencies: + source-map "^0.6.1" + +"@types/webpack-sources@*": + version "1.4.2" + resolved "https://registry.yarnpkg.com/@types/webpack-sources/-/webpack-sources-1.4.2.tgz#5d3d4dea04008a779a90135ff96fb5c0c9e6292c" + integrity sha512-77T++JyKow4BQB/m9O96n9d/UUHWLQHlcqXb9Vsf4F1+wKNrrlWNFPDLKNT92RJnCSL6CieTc+NDXtCVZswdTw== + dependencies: + "@types/node" "*" + "@types/source-list-map" "*" + source-map "^0.7.3" + +"@types/webpack@^4.41.8": + version "4.41.21" + resolved "https://registry.yarnpkg.com/@types/webpack/-/webpack-4.41.21.tgz#cc685b332c33f153bb2f5fc1fa3ac8adeb592dee" + integrity sha512-2j9WVnNrr/8PLAB5csW44xzQSJwS26aOnICsP3pSGCEdsu6KYtfQ6QJsVUKHWRnm1bL7HziJsfh5fHqth87yKA== + dependencies: + "@types/anymatch" "*" + "@types/node" "*" + "@types/tapable" "*" + "@types/uglify-js" "*" + "@types/webpack-sources" "*" + source-map "^0.6.0" + "@types/yargs-parser@*": version "13.0.0" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-13.0.0.tgz#453743c5bbf9f1bed61d959baab5b06be029b2d0" @@ -2469,6 +2530,16 @@ ajv@^6.10.0, ajv@^6.5.5, ajv@^6.9.1: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ajv@^6.12.2: + version "6.12.4" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.4.tgz#0614facc4522127fa713445c6bfd3ebd376e2234" + integrity sha512-eienB2c9qVQs2KWexhkrdMLVDoIQCz5KSeLxwg9Lzk4DOfBtIK9PQwwufcsn1jjGuf9WZmqPMbGxOzfcuphJCQ== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + ansi-align@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-2.0.0.tgz#c36aeccba563b89ceb556f3690f0b1d9e3547f7f" @@ -2586,7 +2657,7 @@ anymatch@^2.0.0: micromatch "^3.1.4" normalize-path "^2.1.1" -anymatch@^3.0.3: +anymatch@^3.0.3, anymatch@~3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.1.tgz#c55ecf02185e2469259399310c173ce31233b142" integrity sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg== @@ -2967,6 +3038,17 @@ babel-loader@^8.0.4: mkdirp "^0.5.1" pify "^4.0.1" +babel-loader@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-8.1.0.tgz#c611d5112bd5209abe8b9fa84c3e4da25275f1c3" + integrity sha512-7q7nC1tYOrqvUrN3LQK4GwSk/TQorZSOlO9C+RZDZpODgyN4ZlCqE5q9cDsyWOliN+aU9B4JX01xK9eJXowJLw== + dependencies: + find-cache-dir "^2.1.0" + loader-utils "^1.4.0" + mkdirp "^0.5.3" + pify "^4.0.1" + schema-utils "^2.6.5" + babel-plugin-dynamic-import-node@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.0.tgz#f00f507bdaa3c3e3ff6e7e5e98d90a7acab96f7f" @@ -3106,6 +3188,11 @@ binary-extensions@^1.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65" integrity sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw== +binary-extensions@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.1.0.tgz#30fa40c9e7fe07dbc895678cd287024dea241dd9" + integrity sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ== + bindings@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" @@ -3245,7 +3332,7 @@ braces@^2.3.1, braces@^2.3.2: split-string "^3.0.2" to-regex "^3.0.1" -braces@^3.0.1: +braces@^3.0.1, braces@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== @@ -3554,6 +3641,14 @@ callsites@^3.0.0: resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== +camel-case@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-4.1.1.tgz#1fc41c854f00e2f7d0139dfeba1542d6896fe547" + integrity sha512-7fa2WcG4fYFkclIvEmxBbTvmibwF2/agfEBc6q3lOpVu0A13ltLsA+Hr/8Hp6kp5f+G7hKi6t8lys6XxP+1K6Q== + dependencies: + pascal-case "^3.1.1" + tslib "^1.10.0" + camelcase@5.3.1, camelcase@^5.0.0, camelcase@^5.3.1: version "5.3.1" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" @@ -3564,6 +3659,11 @@ camelcase@^4.0.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd" integrity sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0= +camelcase@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.0.0.tgz#5259f7c30e35e278f1bdc2a4d91230b37cad981e" + integrity sha512-8KMDF1Vz2gzOq54ONPJS65IvTUaB1cHJ2DMM7MbPmLZljDH1qpzzLsWdiN9pHh6qvkRVDTi/07+eNGch/oLU4w== + caniuse-lite@^1.0.30001022: version "1.0.30001022" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001022.tgz#9eeffe580c3a8f110b7b1742dcf06a395885e4c6" @@ -3680,6 +3780,21 @@ chokidar@^2.1.8: optionalDependencies: fsevents "^1.2.7" +chokidar@^3.4.1: + version "3.4.2" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.4.2.tgz#38dc8e658dec3809741eb3ef7bb0a47fe424232d" + integrity sha512-IZHaDeBeI+sZJRX7lGcXsdzgvZqKv6sECqsbErJA4mHWfpRrD8B97kSFN4cQz6nGBGiuFia1MKR4d6c1o8Cv7A== + dependencies: + anymatch "~3.1.1" + braces "~3.0.2" + glob-parent "~5.1.0" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.4.0" + optionalDependencies: + fsevents "~2.1.2" + chownr@^1.0.1, chownr@^1.1.2, chownr@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.3.tgz#42d837d5239688d55f303003a508230fa6727142" @@ -3755,6 +3870,13 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" +clean-css@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.3.tgz#507b5de7d97b48ee53d84adb0160ff6216380f78" + integrity sha512-VcMWDN54ZN/DS+g58HYL5/n4Zrqe8vHJpGA8KdgUXFU4fuP/aHNw8eld9SyEIyabIMJX/0RaY/fplOo5hYLSFA== + dependencies: + source-map "~0.6.0" + clean-stack@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" @@ -3966,6 +4088,11 @@ commander@^4.0.1: resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.0.tgz#545983a0603fe425bc672d66c9e3c89c42121a83" integrity sha512-NIQrwvv9V39FHgGFm36+U9SMQzbiHvU79k+iADraJTpmrFFfx7Ds0IvDoAdZsDrknlkRk14OYoWXb57uTh7/sw== +commander@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" + integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== + common-tags@1.8.0: version "1.8.0" resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.0.tgz#8e3153e542d4a39e9b10554434afaaf98956a937" @@ -4384,7 +4511,26 @@ css-loader@^1.0.1: postcss-value-parser "^3.3.0" source-list-map "^2.0.0" -css-select@~1.2.0: +css-loader@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-4.2.1.tgz#9f48fd7eae1219d629a3f085ba9a9102ca1141a7" + integrity sha512-MoqmF1if7Z0pZIEXA4ZF9PgtCXxWbfzfJM+3p+OYfhcrwcqhaCRb74DSnfzRl7e024xEiCRn5hCvfUbTf2sgFA== + dependencies: + camelcase "^6.0.0" + cssesc "^3.0.0" + icss-utils "^4.1.1" + loader-utils "^2.0.0" + normalize-path "^3.0.0" + postcss "^7.0.32" + postcss-modules-extract-imports "^2.0.0" + postcss-modules-local-by-default "^3.0.3" + postcss-modules-scope "^2.2.0" + postcss-modules-values "^3.0.0" + postcss-value-parser "^4.1.0" + schema-utils "^2.7.0" + semver "^7.3.2" + +css-select@^1.1.0, css-select@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/css-select/-/css-select-1.2.0.tgz#2b3a110539c5355f1cd8d314623e870b121ec858" integrity sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg= @@ -4413,6 +4559,11 @@ cssesc@^0.1.0: resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-0.1.0.tgz#c814903e45623371a0477b40109aaafbeeaddbb4" integrity sha1-yBSQPkViM3GgR3tAEJqq++6t27Q= +cssesc@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" + integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== + cssom@^0.4.1, cssom@^0.4.4: version "0.4.4" resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.4.4.tgz#5a66cf93d2d0b661d80bf6a44fb65f5c2e4e0a10" @@ -4795,6 +4946,13 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +dom-converter@^0.2: + version "0.2.0" + resolved "https://registry.yarnpkg.com/dom-converter/-/dom-converter-0.2.0.tgz#6721a9daee2e293682955b6afe416771627bb768" + integrity sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA== + dependencies: + utila "~0.4" + dom-serializer@0: version "0.2.1" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.1.tgz#13650c850daffea35d8b626a4cfc4d3a17643fdb" @@ -4863,6 +5021,14 @@ domutils@^1.5.1: dom-serializer "0" domelementtype "1" +dot-case@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-3.0.3.tgz#21d3b52efaaba2ea5fda875bb1aa8124521cf4aa" + integrity sha512-7hwEmg6RiSQfm/GwPL4AAWXKy3YNNZA3oFv2Pdiey0mwkRCPZ9x6SZbkLcn8Ma5PYeVokzoD4Twv2n7LKp5WeA== + dependencies: + no-case "^3.0.3" + tslib "^1.10.0" + dot-prop@^4.1.0: version "4.2.0" resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.0.tgz#1f19e0c2e1aa0e32797c49799f2837ac6af69c57" @@ -5026,6 +5192,15 @@ enhanced-resolve@^4.1.0: memory-fs "^0.5.0" tapable "^1.0.0" +enhanced-resolve@^4.1.1, enhanced-resolve@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.3.0.tgz#3b806f3bfafc1ec7de69551ef93cca46c1704126" + integrity sha512-3e87LvavsdxyoCfGusJnrZ5G8SLPOFeHSNpZI/ATL9a5leXo2k0w6MKnbqhdBad9qTobSfB20Ld7UmgoNbAZkQ== + dependencies: + graceful-fs "^4.1.2" + memory-fs "^0.5.0" + tapable "^1.0.0" + entities@^1.1.1, entities@~1.1.1: version "1.1.2" resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" @@ -5079,6 +5254,23 @@ es-abstract@^1.13.0: is-regex "^1.0.4" object-keys "^1.0.12" +es-abstract@^1.17.0-next.1, es-abstract@^1.17.5: + version "1.17.6" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.6.tgz#9142071707857b2cacc7b89ecb670316c3e2d52a" + integrity sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw== + dependencies: + es-to-primitive "^1.2.1" + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.1" + is-callable "^1.2.0" + is-regex "^1.1.0" + object-inspect "^1.7.0" + object-keys "^1.1.1" + object.assign "^4.1.0" + string.prototype.trimend "^1.0.1" + string.prototype.trimstart "^1.0.1" + es-to-primitive@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.0.tgz#edf72478033456e8dda8ef09e00ad9650707f377" @@ -5088,6 +5280,15 @@ es-to-primitive@^1.2.0: is-date-object "^1.0.1" is-symbol "^1.0.2" +es-to-primitive@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" + integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA== + dependencies: + is-callable "^1.1.4" + is-date-object "^1.0.1" + is-symbol "^1.0.2" + es6-error@4.1.1, es6-error@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/es6-error/-/es6-error-4.1.1.tgz#9e3af407459deed47e9a91f9b885a84eb05c561d" @@ -5875,6 +6076,14 @@ file-entry-cache@^5.0.1: dependencies: flat-cache "^2.0.1" +file-loader@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-6.0.0.tgz#97bbfaab7a2460c07bcbd72d3a6922407f67649f" + integrity sha512-/aMOAYEFXDdjG0wytpTL5YQLfZnnTmLNjn+AIrJ/6HVnTfDqLsVKUUwkDf4I4kgex36BvjuXEn/TX9B/1ESyqQ== + dependencies: + loader-utils "^2.0.0" + schema-utils "^2.6.5" + file-uri-to-path@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" @@ -5955,7 +6164,7 @@ find-up@^4.0.0, find-up@^4.1.0: locate-path "^5.0.0" path-exists "^4.0.0" -findup-sync@3.0.0: +findup-sync@3.0.0, findup-sync@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-3.0.0.tgz#17b108f9ee512dfb7a5c7f3c8b27ea9e1a9c08d1" integrity sha512-YbffarhcicEhOrm4CtrwdKBdCuz576RLdhJDsIfvNtxUuhdRet1qZcsMjqbePtAseKdAnDyM/IyXbu7PRPRLYg== @@ -6167,7 +6376,7 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= -fsevents@2.1.3: +fsevents@2.1.3, fsevents@~2.1.2: version "2.1.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e" integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ== @@ -6319,6 +6528,13 @@ glob-parent@^5.0.0: dependencies: is-glob "^4.0.1" +glob-parent@~5.1.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.1.tgz#b6c1ef417c4e5663ea498f1c45afac6916bbc229" + integrity sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ== + dependencies: + is-glob "^4.0.1" + glob-stream@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/glob-stream/-/glob-stream-6.1.0.tgz#7045c99413b3eb94888d83ab46d0b404cc7bdde4" @@ -6385,7 +6601,7 @@ global-dirs@^2.0.1: dependencies: ini "^1.3.5" -global-modules@2.0.0: +global-modules@2.0.0, global-modules@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780" integrity sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A== @@ -6614,6 +6830,11 @@ has-symbols@^1.0.0: resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.0.tgz#ba1a8f1af2a0fc39650f5c850367704122063b44" integrity sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q= +has-symbols@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8" + integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg== + has-value@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" @@ -6679,6 +6900,11 @@ hasurl@^1.0.0: resolved "https://registry.yarnpkg.com/hasurl/-/hasurl-1.0.0.tgz#e4c619097ae1e8fc906bee904ce47e94f5e1ea37" integrity sha512-43ypUd3DbwyCT01UYpA99AEZxZ4aKtRxWGBHEIbjcOsUghd9YUON0C+JF6isNjaiwC/UF5neaUudy6JS9jZPZQ== +he@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + hmac-drbg@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" @@ -6731,12 +6957,45 @@ html-entities@^1.2.1: resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.2.1.tgz#0df29351f0721163515dfb9e5543e5f6eed5162f" integrity sha1-DfKTUfByEWNRXfueVUPl9u7VFi8= +html-entities@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.3.1.tgz#fb9a1a4b5b14c5daba82d3e34c6ae4fe701a0e44" + integrity sha512-rhE/4Z3hIhzHAUKbW8jVcCyuT5oJCXXqhN/6mXXVCpzTmvJnoH2HL/bt3EZ6p55jbFJBeAe1ZNpL5BugLujxNA== + html-escaper@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.0.tgz#71e87f931de3fe09e56661ab9a29aadec707b491" integrity sha512-a4u9BeERWGu/S8JiWEAQcdrg9v4QArtP9keViQjGMdff20fBdd8waotXaNmODqBe6uZ3Nafi7K/ho4gCQHV3Ig== -htmlparser2@^3.9.1: +html-minifier-terser@^5.0.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz#922e96f1f3bb60832c2634b79884096389b1f054" + integrity sha512-ZPr5MNObqnV/T9akshPKbVgyOqLmy+Bxo7juKCfTfnjNniTAMdy4hz21YQqoofMBJD2kdREaqPPdThoR78Tgxg== + dependencies: + camel-case "^4.1.1" + clean-css "^4.2.3" + commander "^4.1.1" + he "^1.2.0" + param-case "^3.0.3" + relateurl "^0.2.7" + terser "^4.6.3" + +html-webpack-plugin@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-4.3.0.tgz#53bf8f6d696c4637d5b656d3d9863d89ce8174fd" + integrity sha512-C0fzKN8yQoVLTelcJxZfJCE+aAvQiY2VUf3UuKrR4a9k5UMWYOtpDLsaXwATbcVCnI05hUS7L9ULQHWLZhyi3w== + dependencies: + "@types/html-minifier-terser" "^5.0.0" + "@types/tapable" "^1.0.5" + "@types/webpack" "^4.41.8" + html-minifier-terser "^5.0.1" + loader-utils "^1.2.3" + lodash "^4.17.15" + pretty-error "^2.1.1" + tapable "^1.1.3" + util.promisify "1.0.0" + +htmlparser2@^3.3.0, htmlparser2@^3.9.1: version "3.10.1" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f" integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ== @@ -6919,6 +7178,13 @@ icss-utils@^2.1.0: dependencies: postcss "^6.0.1" +icss-utils@^4.0.0, icss-utils@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-4.1.1.tgz#21170b53789ee27447c2f47dd683081403f9a467" + integrity sha512-4aFq7wvWyMHKgxsH8QQtGpvbASCf+eM3wPRLI6R+MgAnTCZ6STYsRvttLvRWK0Nfif5piF394St3HeJDaljGPA== + dependencies: + postcss "^7.0.14" + ieee754@^1.1.12, ieee754@^1.1.4: version "1.1.13" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" @@ -6993,6 +7259,11 @@ indent-string@^4.0.0: resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== +indexes-of@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/indexes-of/-/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607" + integrity sha1-8w9xbI4r00bHtn0985FVZqfAVgc= + indexof@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d" @@ -7089,6 +7360,11 @@ interpret@1.2.0, interpret@^1.0.0: resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.2.0.tgz#d5061a6224be58e8083985f5014d844359576296" integrity sha512-mT34yGKMNceBQUoVn7iCDKDntA7SC6gycMAWzGx1z/CMCTV7b2AAtXlo3nRyHZ1FelRkQbQjprHSYGwzLtkVbw== +interpret@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e" + integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA== + invariant@^2.2.2, invariant@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" @@ -7172,6 +7448,13 @@ is-binary-path@^1.0.0: dependencies: binary-extensions "^1.0.0" +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + is-buffer@^1.1.5: version "1.1.6" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" @@ -7182,6 +7465,11 @@ is-callable@^1.1.4: resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.4.tgz#1e1adf219e1eeb684d691f9d6a05ff0d30a24d75" integrity sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA== +is-callable@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.0.tgz#83336560b54a38e35e3a2df7afd0454d691468bb" + integrity sha512-pyVD9AaGLxtg6srb2Ng6ynWJqkHU9bEM087AKck0w8QwDarTfNcpIYoU8x8Hv2Icm8u6kFJM18Dag8lyqGkviw== + is-ci@^1.0.10: version "1.2.1" resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-1.2.1.tgz#e3779c8ee17fccf428488f6e281187f2e632841c" @@ -7308,7 +7596,7 @@ is-glob@^3.1.0: dependencies: is-extglob "^2.1.0" -is-glob@^4.0.0, is-glob@^4.0.1: +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== @@ -7462,6 +7750,13 @@ is-regex@^1.0.4: dependencies: has "^1.0.1" +is-regex@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.1.tgz#c6f98aacc546f6cec5468a07b7b153ab564a57b9" + integrity sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg== + dependencies: + has-symbols "^1.0.1" + is-relative@^0.1.0: version "0.1.3" resolved "https://registry.yarnpkg.com/is-relative/-/is-relative-0.1.3.tgz#905fee8ae86f45b3ec614bc3c15c869df0876e82" @@ -8461,7 +8756,7 @@ loader-utils@1.2.3, loader-utils@^1.0.2, loader-utils@^1.1.0: emojis-list "^2.0.0" json5 "^1.0.1" -loader-utils@^1.2.3: +loader-utils@^1.2.3, loader-utils@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.0.tgz#c579b5e34cb34b1a74edc6c1fb36bfa371d5a613" integrity sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA== @@ -8470,6 +8765,15 @@ loader-utils@^1.2.3: emojis-list "^3.0.0" json5 "^1.0.1" +loader-utils@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.0.tgz#e4cace5b816d425a166b5f097e10cd12b36064b0" + integrity sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ== + dependencies: + big.js "^5.2.2" + emojis-list "^3.0.0" + json5 "^2.1.2" + local-storage-fallback@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/local-storage-fallback/-/local-storage-fallback-4.1.1.tgz#6dc964635c8e9ab6d522d7d88538f465b62bd161" @@ -8637,7 +8941,7 @@ log-driver@^1.2.7: resolved "https://registry.yarnpkg.com/log-driver/-/log-driver-1.2.7.tgz#63b95021f0702fedfa2c9bb0a24e7797d71871d8" integrity sha512-U7KCmLdqsGHBLeWqYlFA0V0Sl6P08EE1ZrmA9cxjUE0WVqT9qnyVDPz1kzpFEP0jdJuFnasWIfSd7fsaNXkpbg== -loglevel@^1.6.6: +loglevel@^1.6.6, loglevel@^1.6.8: version "1.6.8" resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.8.tgz#8a25fb75d092230ecd4457270d80b54e28011171" integrity sha512-bsU7+gc9AJ2SqpzxwU3+1fedl8zAntbtC5XYlt3s2j1hJcn2PsXSmgN8TaLG/J1/2mod4+cE/3vNL70/c1RNCA== @@ -8656,6 +8960,13 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.1, loose-envify@^1.4 dependencies: js-tokens "^3.0.0 || ^4.0.0" +lower-case@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.1.tgz#39eeb36e396115cc05e29422eaea9e692c9408c7" + integrity sha512-LiWgfDLLb1dwbFQZsSglpRj+1ctGnayXz3Uv0/WO8n558JycT5fg6zkNcnW0G68Nn0aEldTFeEfmjCfmqry/rQ== + dependencies: + tslib "^1.10.0" + lowercase-keys@^1.0.0, lowercase-keys@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f" @@ -8837,6 +9148,11 @@ memoize-one@^3.1.1: resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-3.1.1.tgz#ef609811e3bc28970eac2884eece64d167830d17" integrity sha512-YqVh744GsMlZu6xkhGslPSqSurOv6P+kLN2J3ysBZfagLcL5FdRK/0UpgLoL8hwjjEvvAVkjJZyFP+1T6p1vgA== +memoize-one@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0" + integrity sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA== + memory-fs@^0.4.0, memory-fs@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552" @@ -8927,6 +9243,11 @@ mime-db@1.40.0, "mime-db@>= 1.40.0 < 2": resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.40.0.tgz#a65057e998db090f732a68f6c276d387d4126c32" integrity sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA== +mime-db@1.44.0: + version "1.44.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92" + integrity sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg== + mime-types@^2.1.12, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24: version "2.1.24" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.24.tgz#b6f8d0b3e951efb77dedeca194cff6d16f676f81" @@ -8934,6 +9255,13 @@ mime-types@^2.1.12, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24: dependencies: mime-db "1.40.0" +mime-types@^2.1.26: + version "2.1.27" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.27.tgz#47949f98e279ea53119f5722e0f34e529bec009f" + integrity sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w== + dependencies: + mime-db "1.44.0" + mime@1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" @@ -9085,7 +9413,7 @@ mkdirp@1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== -mkdirp@^0.5.0, mkdirp@^0.5.3: +mkdirp@^0.5.0, mkdirp@^0.5.3, mkdirp@^0.5.5: version "0.5.5" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== @@ -9233,6 +9561,14 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== +no-case@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.3.tgz#c21b434c1ffe48b39087e86cfb4d2582e9df18f8" + integrity sha512-ehY/mVQCf9BL0gKfsJBvFJen+1V//U+0HQMPrWct40ixE4jnv0bfvxDbWtAHL9EcaPEOJHVVYKoQn1TlZUB8Tw== + dependencies: + lower-case "^2.0.1" + tslib "^1.10.0" + node-cleanup@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/node-cleanup/-/node-cleanup-2.1.2.tgz#7ac19abd297e09a7f72a71545d951b517e4dde2c" @@ -9376,7 +9712,7 @@ normalize-path@^2.0.0, normalize-path@^2.0.1, normalize-path@^2.1.1: dependencies: remove-trailing-separator "^1.0.1" -normalize-path@^3.0.0: +normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== @@ -9473,7 +9809,7 @@ nth-check@~1.0.1: dependencies: boolbase "~1.0.0" -nullthrows@^1.0.0: +nullthrows@^1.0.0, nullthrows@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/nullthrows/-/nullthrows-1.1.1.tgz#7818258843856ae971eae4208ad7d7eb19a431b1" integrity sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw== @@ -9507,7 +9843,12 @@ object-copy@^0.1.0: define-property "^0.2.5" kind-of "^3.0.3" -object-keys@^1.0.11, object-keys@^1.0.12: +object-inspect@^1.7.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.8.0.tgz#df807e5ecf53a609cc6bfe93eac3cc7be5b3a9d0" + integrity sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA== + +object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== @@ -9529,6 +9870,14 @@ object.assign@^4.0.4, object.assign@^4.1.0: has-symbols "^1.0.0" object-keys "^1.0.11" +object.getownpropertydescriptors@^2.0.3: + version "2.1.0" + resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.0.tgz#369bf1f9592d8ab89d712dced5cb81c7c5352649" + integrity sha512-Z53Oah9A3TdLoblT7VKJaTDdXdT+lQO+cNpKVnya5JDe9uLvzu1YyY1yFDFrcxrlRgWrEFH0jJtD/IbuwjcEVg== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.0-next.1" + object.omit@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa" @@ -9602,6 +9951,13 @@ open@^7.0.2: is-docker "^2.0.0" is-wsl "^2.1.1" +opn@5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/opn/-/opn-5.3.0.tgz#64871565c863875f052cfdf53d3e3cb5adb53b1c" + integrity sha512-bYJHo/LOmoTd+pfiYhfZDnf9zekVJrY+cnS2a5F2x+w5ppvTqObojTP7WiFG+kVZs9Inw+qQ/lw7TroWwhdd2g== + dependencies: + is-wsl "^1.1.0" + opn@^5.5.0: version "5.5.0" resolved "https://registry.yarnpkg.com/opn/-/opn-5.5.0.tgz#fc7164fab56d235904c51c3b27da6758ca3b9bfc" @@ -9869,6 +10225,14 @@ parallel-transform@^1.1.0: inherits "^2.0.3" readable-stream "^2.1.5" +param-case@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/param-case/-/param-case-3.0.3.tgz#4be41f8399eff621c56eebb829a5e451d9801238" + integrity sha512-VWBVyimc1+QrzappRs7waeN2YmoZFCGXWASRYX1/rGHtXqEcrGEIDm+jqIwFa2fRXNgQEwrxaYuIrX0WcAguTA== + dependencies: + dot-case "^3.0.3" + tslib "^1.10.0" + parent-module@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" @@ -9934,6 +10298,11 @@ parse-link-header@^1.0.1: dependencies: xtend "~4.0.1" +parse-ms@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/parse-ms/-/parse-ms-2.1.0.tgz#348565a753d4391fa524029956b172cb7753097d" + integrity sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA== + parse-node-version@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/parse-node-version/-/parse-node-version-1.0.1.tgz#e2b5dbede00e7fa9bc363607f53327e8b073189b" @@ -9966,6 +10335,14 @@ parseurl@~1.3.2, parseurl@~1.3.3: resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== +pascal-case@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/pascal-case/-/pascal-case-3.1.1.tgz#5ac1975133ed619281e88920973d2cd1f279de5f" + integrity sha512-XIeHKqIrsquVTQL2crjq3NfJUxmdLasn3TYOU0VBM+UX2a6ztAWBlJQBePLGY7VHW8+2dRadeIPK5+KImwTxQA== + dependencies: + no-case "^3.0.3" + tslib "^1.10.0" + pascalcase@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" @@ -10065,6 +10442,11 @@ picomatch@^2.0.4, picomatch@^2.0.5: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.1.tgz#21bac888b6ed8601f831ce7816e335bc779f0a4a" integrity sha512-ISBaA8xQNmwELC7eOjqFKMESB2VIqt4PPDD0nsS95b/9dZXvVKOlz9keMSnoGGKcOHXfTvDD6WMaRoSc9UuhRA== +picomatch@^2.2.1: + version "2.2.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad" + integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg== + pify@^2.0.0: version "2.3.0" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" @@ -10172,6 +10554,15 @@ portfinder@^1.0.25: debug "^3.1.1" mkdirp "^0.5.1" +portfinder@^1.0.26: + version "1.0.28" + resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.28.tgz#67c4622852bd5374dd1dd900f779f53462fac778" + integrity sha512-Se+2isanIcEqf2XMHjyUKskczxbPH7dQnlMjXX6+dybayyHvAf/TCgyMRlzf/B6QDhAEFOGes0pzRo3by4AbMA== + dependencies: + async "^2.6.2" + debug "^3.1.1" + mkdirp "^0.5.5" + posix-character-classes@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" @@ -10184,6 +10575,13 @@ postcss-modules-extract-imports@^1.2.0: dependencies: postcss "^6.0.1" +postcss-modules-extract-imports@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-2.0.0.tgz#818719a1ae1da325f9832446b01136eeb493cd7e" + integrity sha512-LaYLDNS4SG8Q5WAWqIJgdHPJrDDr/Lv775rMBFUbgjTz6j34lUznACHcdRWroPvXANP2Vj7yNK57vp9eFqzLWQ== + dependencies: + postcss "^7.0.5" + postcss-modules-local-by-default@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-1.2.0.tgz#f7d80c398c5a393fa7964466bd19500a7d61c069" @@ -10192,6 +10590,16 @@ postcss-modules-local-by-default@^1.2.0: css-selector-tokenizer "^0.7.0" postcss "^6.0.1" +postcss-modules-local-by-default@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-3.0.3.tgz#bb14e0cc78279d504dbdcbfd7e0ca28993ffbbb0" + integrity sha512-e3xDq+LotiGesympRlKNgaJ0PCzoUIdpH0dj47iWAui/kyTgh3CiAr1qP54uodmJhl6p9rN6BoNcdEDVJx9RDw== + dependencies: + icss-utils "^4.1.1" + postcss "^7.0.32" + postcss-selector-parser "^6.0.2" + postcss-value-parser "^4.1.0" + postcss-modules-scope@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-1.1.0.tgz#d6ea64994c79f97b62a72b426fbe6056a194bb90" @@ -10200,6 +10608,14 @@ postcss-modules-scope@^1.1.0: css-selector-tokenizer "^0.7.0" postcss "^6.0.1" +postcss-modules-scope@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-2.2.0.tgz#385cae013cc7743f5a7d7602d1073a89eaae62ee" + integrity sha512-YyEgsTMRpNd+HmyC7H/mh3y+MeFWevy7V1evVhJWewmMbjDHIbZbOXICC2y+m1xI1UVfIT1HMW/O04Hxyu9oXQ== + dependencies: + postcss "^7.0.6" + postcss-selector-parser "^6.0.0" + postcss-modules-values@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-1.3.0.tgz#ecffa9d7e192518389f42ad0e83f72aec456ea20" @@ -10208,12 +10624,34 @@ postcss-modules-values@^1.3.0: icss-replace-symbols "^1.1.0" postcss "^6.0.1" +postcss-modules-values@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-3.0.0.tgz#5b5000d6ebae29b4255301b4a3a54574423e7f10" + integrity sha512-1//E5jCBrZ9DmRX+zCtmQtRSV6PV42Ix7Bzj9GbwJceduuf7IqP8MgeTXuRDHOWj2m0VzZD5+roFWDuU8RQjcg== + dependencies: + icss-utils "^4.0.0" + postcss "^7.0.6" + +postcss-selector-parser@^6.0.0, postcss-selector-parser@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.2.tgz#934cf799d016c83411859e09dcecade01286ec5c" + integrity sha512-36P2QR59jDTOAiIkqEprfJDsoNrvwFei3eCqKd1Y0tUsBimsq39BLp7RD+JWny3WgB1zGhJX8XVePwm9k4wdBg== + dependencies: + cssesc "^3.0.0" + indexes-of "^1.0.1" + uniq "^1.0.1" + postcss-value-parser@^3.3.0: version "3.3.1" resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281" integrity sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ== -postcss@7.0.32: +postcss-value-parser@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz#443f6a20ced6481a2bda4fa8532a6e55d789a2cb" + integrity sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ== + +postcss@7.0.32, postcss@^7.0.14, postcss@^7.0.32, postcss@^7.0.5, postcss@^7.0.6: version "7.0.32" resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.32.tgz#4310d6ee347053da3433db2be492883d62cec59d" integrity sha512-03eXong5NLnNCD05xscnGKGDZ98CyzoqPSMjOe6SuoQY7Z2hIj0Ld1g/O/UQRuOle2aRtiIRDg9tDcTGAkLfKw== @@ -10261,6 +10699,14 @@ prettier@1.19.1, prettier@^1.0.0: resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb" integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew== +pretty-error@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-2.1.1.tgz#5f4f87c8f91e5ae3f3ba87ab4cf5e03b1a17f1a3" + integrity sha1-X0+HyPkeWuPzuoerTPXgOxoX8aM= + dependencies: + renderkid "^2.0.1" + utila "~0.4" + pretty-format@^25.2.6: version "25.2.6" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-25.2.6.tgz#542a1c418d019bbf1cca2e3620443bc1323cb8d7" @@ -10271,6 +10717,13 @@ pretty-format@^25.2.6: ansi-styles "^4.0.0" react-is "^16.12.0" +pretty-ms@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/pretty-ms/-/pretty-ms-7.0.0.tgz#45781273110caf35f55cab21a8a9bd403a233dc0" + integrity sha512-J3aPWiC5e9ZeZFuSeBraGxSkGMOvulSWsxDByOcbD1Pr75YL3LSNIKIb52WXbCLE1sS5s4inBBbryjF4Y05Ceg== + dependencies: + parse-ms "^2.1.0" + prettyjson@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prettyjson/-/prettyjson-1.2.1.tgz#fcffab41d19cab4dfae5e575e64246619b12d289" @@ -10620,6 +11073,15 @@ react-virtualized-auto-sizer@^1.0.2: resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.2.tgz#a61dd4f756458bbf63bd895a92379f9b70f803bd" integrity sha512-MYXhTY1BZpdJFjUovvYHVBmkq79szK/k7V3MO+36gJkWGkrXKtyr4vCPtpphaTLRAdDNoYEYFZWE8LjN+PIHNg== +react@^16.13.1: + version "16.13.1" + resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e" + integrity sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + prop-types "^15.6.2" + read-package-json-fast@^1.1.1: version "1.1.3" resolved "https://registry.yarnpkg.com/read-package-json-fast/-/read-package-json-fast-1.1.3.tgz#3b78464ea8f3c4447f3358635390b6946dc0737e" @@ -10710,6 +11172,13 @@ readdirp@^2.2.1: micromatch "^3.1.10" readable-stream "^2.0.2" +readdirp@~3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.4.0.tgz#9fdccdf9e9155805449221ac645e8303ab5b9ada" + integrity sha512-0xe001vZBnJEK+uKcj8qOhyAKPzIT+gStxWr3LCB0DwcXR5NZJ3IaC+yGnHCYzB/S7ov3m3EEbZI2zeNvX+hGQ== + dependencies: + picomatch "^2.2.1" + readline-sync@^1.4.9: version "1.4.10" resolved "https://registry.yarnpkg.com/readline-sync/-/readline-sync-1.4.10.tgz#41df7fbb4b6312d673011594145705bf56d8873b" @@ -10749,6 +11218,11 @@ regenerator-runtime@^0.13.4: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz#d878a1d094b4306d10b9096484b33ebd55e26697" integrity sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA== +regenerator-runtime@^0.13.7: + version "0.13.7" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55" + integrity sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew== + regenerator-transform@^0.14.2: version "0.14.5" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.14.5.tgz#c98da154683671c9c4dcb16ece736517e1b7feb4" @@ -10856,6 +11330,11 @@ regjsparser@^0.6.4: dependencies: jsesc "~0.5.0" +relateurl@^0.2.7: + version "0.2.7" + resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" + integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk= + relaxed-json@1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/relaxed-json/-/relaxed-json-1.0.3.tgz#eb2101ae0ee60e82267d95ed0ddf19a3604b8c1e" @@ -10869,6 +11348,17 @@ remove-trailing-separator@^1.0.1: resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" integrity sha1-wkvOKig62tW8P1jg1IJJuSN52O8= +renderkid@^2.0.1: + version "2.0.3" + resolved "https://registry.yarnpkg.com/renderkid/-/renderkid-2.0.3.tgz#380179c2ff5ae1365c522bf2fcfcff01c5b74149" + integrity sha512-z8CLQp7EZBPCwCnncgf9C4XAi3WR0dv+uWu/PjIyhhAb5d6IJ/QZqlHFprHeKT+59//V6BNUsLbvN8+2LarxGA== + dependencies: + css-select "^1.1.0" + dom-converter "^0.2" + htmlparser2 "^3.3.0" + strip-ansi "^3.0.0" + utila "^0.4.0" + repeat-element@^1.1.2: version "1.1.3" resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce" @@ -11354,6 +11844,15 @@ schema-utils@^2.0.1: ajv "^6.1.0" ajv-keywords "^3.1.0" +schema-utils@^2.6.5, schema-utils@^2.6.6, schema-utils@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.0.tgz#17151f76d8eae67fbbf77960c33c676ad9f4efc7" + integrity sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A== + dependencies: + "@types/json-schema" "^7.0.4" + ajv "^6.12.2" + ajv-keywords "^3.4.1" + select-hose@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" @@ -11675,6 +12174,15 @@ sockjs@0.3.19: faye-websocket "^0.10.0" uuid "^3.0.1" +sockjs@0.3.20: + version "0.3.20" + resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.20.tgz#b26a283ec562ef8b2687b44033a4eeceac75d855" + integrity sha512-SpmVOVpdq0DJc0qArhF3E5xsxvaiqGNb73XfgBpK1y3UD5gs8DSo8aCTsuT5pX8rssdc2NDIzANwP9eCAiSdTA== + dependencies: + faye-websocket "^0.10.0" + uuid "^3.4.0" + websocket-driver "0.6.5" + socks-proxy-agent@^4.0.0: version "4.0.2" resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-4.0.2.tgz#3c8991f3145b2799e70e11bd5fbc8b1963116386" @@ -11749,7 +12257,7 @@ source-map@^0.5.0, source-map@^0.5.1, source-map@^0.5.6: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= -source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: +source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== @@ -11795,6 +12303,17 @@ spdy@^4.0.1: select-hose "^2.0.0" spdy-transport "^3.0.0" +spdy@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/spdy/-/spdy-4.0.2.tgz#b74f466203a3eda452c02492b91fb9e84a27677b" + integrity sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA== + dependencies: + debug "^4.1.0" + handle-thing "^2.0.0" + http-deceiver "^1.2.7" + select-hose "^2.0.0" + spdy-transport "^3.0.0" + split-on-first@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/split-on-first/-/split-on-first-1.1.0.tgz#f610afeee3b12bce1d0c30425e76398b78249a5f" @@ -11993,6 +12512,22 @@ string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.0" +string.prototype.trimend@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz#85812a6b847ac002270f5808146064c995fb6913" + integrity sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.5" + +string.prototype.trimstart@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz#14af6d9f34b053f7cfc89b72f8f2ee14b9039a54" + integrity sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.5" + string_decoder@^1.0.0, string_decoder@^1.1.1: version "1.3.0" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" @@ -12103,6 +12638,14 @@ style-loader@^0.23.1: loader-utils "^1.1.0" schema-utils "^1.0.0" +style-loader@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-1.2.1.tgz#c5cbbfbf1170d076cfdd86e0109c5bba114baa1a" + integrity sha512-ByHSTQvHLkWE9Ir5+lGbVOXhxX10fbprhLvdg96wedFZb4NDekDPxVKv5Fwmio+QcMlkkNfuK+5W1peQ5CUhZg== + dependencies: + loader-utils "^2.0.0" + schema-utils "^2.6.6" + sumchecker@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/sumchecker/-/sumchecker-3.0.1.tgz#6377e996795abb0b6d348e9b3e1dfb24345a8e42" @@ -12274,6 +12817,15 @@ terser@^4.1.2: source-map "~0.6.1" source-map-support "~0.5.12" +terser@^4.6.3: + version "4.8.0" + resolved "https://registry.yarnpkg.com/terser/-/terser-4.8.0.tgz#63056343d7c70bb29f3af665865a46fe03a0df17" + integrity sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw== + dependencies: + commander "^2.20.0" + source-map "~0.6.1" + source-map-support "~0.5.12" + test-exclude@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" @@ -12518,6 +13070,11 @@ truncate-utf8-bytes@^1.0.0: dependencies: utf8-byte-length "^1.0.1" +tslib@^1.10.0: + version "1.13.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043" + integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q== + tslib@^1.8.1: version "1.11.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.11.1.tgz#eb15d128827fbee2841549e171f45ed338ac7e35" @@ -12659,6 +13216,11 @@ union-value@^1.0.0: is-extendable "^0.1.1" set-value "^2.0.1" +uniq@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff" + integrity sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8= + unique-filename@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230" @@ -12784,6 +13346,15 @@ urix@^0.1.0: resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= +url-loader@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/url-loader/-/url-loader-4.1.0.tgz#c7d6b0d6b0fccd51ab3ffc58a78d32b8d89a7be2" + integrity sha512-IzgAAIC8wRrg6NYkFIJY09vtktQcsvU8V6HhtQj9PTefbYImzLB1hufqo4m+RyM5N3mLx5BqJKccgxJS+W3kqw== + dependencies: + loader-utils "^2.0.0" + mime-types "^2.1.26" + schema-utils "^2.6.5" + url-parse-lax@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-1.0.0.tgz#7af8f303645e9bd79a272e7a14ac68bc0609da73" @@ -12847,6 +13418,14 @@ util-deprecate@^1.0.1, util-deprecate@~1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= +util.promisify@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/util.promisify/-/util.promisify-1.0.0.tgz#440f7165a459c9a16dc145eb8e72f35687097030" + integrity sha512-i+6qA2MPhvoKLuxnJNpXAGhg7HphQOSUq2LKMZD0m15EiskXUkMvKdF4Uui0WYeCUGea+o2cw/ZuwehtfsrNkA== + dependencies: + define-properties "^1.1.2" + object.getownpropertydescriptors "^2.0.3" + util@0.10.3: version "0.10.3" resolved "https://registry.yarnpkg.com/util/-/util-0.10.3.tgz#7afb1afe50805246489e3db7fe0ed379336ac0f9" @@ -12868,6 +13447,11 @@ util@~0.10.3: dependencies: inherits "2.0.3" +utila@^0.4.0, utila@~0.4: + version "0.4.0" + resolved "https://registry.yarnpkg.com/utila/-/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c" + integrity sha1-ihagXURWV6Oupe7MWxKk+lN5dyw= + utils-merge@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" @@ -12878,6 +13462,11 @@ uuid@^3.0.0, uuid@^3.0.1, uuid@^3.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA== +uuid@^3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" + integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== + v8-compile-cache@2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.0.3.tgz#00f7494d2ae2b688cfe2899df6ed2c54bef91dbe" @@ -12888,6 +13477,11 @@ v8-compile-cache@^2.0.3: resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz#e14de37b31a6d194f5690d67efc4e7f6fc6ab30e" integrity sha512-usZBT3PW+LOjM25wbqIlZwPeJV+3OSz3M1k1Ws8snlW39dZyYL9lOGC5FgPVHfk0jKmjiDV8Z0mIbVQPiwFs7g== +v8-compile-cache@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.1.1.tgz#54bc3cdd43317bca91e35dcaf305b1a7237de745" + integrity sha512-8OQ9CL+VWyt3JStj7HX7/ciTL2V3Rl1Wf5OL+SNTm0yK1KvtReVulksyeRnCANHHuUxHlQig+JJDlUhBt1NQDQ== + v8-to-istanbul@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-4.0.1.tgz#d6a2a3823b8ff49bdf2167ff2a45d82dff81d02f" @@ -12998,6 +13592,13 @@ warning@^4.0.2: dependencies: loose-envify "^1.0.0" +watchpack-chokidar2@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/watchpack-chokidar2/-/watchpack-chokidar2-2.0.0.tgz#9948a1866cbbd6cb824dea13a7ed691f6c8ddff0" + integrity sha512-9TyfOyN/zLUbA288wZ8IsMZ+6cbzvsNyEzSBp6e/zkifi6xxbl8SmQ/CxQq32k8NNqrdVEVUVSEf56L4rQ/ZxA== + dependencies: + chokidar "^2.1.8" + watchpack@1.6.1, watchpack@^1.6.1: version "1.6.1" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.6.1.tgz#280da0a8718592174010c078c7585a74cd8cd0e2" @@ -13007,6 +13608,17 @@ watchpack@1.6.1, watchpack@^1.6.1: graceful-fs "^4.1.2" neo-async "^2.5.0" +watchpack@^1.7.4: + version "1.7.4" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.7.4.tgz#6e9da53b3c80bb2d6508188f5b200410866cd30b" + integrity sha512-aWAgTW4MoSJzZPAicljkO1hsi1oKj/RRq/OJQh2PKI2UKL04c2Bs+MBOB+BBABHTXJpf9mCwHN7ANCvYsvY2sg== + dependencies: + graceful-fs "^4.1.2" + neo-async "^2.5.0" + optionalDependencies: + chokidar "^3.4.1" + watchpack-chokidar2 "^2.0.0" + wbuf@^1.1.0, wbuf@^1.7.3: version "1.7.3" resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.3.tgz#c1d8d149316d3ea852848895cb6a0bfe887b87df" @@ -13093,6 +13705,23 @@ webpack-cli@^3.3.11: v8-compile-cache "2.0.3" yargs "13.2.4" +webpack-cli@^3.3.12: + version "3.3.12" + resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-3.3.12.tgz#94e9ada081453cd0aa609c99e500012fd3ad2d4a" + integrity sha512-NVWBaz9k839ZH/sinurM+HcDvJOTXwSjYp1ku+5XKeOC03z8v5QitnK/x+lAxGXFyhdayoIf/GOpv85z3/xPag== + dependencies: + chalk "^2.4.2" + cross-spawn "^6.0.5" + enhanced-resolve "^4.1.1" + findup-sync "^3.0.0" + global-modules "^2.0.0" + import-local "^2.0.0" + interpret "^1.4.0" + loader-utils "^1.4.0" + supports-color "^6.1.0" + v8-compile-cache "^2.1.1" + yargs "^13.3.2" + webpack-dev-middleware@^3.7.2: version "3.7.2" resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-3.7.2.tgz#0019c3db716e3fa5cecbf64f2ab88a74bab331f3" @@ -13143,6 +13772,45 @@ webpack-dev-server@^3.10.3: ws "^6.2.1" yargs "12.0.5" +webpack-dev-server@^3.11.0: + version "3.11.0" + resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-3.11.0.tgz#8f154a3bce1bcfd1cc618ef4e703278855e7ff8c" + integrity sha512-PUxZ+oSTxogFQgkTtFndEtJIPNmml7ExwufBZ9L2/Xyyd5PnOL5UreWe5ZT7IU25DSdykL9p1MLQzmLh2ljSeg== + dependencies: + ansi-html "0.0.7" + bonjour "^3.5.0" + chokidar "^2.1.8" + compression "^1.7.4" + connect-history-api-fallback "^1.6.0" + debug "^4.1.1" + del "^4.1.1" + express "^4.17.1" + html-entities "^1.3.1" + http-proxy-middleware "0.19.1" + import-local "^2.0.0" + internal-ip "^4.3.0" + ip "^1.1.5" + is-absolute-url "^3.0.3" + killable "^1.0.1" + loglevel "^1.6.8" + opn "^5.5.0" + p-retry "^3.0.1" + portfinder "^1.0.26" + schema-utils "^1.0.0" + selfsigned "^1.10.7" + semver "^6.3.0" + serve-index "^1.9.1" + sockjs "0.3.20" + sockjs-client "1.4.0" + spdy "^4.0.2" + strip-ansi "^3.0.1" + supports-color "^6.1.0" + url "^0.11.0" + webpack-dev-middleware "^3.7.2" + webpack-log "^2.0.0" + ws "^6.2.1" + yargs "^13.3.2" + webpack-log@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/webpack-log/-/webpack-log-2.0.0.tgz#5b7928e0637593f119d32f6227c1e0ac31e1b47f" @@ -13188,6 +13856,42 @@ webpack@^4.41.2, webpack@^4.43.0: watchpack "^1.6.1" webpack-sources "^1.4.1" +webpack@^4.44.1: + version "4.44.1" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.44.1.tgz#17e69fff9f321b8f117d1fda714edfc0b939cc21" + integrity sha512-4UOGAohv/VGUNQJstzEywwNxqX417FnjZgZJpJQegddzPmTvph37eBIRbRTfdySXzVtJXLJfbMN3mMYhM6GdmQ== + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-module-context" "1.9.0" + "@webassemblyjs/wasm-edit" "1.9.0" + "@webassemblyjs/wasm-parser" "1.9.0" + acorn "^6.4.1" + ajv "^6.10.2" + ajv-keywords "^3.4.1" + chrome-trace-event "^1.0.2" + enhanced-resolve "^4.3.0" + eslint-scope "^4.0.3" + json-parse-better-errors "^1.0.2" + loader-runner "^2.4.0" + loader-utils "^1.2.3" + memory-fs "^0.4.1" + micromatch "^3.1.10" + mkdirp "^0.5.3" + neo-async "^2.6.1" + node-libs-browser "^2.2.1" + schema-utils "^1.0.0" + tapable "^1.1.3" + terser-webpack-plugin "^1.4.3" + watchpack "^1.7.4" + webpack-sources "^1.4.1" + +websocket-driver@0.6.5: + version "0.6.5" + resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.6.5.tgz#5cb2556ceb85f4373c6d8238aa691c8454e13a36" + integrity sha1-XLJVbOuF9Dc8bYI4qmkchFThOjY= + dependencies: + websocket-extensions ">=0.1.1" + websocket-driver@>=0.5.1: version "0.7.3" resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.3.tgz#a2d4e0d4f4f116f1e6297eba58b05d430100e9f9" @@ -13467,6 +14171,14 @@ yargs-parser@^13.1.0: camelcase "^5.0.0" decamelize "^1.2.0" +yargs-parser@^13.1.2: + version "13.1.2" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38" + integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + yargs-parser@^18.1.1: version "18.1.2" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.2.tgz#2f482bea2136dbde0861683abea7756d30b504f1" @@ -13527,6 +14239,22 @@ yargs@15.3.1, yargs@^15.3.1, yargs@~15.3.0: y18n "^4.0.0" yargs-parser "^18.1.1" +yargs@^13.3.2: + version "13.3.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" + integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw== + dependencies: + cliui "^5.0.0" + find-up "^3.0.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^3.0.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^13.1.2" + yauzl@2.10.0: version "2.10.0" resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"