Import React Concurrent Mode Profiler (#19634)
Co-authored-by: Brian Vaughn <bvaughn@fb.com> Co-authored-by: Kartik Choudhary <kartikc.918@gmail.com>
This commit is contained in:
parent
c641b611c4
commit
2bea3fb0b8
|
@ -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
|
||||
packages/react-devtools-shell/dist
|
||||
packages/react-devtools-scheduling-profiler/dist
|
||||
packages/react-devtools-scheduling-profiler/static
|
|
@ -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
|
||||
packages/react-devtools-shell/dist
|
||||
packages/react-devtools-scheduling-profiler/dist
|
|
@ -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
|
||||
packages/react-devtools-shell/dist
|
||||
packages/react-devtools-scheduling-profiler/dist
|
||||
packages/react-devtools-scheduling-profiler/static
|
|
@ -0,0 +1,3 @@
|
|||
# Experimental React Concurrent Mode Profiler
|
||||
|
||||
- Deployed at: https://react-scheduling-profiler.vercel.app
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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<ReactProfilerData | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
if (profilerData) {
|
||||
return <CanvasPage profilerData={profilerData} />;
|
||||
} else {
|
||||
return <ImportPage onDataImported={setProfilerData} />;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
.CanvasPage {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
bottom: 0.5rem;
|
||||
left: 0.5rem;
|
||||
right: 0.5rem;
|
||||
}
|
|
@ -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 (
|
||||
<div
|
||||
className={styles.CanvasPage}
|
||||
style={{backgroundColor: COLORS.BACKGROUND}}>
|
||||
<AutoSizer>
|
||||
{({height, width}: {height: number, width: number}) => (
|
||||
<AutoSizedCanvas data={profilerData} height={height} width={width} />
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLCanvasElement | null>(null);
|
||||
|
||||
const [isContextMenuShown, setIsContextMenuShown] = useState<boolean>(false);
|
||||
const [mouseLocation, setMouseLocation] = useState<Point>(zeroPoint); // DOM coordinates
|
||||
const [
|
||||
hoveredEvent,
|
||||
setHoveredEvent,
|
||||
] = useState<ReactHoverContextInfo | null>(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<HorizontalPanAndZoomView[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
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<ContextMenuContextData>({
|
||||
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 (
|
||||
<Fragment>
|
||||
<canvas ref={canvasRef} height={height} width={width} />
|
||||
<ContextMenu id={CONTEXT_MENU_ID}>
|
||||
{(contextData: ContextMenuContextData) => {
|
||||
if (contextData.hoveredEvent == null) {
|
||||
return null;
|
||||
}
|
||||
const {
|
||||
event,
|
||||
flamechartStackFrame,
|
||||
measure,
|
||||
} = contextData.hoveredEvent;
|
||||
return (
|
||||
<Fragment>
|
||||
{event !== null && (
|
||||
<ContextMenuItem
|
||||
onClick={() => copy(event.componentName)}
|
||||
title="Copy component name">
|
||||
Copy component name
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{event !== null && (
|
||||
<ContextMenuItem
|
||||
onClick={() => copy(event.componentStack)}
|
||||
title="Copy component stack">
|
||||
Copy component stack
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{measure !== null && (
|
||||
<ContextMenuItem
|
||||
onClick={() =>
|
||||
zoomToBatch(
|
||||
contextData.data,
|
||||
measure,
|
||||
syncedHorizontalPanAndZoomViewsRef.current,
|
||||
)
|
||||
}
|
||||
title="Zoom to batch">
|
||||
Zoom to batch
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{measure !== null && (
|
||||
<ContextMenuItem
|
||||
onClick={() => copySummary(contextData.data, measure)}
|
||||
title="Copy summary">
|
||||
Copy summary
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{flamechartStackFrame !== null && (
|
||||
<ContextMenuItem
|
||||
onClick={() => copy(flamechartStackFrame.scriptUrl)}
|
||||
title="Copy file path">
|
||||
Copy file path
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{flamechartStackFrame !== null && (
|
||||
<ContextMenuItem
|
||||
onClick={() =>
|
||||
copy(
|
||||
`line ${flamechartStackFrame.locationLine ??
|
||||
''}, column ${flamechartStackFrame.locationColumn ??
|
||||
''}`,
|
||||
)
|
||||
}
|
||||
title="Copy location">
|
||||
Copy location
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}}
|
||||
</ContextMenu>
|
||||
{!isContextMenuShown && (
|
||||
<EventTooltip
|
||||
data={data}
|
||||
hoveredEvent={hoveredEvent}
|
||||
origin={mouseLocation}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export default CanvasPage;
|
|
@ -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;
|
||||
}
|
|
@ -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 <TooltipReactEvent event={event} tooltipRef={tooltipRef} />;
|
||||
} else if (measure !== null) {
|
||||
return (
|
||||
<TooltipReactMeasure
|
||||
data={data}
|
||||
measure={measure}
|
||||
tooltipRef={tooltipRef}
|
||||
/>
|
||||
);
|
||||
} else if (flamechartStackFrame !== null) {
|
||||
return (
|
||||
<TooltipFlamechartNode
|
||||
stackFrame={flamechartStackFrame}
|
||||
tooltipRef={tooltipRef}
|
||||
/>
|
||||
);
|
||||
} else if (userTimingMark !== null) {
|
||||
return (
|
||||
<TooltipUserTimingMark mark={userTimingMark} tooltipRef={tooltipRef} />
|
||||
);
|
||||
}
|
||||
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<typeof useRef>,
|
||||
}) => {
|
||||
const {
|
||||
name,
|
||||
timestamp,
|
||||
duration,
|
||||
scriptUrl,
|
||||
locationLine,
|
||||
locationColumn,
|
||||
} = stackFrame;
|
||||
return (
|
||||
<div className={styles.Tooltip} ref={tooltipRef}>
|
||||
{formatDuration(duration)}
|
||||
<span className={styles.FlamechartStackFrameName}>{name}</span>
|
||||
<div className={styles.DetailsGrid}>
|
||||
<div className={styles.DetailsGridLabel}>Timestamp:</div>
|
||||
<div>{formatTimestamp(timestamp)}</div>
|
||||
{scriptUrl && (
|
||||
<>
|
||||
<div className={styles.DetailsGridLabel}>Script URL:</div>
|
||||
<div className={styles.DetailsGridURL}>{scriptUrl}</div>
|
||||
</>
|
||||
)}
|
||||
{(locationLine !== undefined || locationColumn !== undefined) && (
|
||||
<>
|
||||
<div className={styles.DetailsGridLabel}>Location:</div>
|
||||
<div>
|
||||
line {locationLine}, column {locationColumn}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TooltipReactEvent = ({
|
||||
event,
|
||||
tooltipRef,
|
||||
}: {
|
||||
event: ReactEvent,
|
||||
tooltipRef: Return<typeof useRef>,
|
||||
}) => {
|
||||
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 (
|
||||
<div className={styles.Tooltip} ref={tooltipRef}>
|
||||
{componentName && (
|
||||
<span className={styles.ComponentName} style={{color}}>
|
||||
{trimmedString(componentName, 768)}
|
||||
</span>
|
||||
)}
|
||||
{label}
|
||||
<div className={styles.Divider} />
|
||||
<div className={styles.DetailsGrid}>
|
||||
<div className={styles.DetailsGridLabel}>Timestamp:</div>
|
||||
<div>{formatTimestamp(timestamp)}</div>
|
||||
{componentStack && (
|
||||
<Fragment>
|
||||
<div className={styles.DetailsGridLabel}>Component stack:</div>
|
||||
<pre className={styles.ComponentStack}>
|
||||
{formatComponentStack(componentStack)}
|
||||
</pre>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TooltipReactMeasure = ({
|
||||
data,
|
||||
measure,
|
||||
tooltipRef,
|
||||
}: {
|
||||
data: ReactProfilerData,
|
||||
measure: ReactMeasure,
|
||||
tooltipRef: Return<typeof useRef>,
|
||||
}) => {
|
||||
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 (
|
||||
<div className={styles.Tooltip} ref={tooltipRef}>
|
||||
{formatDuration(duration)}
|
||||
<span className={styles.ReactMeasureLabel}>{label}</span>
|
||||
<div className={styles.Divider} />
|
||||
<div className={styles.DetailsGrid}>
|
||||
<div className={styles.DetailsGridLabel}>Timestamp:</div>
|
||||
<div>{formatTimestamp(timestamp)}</div>
|
||||
<div className={styles.DetailsGridLabel}>Batch duration:</div>
|
||||
<div>{formatDuration(stopTime - startTime)}</div>
|
||||
<div className={styles.DetailsGridLabel}>
|
||||
Lane{lanes.length === 1 ? '' : 's'}:
|
||||
</div>
|
||||
<div>{lanes.join(', ')}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TooltipUserTimingMark = ({
|
||||
mark,
|
||||
tooltipRef,
|
||||
}: {
|
||||
mark: UserTimingMark,
|
||||
tooltipRef: Return<typeof useRef>,
|
||||
}) => {
|
||||
const {name, timestamp} = mark;
|
||||
return (
|
||||
<div className={styles.Tooltip} ref={tooltipRef}>
|
||||
<span className={styles.UserTimingLabel}>{name}</span>
|
||||
<div className={styles.Divider} />
|
||||
<div className={styles.DetailsGrid}>
|
||||
<div className={styles.DetailsGridLabel}>Timestamp:</div>
|
||||
<div>{formatTimestamp(timestamp)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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<HTMLInputElement>) => {
|
||||
const readFile = await readInputData(event.target.files[0]);
|
||||
processTimeline(JSON.parse(readFile));
|
||||
},
|
||||
[processTimeline],
|
||||
);
|
||||
|
||||
const upload = useRef(null);
|
||||
|
||||
return (
|
||||
<div className={style.App}>
|
||||
<div className={style.container}>
|
||||
<div className={style.card}>
|
||||
<div className={style.cardcontainer}>
|
||||
<div className={style.row}>
|
||||
<div className={style.column}>
|
||||
<img
|
||||
src={profilerBrowser}
|
||||
className={style.browserScreenshot}
|
||||
alt="logo"
|
||||
/>
|
||||
</div>
|
||||
<div className={style.columncontent}>
|
||||
<h2>React Concurrent Mode Profiler</h2>
|
||||
<hr />
|
||||
<p>
|
||||
Import a captured{' '}
|
||||
<a
|
||||
className={style.link}
|
||||
href="https://developers.google.com/web/tools/chrome-devtools/evaluate-performance">
|
||||
performance profile
|
||||
</a>{' '}
|
||||
from Chrome Devtools.
|
||||
<br />
|
||||
To zoom, scroll while holding down <kbd>Ctrl</kbd> or{' '}
|
||||
<kbd>Shift</kbd>
|
||||
</p>
|
||||
<p className={style.legendKey}>
|
||||
<svg height="20" width="20">
|
||||
<circle cx="10" cy="10" r="5" fill="#ff718e" />
|
||||
</svg>
|
||||
State Update Scheduled
|
||||
<br />
|
||||
<svg height="20" width="20">
|
||||
<circle cx="10" cy="10" r="5" fill="#9fc3f3" />
|
||||
</svg>
|
||||
State Update Scheduled
|
||||
<br />
|
||||
<svg height="20" width="20">
|
||||
<circle cx="10" cy="10" r="5" fill="#a6e59f" />
|
||||
</svg>
|
||||
Suspended
|
||||
</p>
|
||||
|
||||
<div className={style.buttongrp}>
|
||||
<label htmlFor="upload">
|
||||
<button
|
||||
className={style.ImportButton}
|
||||
onClick={() => upload.current && upload.current.click()}>
|
||||
Import
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
ref={upload}
|
||||
className={style.inputbtn}
|
||||
onChange={handleProfilerInput}
|
||||
accept="application/json"
|
||||
/>
|
||||
</label>
|
||||
<a href="https://github.com/MLH-Fellowship/scheduling-profiler-prototype">
|
||||
<button className={style.ViewSourceButton}>
|
||||
<span>Source </span>
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 8.0 KiB |
Binary file not shown.
After Width: | Height: | Size: 76 KiB |
|
@ -0,0 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
|
||||
<g fill="#61DAFB">
|
||||
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
|
||||
<circle cx="420.9" cy="296.5" r="45.7"/>
|
||||
<path d="M520.5 78.1z"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2.6 KiB |
|
@ -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;
|
378
packages/react-devtools-scheduling-profiler/src/content-views/FlamechartView.js
vendored
Normal file
378
packages/react-devtools-scheduling-profiler/src/content-views/FlamechartView.js
vendored
Normal file
|
@ -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<FlamechartStackFrame>;
|
||||
|
||||
_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;
|
||||
}
|
||||
}
|
||||
}
|
276
packages/react-devtools-scheduling-profiler/src/content-views/ReactEventsView.js
vendored
Normal file
276
packages/react-devtools-scheduling-profiler/src/content-views/ReactEventsView.js
vendored
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
318
packages/react-devtools-scheduling-profiler/src/content-views/ReactMeasuresView.js
vendored
Normal file
318
packages/react-devtools-scheduling-profiler/src/content-views/ReactMeasuresView.js
vendored
Normal file
|
@ -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<ReactLane, ReactMeasure[]>;
|
||||
|
||||
_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<ReactLane, ReactMeasure[]>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
163
packages/react-devtools-scheduling-profiler/src/content-views/TimeAxisMarkersView.js
vendored
Normal file
163
packages/react-devtools-scheduling-profiler/src/content-views/TimeAxisMarkersView.js
vendored
Normal file
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
230
packages/react-devtools-scheduling-profiler/src/content-views/UserTimingMarksView.js
vendored
Normal file
230
packages/react-devtools-scheduling-profiler/src/content-views/UserTimingMarksView.js
vendored
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
});
|
|
@ -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';
|
93
packages/react-devtools-scheduling-profiler/src/content-views/utils/__tests__/colors-test.js
vendored
Normal file
93
packages/react-devtools-scheduling-profiler/src/content-views/utils/__tests__/colors-test.js
vendored
Normal file
|
@ -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});
|
||||
});
|
||||
});
|
||||
});
|
113
packages/react-devtools-scheduling-profiler/src/content-views/utils/colors.js
vendored
Normal file
113
packages/react-devtools-scheduling-profiler/src/content-views/utils/colors.js
vendored
Normal file
|
@ -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<string, HslaColor>;
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
41
packages/react-devtools-scheduling-profiler/src/content-views/utils/positioning.js
vendored
Normal file
41
packages/react-devtools-scheduling-profiler/src/content-views/utils/positioning.js
vendored
Normal file
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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<RegistryContextType>(
|
||||
RegistryContext,
|
||||
);
|
||||
|
||||
const [state, setState] = useState(HIDDEN_STATE);
|
||||
|
||||
const bodyAccessorRef = useRef<HTMLDivElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement | null>(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 <div ref={bodyAccessorRef} />;
|
||||
} else {
|
||||
const container = containerRef.current;
|
||||
if (container !== null) {
|
||||
return createPortal(
|
||||
<div ref={menuRef} className={styles.ContextMenu}>
|
||||
{children(state.data)}
|
||||
</div>,
|
||||
container,
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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<RegistryContextType>(RegistryContext);
|
||||
|
||||
const handleClick: MouseEventHandler = event => {
|
||||
onClick();
|
||||
hideMenu();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.ContextMenuItem}
|
||||
onClick={handleClick}
|
||||
onTouchEnd={handleClick}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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<string, ShowFn>();
|
||||
const idToHideFnMap = new Map<string, HideFn>();
|
||||
|
||||
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<RegistryContextType>({
|
||||
hideMenu,
|
||||
showMenu,
|
||||
registerMenu,
|
||||
});
|
|
@ -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};
|
|
@ -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<T>({
|
||||
data,
|
||||
id,
|
||||
onChange,
|
||||
ref,
|
||||
}: {|
|
||||
data: T,
|
||||
id: string,
|
||||
onChange: OnChangeFn,
|
||||
ref: {+current: HTMLElement | null},
|
||||
|}) {
|
||||
const {showMenu} = useContext<RegistryContextType>(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]);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
|
@ -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, F: (...args: Array<any>) => R> = R;
|
||||
/** Get return type of a function. */
|
||||
export type Return<T> = 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<ReactEvent, 'type'>;
|
||||
|
||||
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<ReactProfilerData> | null,
|
||||
flamechartStackFrame: FlamechartStackFrame | null,
|
||||
userTimingMark: UserTimingMark | null,
|
||||
|};
|
File diff suppressed because it is too large
Load Diff
352
packages/react-devtools-scheduling-profiler/src/utils/__tests__/preprocessData-test.js
vendored
Normal file
352
packages/react-devtools-scheduling-profiler/src/utils/__tests__/preprocessData-test.js
vendored
Normal file
|
@ -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
|
||||
});
|
|
@ -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);
|
|
@ -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<ProcessorState, 'measureStack'>) {
|
||||
if (stack.length > 0) {
|
||||
const {type} = stack[stack.length - 1];
|
||||
return type;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getDepth(stack: $PropertyType<ProcessorState, 'measureStack'>) {
|
||||
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<ProcessorState, 'measureStack'>,
|
||||
) {
|
||||
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<ProcessorState, 'measureStack'>,
|
||||
) {
|
||||
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;
|
||||
}
|
|
@ -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<string> => {
|
||||
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);
|
||||
});
|
||||
};
|
|
@ -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<HTMLElement | null>(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;
|
||||
}
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
342
packages/react-devtools-scheduling-profiler/src/view-base/HorizontalPanAndZoomView.js
vendored
Normal file
342
packages/react-devtools-scheduling-profiler/src/view-base/HorizontalPanAndZoomView.js
vendored
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
311
packages/react-devtools-scheduling-profiler/src/view-base/ResizableSplitView.js
vendored
Normal file
311
packages/react-devtools-scheduling-profiler/src/view-base/ResizableSplitView.js
vendored
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
181
packages/react-devtools-scheduling-profiler/src/view-base/VerticalScrollView.js
vendored
Normal file
181
packages/react-devtools-scheduling-profiler/src/view-base/VerticalScrollView.js
vendored
Normal file
|
@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
272
packages/react-devtools-scheduling-profiler/src/view-base/__tests__/geometry-test.js
vendored
Normal file
272
packages/react-devtools-scheduling-profiler/src/view-base/__tests__/geometry-test.js
vendored
Normal file
|
@ -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}});
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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);
|
||||
}
|
|
@ -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';
|
|
@ -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;
|
||||
}
|
192
packages/react-devtools-scheduling-profiler/src/view-base/useCanvasInteraction.js
vendored
Normal file
192
packages/react-devtools-scheduling-profiler/src/view-base/useCanvasInteraction.js
vendored
Normal file
|
@ -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]);
|
||||
}
|
|
@ -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));
|
||||
}
|
91
packages/react-devtools-scheduling-profiler/src/view-base/utils/normalizeWheel.js
vendored
Normal file
91
packages/react-devtools-scheduling-profiler/src/view-base/utils/normalizeWheel.js
vendored
Normal file
|
@ -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};
|
||||
}
|
|
@ -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;
|
Loading…
Reference in New Issue