@samitouri / QOS-React-2 / commits / 2e1c8841e9

[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 +}