[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
Blake Friedman committed
Jun 28, 2022 at 22:15 UTC
2e1c8841e97923e7af50c5c5311e3724b7b6555d
16 files changed
+346
-48
packages/react-devtools-extensions/src/main.js
+5
@@ -245,6 +245,10 @@ function createPanelIfReactLoaded() {
245
}
246
};
247
248
+ const viewSourceLineFunction = (url, line) => {
249
+ chrome.devtools.panels.openResource(url, line);
250
+ };
251
+
252
let debugIDCounter = 0;
253
254
// For some reason in Firefox, chrome.runtime.sendMessage() from a content script
@@ -381,6 +385,7 @@ function createPanelIfReactLoaded() {
385
warnIfUnsupportedVersionDetected: true,
386
viewAttributeSourceFunction,
387
viewElementSourceFunction,
388
+ viewSourceLineFunction,
389
}),
390
);
391
};
packages/react-devtools-shared/src/__tests__/utils-test.js
+18
@@ -11,6 +11,7 @@ import {
11
getDisplayName,
12
getDisplayNameForReactElement,
13
} from 'react-devtools-shared/src/utils';
14
+import {stackToComponentSources} from 'react-devtools-shared/src/devtools/utils';
15
import {
16
format,
17
formatWithStyles,
@@ -53,6 +54,23 @@ describe('utils', () => {
54
const FauxComponent = {name: {}};
55
expect(getDisplayName(FauxComponent, 'Fallback')).toEqual('Fallback');
56
});
57
+
58
+ it('should parse a component stack trace', () => {
59
+ expect(
60
+ stackToComponentSources(`
61
+ at Foobar (http://localhost:3000/static/js/bundle.js:103:74)
62
+ at a
63
+ at header
64
+ at div
65
+ at App`),
66
+ ).toEqual([
67
+ ['Foobar', ['http://localhost:3000/static/js/bundle.js', 103, 74]],
68
+ ['a', null],
69
+ ['header', null],
70
+ ['div', null],
71
+ ['App', null],
72
+ ]);
73
+ });
74
});
75
76
describe('getDisplayNameForReactElement', () => {
packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-fb.js
+1
@@ -19,6 +19,7 @@ export const enableNamedHooksFeature = true;
19
export const enableProfilerChangedHookIndices = true;
20
export const enableStyleXFeatures = true;
21
export const isInternalFacebookBuild = true;
22
+export const enableProfilerComponentTree = true;
23
24
/************************************************************************
25
* Do not edit the code below.
packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-oss.js
+1
@@ -19,6 +19,7 @@ export const enableNamedHooksFeature = true;
19
export const enableProfilerChangedHookIndices = true;
20
export const enableStyleXFeatures = false;
21
export const isInternalFacebookBuild = false;
22
+export const enableProfilerComponentTree = false;
23
24
/************************************************************************
25
* Do not edit the code below.
packages/react-devtools-shared/src/config/DevToolsFeatureFlags.default.js
+1
@@ -19,3 +19,4 @@ export const enableNamedHooksFeature = true;
19
export const enableProfilerChangedHookIndices = true;
20
export const enableStyleXFeatures = false;
21
export const isInternalFacebookBuild = false;
22
+export const enableProfilerComponentTree = false;
packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-fb.js
+1
@@ -19,6 +19,7 @@ export const enableNamedHooksFeature = true;
19
export const enableProfilerChangedHookIndices = true;
20
export const enableStyleXFeatures = true;
21
export const isInternalFacebookBuild = true;
22
+export const enableProfilerComponentTree = true;
23
24
/************************************************************************
25
* Do not edit the code below.
packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-oss.js
+1
@@ -19,6 +19,7 @@ export const enableNamedHooksFeature = true;
19
export const enableProfilerChangedHookIndices = true;
20
export const enableStyleXFeatures = false;
21
export const isInternalFacebookBuild = false;
22
+export const enableProfilerComponentTree = false;
23
24
/************************************************************************
25
* Do not edit the code below.
packages/react-devtools-shared/src/devtools/utils.js
+25
@@ -185,3 +185,28 @@ export function smartStringify(value: any) {
185
186
return JSON.stringify(value);
187
}
188
+
189
+// [url, row, column]
190
+export type Stack = [string, number, number];
191
+
192
+const STACK_DELIMETER = /\n\s+at /;
193
+const STACK_SOURCE_LOCATION = /([^\s]+) \((.+):(.+):(.+)\)/;
194
+
195
+export function stackToComponentSources(
196
+ stack: string,
197
+): Array<[string, ?Stack]> {
198
+ const out = [];
199
+ stack
200
+ .split(STACK_DELIMETER)
201
+ .slice(1)
202
+ .forEach(entry => {
203
+ const match = STACK_SOURCE_LOCATION.exec(entry);
204
+ if (match) {
205
+ const [, component, url, row, column] = match;
206
+ out.push([component, [url, parseInt(row, 10), parseInt(column, 10)]]);
207
+ } else {
208
+ out.push([entry, null]);
209
+ }
210
+ });
211
+ return out;
212
+}
packages/react-devtools-shared/src/devtools/views/Components/ViewSourceContext.js
new
+21
@@ -0,0 +1,21 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+
10
+import {createContext} from 'react';
11
+
12
+import type {ViewSourceLine} from 'react-devtools-shared/src/devtools/views/DevTools';
13
+
14
+export type Context = {|
15
+ viewSourceLineFunction: ViewSourceLine | null,
16
+|};
17
+
18
+const ViewSourceContext = createContext<Context>(((null: any): Context));
19
+ViewSourceContext.displayName = 'ViewSourceContext';
20
+
21
+export default ViewSourceContext;
packages/react-devtools-shared/src/devtools/views/DevTools.js
+63
-46
@@ -27,6 +27,7 @@ import TabBar from './TabBar';
27
import {SettingsContextController} from './Settings/SettingsContext';
28
import {TreeContextController} from './Components/TreeContext';
29
import ViewElementSourceContext from './Components/ViewElementSourceContext';
30
+import ViewSourceContext from './Components/ViewSourceContext';
31
import FetchFileWithCachingContext from './Components/FetchFileWithCachingContext';
32
import HookNamesModuleLoaderContext from 'react-devtools-shared/src/devtools/views/Components/HookNamesModuleLoaderContext';
33
import {ProfilerContextController} from './Profiler/ProfilerContext';
@@ -57,6 +58,7 @@ export type ViewElementSource = (
58
id: number,
59
inspectedElement: InspectedElement,
60
) => void;
61
+export type ViewSourceLine = (url: string, row: number, column: number) => void;
62
export type ViewAttributeSource = (
63
id: number,
64
path: Array<string | number>,
@@ -77,6 +79,7 @@ export type Props = {|
79
warnIfUnsupportedVersionDetected?: boolean,
80
viewAttributeSourceFunction?: ?ViewAttributeSource,
81
viewElementSourceFunction?: ?ViewElementSource,
82
+ viewSourceLineFunction?: ?ViewSourceLine,
83
readOnly?: boolean,
84
hideSettings?: boolean,
85
hideToggleErrorAction?: boolean,
@@ -136,6 +139,7 @@ export default function DevTools({
139
warnIfUnsupportedVersionDetected = false,
140
viewAttributeSourceFunction,
141
viewElementSourceFunction,
142
+ viewSourceLineFunction,
143
readOnly,
144
hideSettings,
145
hideToggleErrorAction,
@@ -199,6 +203,15 @@ export default function DevTools({
203
[canViewElementSourceFunction, viewElementSourceFunction],
204
);
205
206
+ const viewSource = useMemo(
207
+ () => ({
208
+ viewSourceLineFunction: viewSourceLineFunction || null,
209
+ // todo(blakef): Add inspect(...) method here and remove viewElementSource
210
+ // to consolidate source code inspection.
211
+ }),
212
+ [viewSourceLineFunction],
213
+ );
214
+
215
const contextMenu = useMemo(
216
() => ({
217
isEnabledForInspectedElement: enabledInspectedElementContextMenu,
@@ -267,55 +280,59 @@ export default function DevTools({
280
componentsPortalContainer={componentsPortalContainer}
281
profilerPortalContainer={profilerPortalContainer}>
282
<ViewElementSourceContext.Provider value={viewElementSource}>
270
- <HookNamesModuleLoaderContext.Provider
271
- value={hookNamesModuleLoaderFunction || null}>
272
- <FetchFileWithCachingContext.Provider
273
- value={fetchFileWithCaching || null}>
274
- <TreeContextController>
275
- <ProfilerContextController>
276
- <TimelineContextController>
277
- <ThemeProvider>
278
- <div
279
- className={styles.DevTools}
280
- ref={devToolsRef}
281
- data-react-devtools-portal-root={true}>
282
- {showTabBar && (
283
- <div className={styles.TabBar}>
284
- <ReactLogo />
285
- <span className={styles.DevToolsVersion}>
286
- {process.env.DEVTOOLS_VERSION}
287
- </span>
288
- <div className={styles.Spacer} />
289
- <TabBar
290
- currentTab={tab}
291
- id="DevTools"
292
- selectTab={selectTab}
293
- tabs={tabs}
294
- type="navigation"
283
+ <ViewSourceContext.Provider value={viewSource}>
284
+ <HookNamesModuleLoaderContext.Provider
285
+ value={hookNamesModuleLoaderFunction || null}>
286
+ <FetchFileWithCachingContext.Provider
287
+ value={fetchFileWithCaching || null}>
288
+ <TreeContextController>
289
+ <ProfilerContextController>
290
+ <TimelineContextController>
291
+ <ThemeProvider>
292
+ <div
293
+ className={styles.DevTools}
294
+ ref={devToolsRef}
295
+ data-react-devtools-portal-root={true}>
296
+ {showTabBar && (
297
+ <div className={styles.TabBar}>
298
+ <ReactLogo />
299
+ <span className={styles.DevToolsVersion}>
300
+ {process.env.DEVTOOLS_VERSION}
301
+ </span>
302
+ <div className={styles.Spacer} />
303
+ <TabBar
304
+ currentTab={tab}
305
+ id="DevTools"
306
+ selectTab={selectTab}
307
+ tabs={tabs}
308
+ type="navigation"
309
+ />
310
+ </div>
311
+ )}
312
+ <div
313
+ className={styles.TabContent}
314
+ hidden={tab !== 'components'}>
315
+ <Components
316
+ portalContainer={
317
+ componentsPortalContainer
318
+ }
319
+ />
320
+ </div>
321
+ <div
322
+ className={styles.TabContent}
323
+ hidden={tab !== 'profiler'}>
324
+ <Profiler
325
+ portalContainer={profilerPortalContainer}
326
/>
327
</div>
297
- )}
298
- <div
299
- className={styles.TabContent}
300
- hidden={tab !== 'components'}>
301
- <Components
302
- portalContainer={componentsPortalContainer}
303
- />
304
- </div>
305
- <div
306
- className={styles.TabContent}
307
- hidden={tab !== 'profiler'}>
308
- <Profiler
309
- portalContainer={profilerPortalContainer}
310
- />
328
</div>
312
- </div>
313
- </ThemeProvider>
314
- </TimelineContextController>
315
- </ProfilerContextController>
316
- </TreeContextController>
317
- </FetchFileWithCachingContext.Provider>
318
- </HookNamesModuleLoaderContext.Provider>
329
+ </ThemeProvider>
330
+ </TimelineContextController>
331
+ </ProfilerContextController>
332
+ </TreeContextController>
333
+ </FetchFileWithCachingContext.Provider>
334
+ </HookNamesModuleLoaderContext.Provider>
335
+ </ViewSourceContext.Provider>
336
</ViewElementSourceContext.Provider>
337
</SettingsContextController>
338
<UnsupportedBridgeProtocolDialog />
packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.js
+8
-1
@@ -17,6 +17,7 @@ import CommitFlamegraph from './CommitFlamegraph';
17
import CommitRanked from './CommitRanked';
18
import RootSelector from './RootSelector';
19
import {Timeline} from 'react-devtools-timeline/src/Timeline';
20
+import SidebarEventInfo from './SidebarEventInfo';
21
import RecordToggle from './RecordToggle';
22
import ReloadAndProfileButton from './ReloadAndProfileButton';
23
import ProfilingImportExportButtons from './ProfilingImportExportButtons';
@@ -33,6 +34,7 @@ import {SettingsModalContextController} from 'react-devtools-shared/src/devtools
34
import portaledContent from '../portaledContent';
35
import {StoreContext} from '../context';
36
import {TimelineContext} from 'react-devtools-timeline/src/TimelineContext';
37
+import {enableProfilerComponentTree} from 'react-devtools-feature-flags';
38
39
import styles from './Profiler.css';
40
@@ -55,6 +57,8 @@ function Profiler(_: {||}) {
57
const {supportsTimeline} = useContext(StoreContext);
58
59
const isLegacyProfilerSelected = selectedTabID !== 'timeline';
60
+ const isRightColumnVisible =
61
+ isLegacyProfilerSelected || enableProfilerComponentTree;
62
63
let view = null;
64
if (didRecordCommits || selectedTabID === 'timeline') {
@@ -102,6 +106,9 @@ function Profiler(_: {||}) {
106
}
107
}
108
break;
109
+ case 'timeline':
110
+ sidebar = <SidebarEventInfo />;
111
+ break;
112
default:
113
break;
114
}
@@ -145,7 +152,7 @@ function Profiler(_: {||}) {
152
<ModalDialog />
153
</div>
154
</div>
148
- {isLegacyProfilerSelected && (
155
+ {isRightColumnVisible && (
156
<div className={styles.RightColumn}>{sidebar}</div>
157
)}
158
<SettingsModal />
packages/react-devtools-shared/src/devtools/views/Profiler/SidebarEventInfo.css
new
+70
@@ -0,0 +1,70 @@
1
+.Toolbar {
2
+ height: 2.25rem;
3
+ padding: 0 0.5rem;
4
+ flex: 0 0 auto;
5
+ display: flex;
6
+ align-items: center;
7
+ border-bottom: 1px solid var(--color-border);
8
+}
9
+
10
+.Content {
11
+ padding: 0.5rem;
12
+ user-select: none;
13
+ overflow: auto;
14
+}
15
+
16
+.List {
17
+ list-style: none;
18
+ margin: 0;
19
+ padding: 0;
20
+}
21
+
22
+.ListItem {
23
+ margin: 0;
24
+}
25
+
26
+.Label {
27
+ display: flex;
28
+ justify-content: space-between;
29
+
30
+ font-weight: bold;
31
+}
32
+
33
+[data-source="true"]:hover .Label > .Button {
34
+ background-color: var(--color-background-hover);
35
+}
36
+
37
+.Value {
38
+ font-family: var(--font-family-monospace);
39
+ font-size: var(--font-size-monospace-normal);
40
+}
41
+
42
+.NothingSelected {
43
+ display: flex;
44
+ align-items: center;
45
+ justify-content: center;
46
+ height: 100%;
47
+ color: var(--color-dim);
48
+}
49
+
50
+.Button {
51
+ display: flex;
52
+ flex: 1;
53
+
54
+ max-width: 95%;
55
+ overflow: hidden;
56
+ text-overflow: ellipsis;
57
+}
58
+
59
+[data-source="true"] .Button {
60
+ cursor: pointer;
61
+}
62
+
63
+
64
+.Button > span {
65
+ display: block;
66
+ text-align: left;
67
+}
68
+
69
+.Source {
70
+}
packages/react-devtools-shared/src/devtools/views/Profiler/SidebarEventInfo.js
new
+86
@@ -0,0 +1,86 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+
10
+import * as React from 'react';
11
+import {isStateUpdateEvent} from 'react-devtools-timeline/src/utils/flow';
12
+import Button from '../Button';
13
+import ButtonIcon from '../ButtonIcon';
14
+import ViewSourceContext from '../Components/ViewSourceContext';
15
+import {useContext, useMemo} from 'react';
16
+import {ProfilerContext} from './ProfilerContext';
17
+import {stackToComponentSources} from 'react-devtools-shared/src/devtools/utils';
18
+
19
+import styles from './SidebarEventInfo.css';
20
+
21
+export type Props = {||};
22
+
23
+export default function SidebarEventInfo(_: Props) {
24
+ const {profilingData, selectedCommitIndex} = useContext(ProfilerContext);
25
+ const {viewSourceLineFunction} = useContext(ViewSourceContext);
26
+
27
+ const {stack} = useMemo(() => {
28
+ if (
29
+ selectedCommitIndex == null ||
30
+ profilingData == null ||
31
+ profilingData.timelineData.length === 0
32
+ ) {
33
+ return {};
34
+ }
35
+ const {schedulingEvents} = profilingData.timelineData[0];
36
+
37
+ const event = schedulingEvents[selectedCommitIndex];
38
+ if (!isStateUpdateEvent(event)) {
39
+ return {};
40
+ }
41
+
42
+ let componentStack = null;
43
+ if (event.componentStack) {
44
+ componentStack = stackToComponentSources(event.componentStack);
45
+ }
46
+
47
+ return {
48
+ stack: componentStack,
49
+ };
50
+ }, [profilingData, selectedCommitIndex]);
51
+
52
+ let components;
53
+ if (stack) {
54
+ components = stack.map(([displayName, source], index) => {
55
+ const hasSource = source != null;
56
+
57
+ const onClick = () => {
58
+ if (viewSourceLineFunction != null && source != null) {
59
+ viewSourceLineFunction(...source);
60
+ }
61
+ };
62
+
63
+ return (
64
+ <li key={index} className={styles.ListItem} data-source={hasSource}>
65
+ <label className={styles.Label}>
66
+ <Button className={styles.Button} onClick={onClick}>
67
+ {displayName}
68
+ </Button>
69
+ {hasSource && (
70
+ <ButtonIcon className={styles.Source} type="view-source" />
71
+ )}
72
+ </label>
73
+ </li>
74
+ );
75
+ });
76
+ }
77
+
78
+ return (
79
+ <>
80
+ <div className={styles.Toolbar}>Event Component Tree</div>
81
+ <div className={styles.Content} tabIndex={0}>
82
+ <ol className={styles.List}>{components}</ol>
83
+ </div>
84
+ </>
85
+ );
86
+}
packages/react-devtools-timeline/src/CanvasPage.js
+6
@@ -63,6 +63,7 @@ import useContextMenu from 'react-devtools-shared/src/devtools/ContextMenu/useCo
63
import {getBatchRange} from './utils/getBatchRange';
64
import {MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL} from './view-base/constants';
65
import {TimelineSearchContext} from './TimelineSearchContext';
66
+import {ProfilerContext} from 'react-devtools-shared/src/devtools/views/Profiler/ProfilerContext';
67
68
import styles from './CanvasPage.css';
69
@@ -528,6 +529,8 @@ function AutoSizedCanvas({
529
ref: canvasRef,
530
});
531
532
+ const {selectCommitIndex} = useContext(ProfilerContext);
533
+
534
useEffect(() => {
535
const {current: userTimingMarksView} = userTimingMarksViewRef;
536
if (userTimingMarksView) {
@@ -563,6 +566,9 @@ function AutoSizedCanvas({
566
});
567
}
568
};
569
+ schedulingEventsView.onClick = (schedulingEvent, eventIndex) => {
570
+ selectCommitIndex(eventIndex);
571
+ };
572
}
573
574
const {current: suspenseEventsView} = suspenseEventsViewRef;
packages/react-devtools-timeline/src/content-views/SchedulingEventsView.js
+26
-1
@@ -9,6 +9,7 @@
9
10
import type {SchedulingEvent, TimelineData} from '../types';
11
import type {
12
+ ClickInteraction,
13
Interaction,
14
MouseMoveInteraction,
15
Rect,
@@ -45,6 +46,9 @@ export class SchedulingEventsView extends View {
46
47
_hoveredEvent: SchedulingEvent | null = null;
48
onHover: ((event: SchedulingEvent | null) => void) | null = null;
49
+ onClick:
50
+ | ((event: SchedulingEvent | null, eventIndex: number | null) => void)
51
+ | null = null;
52
53
constructor(surface: Surface, frame: Rect, profilerData: TimelineData) {
54
super(surface, frame);
@@ -243,7 +247,7 @@ export class SchedulingEventsView extends View {
247
timestamp - eventTimestampAllowance <= hoverTimestamp &&
248
hoverTimestamp <= timestamp + eventTimestampAllowance
249
) {
246
- this.currentCursor = 'context-menu';
250
+ this.currentCursor = 'pointer';
251
viewRefs.hoveredView = this;
252
onHover(event);
253
return;
@@ -253,11 +257,32 @@ export class SchedulingEventsView extends View {
257
onHover(null);
258
}
259
260
+ /**
261
+ * @private
262
+ */
263
+ _handleClick(interaction: ClickInteraction) {
264
+ const {onClick} = this;
265
+ if (onClick) {
266
+ const {
267
+ _profilerData: {schedulingEvents},
268
+ } = this;
269
+ const eventIndex = schedulingEvents.findIndex(
270
+ event => event === this._hoveredEvent,
271
+ );
272
+ // onHover is going to take care of all the difficult logic here of
273
+ // figuring out which event when they're proximity is close.
274
+ onClick(this._hoveredEvent, eventIndex >= 0 ? eventIndex : null);
275
+ }
276
+ }
277
+
278
handleInteraction(interaction: Interaction, viewRefs: ViewRefs) {
279
switch (interaction.type) {
280
case 'mousemove':
281
this._handleMouseMove(interaction, viewRefs);
282
break;
283
+ case 'click':
284
+ this._handleClick(interaction);
285
+ break;
286
}
287
}
288
}
packages/react-devtools-timeline/src/utils/flow.js
new
+13
@@ -0,0 +1,13 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+import type {SchedulingEvent} from '../types';
10
+
11
+export function isStateUpdateEvent(event: SchedulingEvent): boolean %checks {
12
+ return event.type === 'schedule-state-update';
13
+}