[DevTools] front-end for profiling event stack (#24805)

* [DevTools] front-end for profiling event stack

Adds a side-bar to the profiling tab. Users can now select an update event, and are
shown the callstack from the originating component. When a source path is available
there is now UI to jump to source.

Add FB enabled feature flag: enableProfilerComponentTree for the side-bar.

resolves #24170
This commit is contained in:
Blake Friedman
2022-06-28 22:15:24 +01:00
committed by GitHub
parent f86b06c71d
commit 51b6878ee9
16 changed files with 346 additions and 48 deletions
+5
View File
@@ -245,6 +245,10 @@ function createPanelIfReactLoaded() {
}
};
const viewSourceLineFunction = (url, line) => {
chrome.devtools.panels.openResource(url, line);
};
let debugIDCounter = 0;
// For some reason in Firefox, chrome.runtime.sendMessage() from a content script
@@ -381,6 +385,7 @@ function createPanelIfReactLoaded() {
warnIfUnsupportedVersionDetected: true,
viewAttributeSourceFunction,
viewElementSourceFunction,
viewSourceLineFunction,
}),
);
};
@@ -11,6 +11,7 @@ import {
getDisplayName,
getDisplayNameForReactElement,
} from 'react-devtools-shared/src/utils';
import {stackToComponentSources} from 'react-devtools-shared/src/devtools/utils';
import {
format,
formatWithStyles,
@@ -53,6 +54,23 @@ describe('utils', () => {
const FauxComponent = {name: {}};
expect(getDisplayName(FauxComponent, 'Fallback')).toEqual('Fallback');
});
it('should parse a component stack trace', () => {
expect(
stackToComponentSources(`
at Foobar (http://localhost:3000/static/js/bundle.js:103:74)
at a
at header
at div
at App`),
).toEqual([
['Foobar', ['http://localhost:3000/static/js/bundle.js', 103, 74]],
['a', null],
['header', null],
['div', null],
['App', null],
]);
});
});
describe('getDisplayNameForReactElement', () => {
@@ -19,6 +19,7 @@ export const enableNamedHooksFeature = true;
export const enableProfilerChangedHookIndices = true;
export const enableStyleXFeatures = true;
export const isInternalFacebookBuild = true;
export const enableProfilerComponentTree = true;
/************************************************************************
* Do not edit the code below.
@@ -19,6 +19,7 @@ export const enableNamedHooksFeature = true;
export const enableProfilerChangedHookIndices = true;
export const enableStyleXFeatures = false;
export const isInternalFacebookBuild = false;
export const enableProfilerComponentTree = false;
/************************************************************************
* Do not edit the code below.
@@ -19,3 +19,4 @@ export const enableNamedHooksFeature = true;
export const enableProfilerChangedHookIndices = true;
export const enableStyleXFeatures = false;
export const isInternalFacebookBuild = false;
export const enableProfilerComponentTree = false;
@@ -19,6 +19,7 @@ export const enableNamedHooksFeature = true;
export const enableProfilerChangedHookIndices = true;
export const enableStyleXFeatures = true;
export const isInternalFacebookBuild = true;
export const enableProfilerComponentTree = true;
/************************************************************************
* Do not edit the code below.
@@ -19,6 +19,7 @@ export const enableNamedHooksFeature = true;
export const enableProfilerChangedHookIndices = true;
export const enableStyleXFeatures = false;
export const isInternalFacebookBuild = false;
export const enableProfilerComponentTree = false;
/************************************************************************
* Do not edit the code below.
+25
View File
@@ -185,3 +185,28 @@ export function smartStringify(value: any) {
return JSON.stringify(value);
}
// [url, row, column]
export type Stack = [string, number, number];
const STACK_DELIMETER = /\n\s+at /;
const STACK_SOURCE_LOCATION = /([^\s]+) \((.+):(.+):(.+)\)/;
export function stackToComponentSources(
stack: string,
): Array<[string, ?Stack]> {
const out = [];
stack
.split(STACK_DELIMETER)
.slice(1)
.forEach(entry => {
const match = STACK_SOURCE_LOCATION.exec(entry);
if (match) {
const [, component, url, row, column] = match;
out.push([component, [url, parseInt(row, 10), parseInt(column, 10)]]);
} else {
out.push([entry, null]);
}
});
return out;
}
@@ -0,0 +1,21 @@
/**
* 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';
import type {ViewSourceLine} from 'react-devtools-shared/src/devtools/views/DevTools';
export type Context = {|
viewSourceLineFunction: ViewSourceLine | null,
|};
const ViewSourceContext = createContext<Context>(((null: any): Context));
ViewSourceContext.displayName = 'ViewSourceContext';
export default ViewSourceContext;
+63 -46
View File
@@ -27,6 +27,7 @@ import TabBar from './TabBar';
import {SettingsContextController} from './Settings/SettingsContext';
import {TreeContextController} from './Components/TreeContext';
import ViewElementSourceContext from './Components/ViewElementSourceContext';
import ViewSourceContext from './Components/ViewSourceContext';
import FetchFileWithCachingContext from './Components/FetchFileWithCachingContext';
import HookNamesModuleLoaderContext from 'react-devtools-shared/src/devtools/views/Components/HookNamesModuleLoaderContext';
import {ProfilerContextController} from './Profiler/ProfilerContext';
@@ -57,6 +58,7 @@ export type ViewElementSource = (
id: number,
inspectedElement: InspectedElement,
) => void;
export type ViewSourceLine = (url: string, row: number, column: number) => void;
export type ViewAttributeSource = (
id: number,
path: Array<string | number>,
@@ -77,6 +79,7 @@ export type Props = {|
warnIfUnsupportedVersionDetected?: boolean,
viewAttributeSourceFunction?: ?ViewAttributeSource,
viewElementSourceFunction?: ?ViewElementSource,
viewSourceLineFunction?: ?ViewSourceLine,
readOnly?: boolean,
hideSettings?: boolean,
hideToggleErrorAction?: boolean,
@@ -136,6 +139,7 @@ export default function DevTools({
warnIfUnsupportedVersionDetected = false,
viewAttributeSourceFunction,
viewElementSourceFunction,
viewSourceLineFunction,
readOnly,
hideSettings,
hideToggleErrorAction,
@@ -199,6 +203,15 @@ export default function DevTools({
[canViewElementSourceFunction, viewElementSourceFunction],
);
const viewSource = useMemo(
() => ({
viewSourceLineFunction: viewSourceLineFunction || null,
// todo(blakef): Add inspect(...) method here and remove viewElementSource
// to consolidate source code inspection.
}),
[viewSourceLineFunction],
);
const contextMenu = useMemo(
() => ({
isEnabledForInspectedElement: enabledInspectedElementContextMenu,
@@ -267,55 +280,59 @@ export default function DevTools({
componentsPortalContainer={componentsPortalContainer}
profilerPortalContainer={profilerPortalContainer}>
<ViewElementSourceContext.Provider value={viewElementSource}>
<HookNamesModuleLoaderContext.Provider
value={hookNamesModuleLoaderFunction || null}>
<FetchFileWithCachingContext.Provider
value={fetchFileWithCaching || null}>
<TreeContextController>
<ProfilerContextController>
<TimelineContextController>
<ThemeProvider>
<div
className={styles.DevTools}
ref={devToolsRef}
data-react-devtools-portal-root={true}>
{showTabBar && (
<div className={styles.TabBar}>
<ReactLogo />
<span className={styles.DevToolsVersion}>
{process.env.DEVTOOLS_VERSION}
</span>
<div className={styles.Spacer} />
<TabBar
currentTab={tab}
id="DevTools"
selectTab={selectTab}
tabs={tabs}
type="navigation"
<ViewSourceContext.Provider value={viewSource}>
<HookNamesModuleLoaderContext.Provider
value={hookNamesModuleLoaderFunction || null}>
<FetchFileWithCachingContext.Provider
value={fetchFileWithCaching || null}>
<TreeContextController>
<ProfilerContextController>
<TimelineContextController>
<ThemeProvider>
<div
className={styles.DevTools}
ref={devToolsRef}
data-react-devtools-portal-root={true}>
{showTabBar && (
<div className={styles.TabBar}>
<ReactLogo />
<span className={styles.DevToolsVersion}>
{process.env.DEVTOOLS_VERSION}
</span>
<div className={styles.Spacer} />
<TabBar
currentTab={tab}
id="DevTools"
selectTab={selectTab}
tabs={tabs}
type="navigation"
/>
</div>
)}
<div
className={styles.TabContent}
hidden={tab !== 'components'}>
<Components
portalContainer={
componentsPortalContainer
}
/>
</div>
<div
className={styles.TabContent}
hidden={tab !== 'profiler'}>
<Profiler
portalContainer={profilerPortalContainer}
/>
</div>
)}
<div
className={styles.TabContent}
hidden={tab !== 'components'}>
<Components
portalContainer={componentsPortalContainer}
/>
</div>
<div
className={styles.TabContent}
hidden={tab !== 'profiler'}>
<Profiler
portalContainer={profilerPortalContainer}
/>
</div>
</div>
</ThemeProvider>
</TimelineContextController>
</ProfilerContextController>
</TreeContextController>
</FetchFileWithCachingContext.Provider>
</HookNamesModuleLoaderContext.Provider>
</ThemeProvider>
</TimelineContextController>
</ProfilerContextController>
</TreeContextController>
</FetchFileWithCachingContext.Provider>
</HookNamesModuleLoaderContext.Provider>
</ViewSourceContext.Provider>
</ViewElementSourceContext.Provider>
</SettingsContextController>
<UnsupportedBridgeProtocolDialog />
@@ -17,6 +17,7 @@ import CommitFlamegraph from './CommitFlamegraph';
import CommitRanked from './CommitRanked';
import RootSelector from './RootSelector';
import {Timeline} from 'react-devtools-timeline/src/Timeline';
import SidebarEventInfo from './SidebarEventInfo';
import RecordToggle from './RecordToggle';
import ReloadAndProfileButton from './ReloadAndProfileButton';
import ProfilingImportExportButtons from './ProfilingImportExportButtons';
@@ -33,6 +34,7 @@ import {SettingsModalContextController} from 'react-devtools-shared/src/devtools
import portaledContent from '../portaledContent';
import {StoreContext} from '../context';
import {TimelineContext} from 'react-devtools-timeline/src/TimelineContext';
import {enableProfilerComponentTree} from 'react-devtools-feature-flags';
import styles from './Profiler.css';
@@ -55,6 +57,8 @@ function Profiler(_: {||}) {
const {supportsTimeline} = useContext(StoreContext);
const isLegacyProfilerSelected = selectedTabID !== 'timeline';
const isRightColumnVisible =
isLegacyProfilerSelected || enableProfilerComponentTree;
let view = null;
if (didRecordCommits || selectedTabID === 'timeline') {
@@ -102,6 +106,9 @@ function Profiler(_: {||}) {
}
}
break;
case 'timeline':
sidebar = <SidebarEventInfo />;
break;
default:
break;
}
@@ -145,7 +152,7 @@ function Profiler(_: {||}) {
<ModalDialog />
</div>
</div>
{isLegacyProfilerSelected && (
{isRightColumnVisible && (
<div className={styles.RightColumn}>{sidebar}</div>
)}
<SettingsModal />
@@ -0,0 +1,70 @@
.Toolbar {
height: 2.25rem;
padding: 0 0.5rem;
flex: 0 0 auto;
display: flex;
align-items: center;
border-bottom: 1px solid var(--color-border);
}
.Content {
padding: 0.5rem;
user-select: none;
overflow: auto;
}
.List {
list-style: none;
margin: 0;
padding: 0;
}
.ListItem {
margin: 0;
}
.Label {
display: flex;
justify-content: space-between;
font-weight: bold;
}
[data-source="true"]:hover .Label > .Button {
background-color: var(--color-background-hover);
}
.Value {
font-family: var(--font-family-monospace);
font-size: var(--font-size-monospace-normal);
}
.NothingSelected {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--color-dim);
}
.Button {
display: flex;
flex: 1;
max-width: 95%;
overflow: hidden;
text-overflow: ellipsis;
}
[data-source="true"] .Button {
cursor: pointer;
}
.Button > span {
display: block;
text-align: left;
}
.Source {
}
@@ -0,0 +1,86 @@
/**
* 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 * as React from 'react';
import {isStateUpdateEvent} from 'react-devtools-timeline/src/utils/flow';
import Button from '../Button';
import ButtonIcon from '../ButtonIcon';
import ViewSourceContext from '../Components/ViewSourceContext';
import {useContext, useMemo} from 'react';
import {ProfilerContext} from './ProfilerContext';
import {stackToComponentSources} from 'react-devtools-shared/src/devtools/utils';
import styles from './SidebarEventInfo.css';
export type Props = {||};
export default function SidebarEventInfo(_: Props) {
const {profilingData, selectedCommitIndex} = useContext(ProfilerContext);
const {viewSourceLineFunction} = useContext(ViewSourceContext);
const {stack} = useMemo(() => {
if (
selectedCommitIndex == null ||
profilingData == null ||
profilingData.timelineData.length === 0
) {
return {};
}
const {schedulingEvents} = profilingData.timelineData[0];
const event = schedulingEvents[selectedCommitIndex];
if (!isStateUpdateEvent(event)) {
return {};
}
let componentStack = null;
if (event.componentStack) {
componentStack = stackToComponentSources(event.componentStack);
}
return {
stack: componentStack,
};
}, [profilingData, selectedCommitIndex]);
let components;
if (stack) {
components = stack.map(([displayName, source], index) => {
const hasSource = source != null;
const onClick = () => {
if (viewSourceLineFunction != null && source != null) {
viewSourceLineFunction(...source);
}
};
return (
<li key={index} className={styles.ListItem} data-source={hasSource}>
<label className={styles.Label}>
<Button className={styles.Button} onClick={onClick}>
{displayName}
</Button>
{hasSource && (
<ButtonIcon className={styles.Source} type="view-source" />
)}
</label>
</li>
);
});
}
return (
<>
<div className={styles.Toolbar}>Event Component Tree</div>
<div className={styles.Content} tabIndex={0}>
<ol className={styles.List}>{components}</ol>
</div>
</>
);
}
+6
View File
@@ -63,6 +63,7 @@ import useContextMenu from 'react-devtools-shared/src/devtools/ContextMenu/useCo
import {getBatchRange} from './utils/getBatchRange';
import {MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL} from './view-base/constants';
import {TimelineSearchContext} from './TimelineSearchContext';
import {ProfilerContext} from 'react-devtools-shared/src/devtools/views/Profiler/ProfilerContext';
import styles from './CanvasPage.css';
@@ -528,6 +529,8 @@ function AutoSizedCanvas({
ref: canvasRef,
});
const {selectCommitIndex} = useContext(ProfilerContext);
useEffect(() => {
const {current: userTimingMarksView} = userTimingMarksViewRef;
if (userTimingMarksView) {
@@ -563,6 +566,9 @@ function AutoSizedCanvas({
});
}
};
schedulingEventsView.onClick = (schedulingEvent, eventIndex) => {
selectCommitIndex(eventIndex);
};
}
const {current: suspenseEventsView} = suspenseEventsViewRef;
@@ -9,6 +9,7 @@
import type {SchedulingEvent, TimelineData} from '../types';
import type {
ClickInteraction,
Interaction,
MouseMoveInteraction,
Rect,
@@ -45,6 +46,9 @@ export class SchedulingEventsView extends View {
_hoveredEvent: SchedulingEvent | null = null;
onHover: ((event: SchedulingEvent | null) => void) | null = null;
onClick:
| ((event: SchedulingEvent | null, eventIndex: number | null) => void)
| null = null;
constructor(surface: Surface, frame: Rect, profilerData: TimelineData) {
super(surface, frame);
@@ -243,7 +247,7 @@ export class SchedulingEventsView extends View {
timestamp - eventTimestampAllowance <= hoverTimestamp &&
hoverTimestamp <= timestamp + eventTimestampAllowance
) {
this.currentCursor = 'context-menu';
this.currentCursor = 'pointer';
viewRefs.hoveredView = this;
onHover(event);
return;
@@ -253,11 +257,32 @@ export class SchedulingEventsView extends View {
onHover(null);
}
/**
* @private
*/
_handleClick(interaction: ClickInteraction) {
const {onClick} = this;
if (onClick) {
const {
_profilerData: {schedulingEvents},
} = this;
const eventIndex = schedulingEvents.findIndex(
event => event === this._hoveredEvent,
);
// onHover is going to take care of all the difficult logic here of
// figuring out which event when they're proximity is close.
onClick(this._hoveredEvent, eventIndex >= 0 ? eventIndex : null);
}
}
handleInteraction(interaction: Interaction, viewRefs: ViewRefs) {
switch (interaction.type) {
case 'mousemove':
this._handleMouseMove(interaction, viewRefs);
break;
case 'click':
this._handleClick(interaction);
break;
}
}
}
+13
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
*/
import type {SchedulingEvent} from '../types';
export function isStateUpdateEvent(event: SchedulingEvent): boolean %checks {
return event.type === 'schedule-state-update';
}