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:
E-Liang Tan 2020-08-21 02:06:28 +08:00 committed by GitHub
parent c641b611c4
commit 2bea3fb0b8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
58 changed files with 8946 additions and 19 deletions

View File

@ -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

3
.gitignore vendored
View File

@ -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

View File

@ -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

View File

@ -0,0 +1,3 @@
# Experimental React Concurrent Mode Profiler
- Deployed at: https://react-scheduling-profiler.vercel.app

View File

@ -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"
}
}

View File

@ -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} />;
}
}

View File

@ -0,0 +1,7 @@
.CanvasPage {
position: absolute;
top: 0.5rem;
bottom: 0.5rem;
left: 0.5rem;
right: 0.5rem;
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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>
);
};

View File

@ -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;
}

View File

@ -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

View File

@ -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

View File

@ -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;

View 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;
}
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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,
);
}
}
}

View 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;
}
}
}

View File

@ -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',
});

View File

@ -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';

View 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});
});
});
});

View 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),
};
}
}

View 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;
}

View File

@ -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;
}

View File

@ -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;
}
}
}

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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,
});

View File

@ -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};

View File

@ -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]);
}

View File

@ -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;
}

View File

@ -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>,
);

View File

@ -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,
|};

View 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
});

View File

@ -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);

View File

@ -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;
}

View File

@ -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);
});
};

View 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;
}

View File

@ -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,
);
}
}

View 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);
}
}

View 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;
}
}
}

View File

@ -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);
}
}

View 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,
),
};
}
}

View File

@ -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),
);
}
}

View 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}});
});
});

View File

@ -0,0 +1,13 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
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;

View File

@ -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);
}

View File

@ -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';

View File

@ -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;
}

View 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]);
}

View File

@ -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));
}

View 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};
}

View File

@ -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;

760
yarn.lock

File diff suppressed because it is too large Load Diff