diff --git a/packages/react-devtools-extensions/src/main.js b/packages/react-devtools-extensions/src/main.js index a8875eacfd60e..3f4d24e730e3c 100644 --- a/packages/react-devtools-extensions/src/main.js +++ b/packages/react-devtools-extensions/src/main.js @@ -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, }), ); }; diff --git a/packages/react-devtools-shared/src/__tests__/utils-test.js b/packages/react-devtools-shared/src/__tests__/utils-test.js index 7d001e4a5e7d6..6c322c4fa8446 100644 --- a/packages/react-devtools-shared/src/__tests__/utils-test.js +++ b/packages/react-devtools-shared/src/__tests__/utils-test.js @@ -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', () => { diff --git a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-fb.js b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-fb.js index f382a2b4b2e27..59b8e61a42ae1 100644 --- a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-fb.js +++ b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-fb.js @@ -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. diff --git a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-oss.js b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-oss.js index 579efaedd619d..034368121988c 100644 --- a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-oss.js +++ b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-oss.js @@ -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. diff --git a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.default.js b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.default.js index 3bd4efb101c76..01d5fd01075c2 100644 --- a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.default.js +++ b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.default.js @@ -19,3 +19,4 @@ export const enableNamedHooksFeature = true; export const enableProfilerChangedHookIndices = true; export const enableStyleXFeatures = false; export const isInternalFacebookBuild = false; +export const enableProfilerComponentTree = false; diff --git a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-fb.js b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-fb.js index d86b0ddf73ab5..76d5546efb3d4 100644 --- a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-fb.js +++ b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-fb.js @@ -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. diff --git a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-oss.js b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-oss.js index f1a307306242a..678ba417e2c98 100644 --- a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-oss.js +++ b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-oss.js @@ -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. diff --git a/packages/react-devtools-shared/src/devtools/utils.js b/packages/react-devtools-shared/src/devtools/utils.js index 35e34b9c8cf42..7cbce2d771dea 100644 --- a/packages/react-devtools-shared/src/devtools/utils.js +++ b/packages/react-devtools-shared/src/devtools/utils.js @@ -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; +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/ViewSourceContext.js b/packages/react-devtools-shared/src/devtools/views/Components/ViewSourceContext.js new file mode 100644 index 0000000000000..daf216245bb74 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/ViewSourceContext.js @@ -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(((null: any): Context)); +ViewSourceContext.displayName = 'ViewSourceContext'; + +export default ViewSourceContext; diff --git a/packages/react-devtools-shared/src/devtools/views/DevTools.js b/packages/react-devtools-shared/src/devtools/views/DevTools.js index b16429b5bcff2..49c947ba91389 100644 --- a/packages/react-devtools-shared/src/devtools/views/DevTools.js +++ b/packages/react-devtools-shared/src/devtools/views/DevTools.js @@ -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, @@ -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}> - - - - - - -
- {showTabBar && ( -
- - - {process.env.DEVTOOLS_VERSION} - -
- + + + + + + +
+ {showTabBar && ( +
+ + + {process.env.DEVTOOLS_VERSION} + +
+ +
+ )} + + - )} - - -
- - - - - - + + + + + + + diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.js b/packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.js index f9f98fae656f3..0d7e23f2e388e 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.js @@ -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 = ; + break; default: break; } @@ -145,7 +152,7 @@ function Profiler(_: {||}) {
- {isLegacyProfilerSelected && ( + {isRightColumnVisible && (
{sidebar}
)} diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarEventInfo.css b/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarEventInfo.css new file mode 100644 index 0000000000000..b16e8b97847fd --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarEventInfo.css @@ -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 { +} diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarEventInfo.js b/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarEventInfo.js new file mode 100644 index 0000000000000..b32308d5d5df2 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarEventInfo.js @@ -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 ( +
  • + +
  • + ); + }); + } + + return ( + <> +
    Event Component Tree
    +
    +
      {components}
    +
    + + ); +} diff --git a/packages/react-devtools-timeline/src/CanvasPage.js b/packages/react-devtools-timeline/src/CanvasPage.js index 8ef42b1dd37d9..116d7b28567df 100644 --- a/packages/react-devtools-timeline/src/CanvasPage.js +++ b/packages/react-devtools-timeline/src/CanvasPage.js @@ -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; diff --git a/packages/react-devtools-timeline/src/content-views/SchedulingEventsView.js b/packages/react-devtools-timeline/src/content-views/SchedulingEventsView.js index 3798d261e0b1b..4c35ee6828943 100644 --- a/packages/react-devtools-timeline/src/content-views/SchedulingEventsView.js +++ b/packages/react-devtools-timeline/src/content-views/SchedulingEventsView.js @@ -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; } } } diff --git a/packages/react-devtools-timeline/src/utils/flow.js b/packages/react-devtools-timeline/src/utils/flow.js new file mode 100644 index 0000000000000..0754e23a001f9 --- /dev/null +++ b/packages/react-devtools-timeline/src/utils/flow.js @@ -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'; +}