From 18f422442e0ec0128636d677d7337687cd414f12 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Tue, 31 Mar 2026 11:39:10 -0400 Subject: [PATCH 01/19] Implement column sorting in the generic TreeView. It's not used by any TreeView users yet. The next commit will use it for the marker table. The intention is to also use it for the function list. --- src/components/marker-table/index.tsx | 12 +- src/components/shared/TreeView.css | 27 +++- src/components/shared/TreeView.tsx | 193 ++++++++++++++++++++++-- src/profile-logic/call-tree.ts | 5 + src/profile-logic/zip-files.ts | 5 + src/test/unit/column-sort-state.test.ts | 170 +++++++++++++++++++++ 6 files changed, 391 insertions(+), 21 deletions(-) create mode 100644 src/test/unit/column-sort-state.test.ts diff --git a/src/components/marker-table/index.tsx b/src/components/marker-table/index.tsx index a02b10c06d..762f1ceef4 100644 --- a/src/components/marker-table/index.tsx +++ b/src/components/marker-table/index.tsx @@ -6,7 +6,7 @@ import { PureComponent } from 'react'; import memoize from 'memoize-immutable'; import explicitConnect from '../../utils/connect'; -import { TreeView } from '../shared/TreeView'; +import { type SortableColumn, type Tree, TreeView } from '../shared/TreeView'; import { MarkerTableEmptyReasons } from './MarkerTableEmptyReasons'; import { getZeroAt, @@ -50,7 +50,7 @@ type MarkerDisplayData = { details: string; }; -class MarkerTree { +class MarkerTree implements Tree { _getMarker: (param: MarkerIndex) => Marker; _markerIndexes: MarkerIndex[]; _zeroAt: Milliseconds; @@ -73,6 +73,10 @@ class MarkerTree { this._getMarkerLabel = getMarkerLabel; } + getSortableColumns(): SortableColumn[] { + return []; + } + copyTable = ( format: 'plain' | 'markdown', onExceeedMaxCopyRows: (rows: number, maxRows: number) => void @@ -180,7 +184,7 @@ class MarkerTree { } getAllDescendants() { - return new Set(); + return new Set(); } getParent(): MarkerIndex { @@ -366,7 +370,7 @@ class MarkerTableImpl extends PureComponent { ) : ( > = { }>; }; +export type SingleColumnSortState = { + column: string; + ascending: boolean; +}; + +export class ColumnSortState { + sortedColumns: SingleColumnSortState[]; + + constructor(sortedColumns: SingleColumnSortState[]) { + this.sortedColumns = sortedColumns; + } + + withToggledSortForColumn( + column: string, + prefersDescending: boolean + ): ColumnSortState { + const current = this.current(); + const sortedColumns = this.sortedColumns.filter((c) => c.column !== column); + + sortedColumns.push({ + column, + ascending: + current && current.column === column + ? !current.ascending + : !prefersDescending, + }); + return new ColumnSortState(sortedColumns); + } + + current(): SingleColumnSortState | null { + return this.sortedColumns.length > 0 + ? this.sortedColumns[this.sortedColumns.length - 1] + : null; + } + + /** + * Sort `items` by all columns in `sortedColumns`, with the last column being + * the primary key. `compareColumn(a, b, column)` must return the sign of + * (a - b) for that column — i.e. ascending order. Array.prototype.sort is + * stable, so earlier-listed columns act as tiebreakers. + */ + sortItemsHelper( + items: T[], + compareColumn: (a: T, b: T, column: string) => number + ): T[] { + const sorted = items.slice(); + for (const { column, ascending } of this.sortedColumns) { + const sign = ascending ? 1 : -1; + sorted.sort((a, b) => sign * compareColumn(a, b, column)); + } + return sorted; + } +} + export type MaybeResizableColumn> = Column & { /** defaults to initialWidth */ @@ -73,6 +127,9 @@ type TreeViewHeaderProps> = { // passes the column index and the start x coordinate readonly onColumnWidthChangeStart: (param: number, x: CssPixels) => void; readonly onColumnWidthReset: (param: number) => void; + readonly onToggleSortForColumn: (column: string) => void; + readonly currentSortedColumn: SingleColumnSortState | null; + readonly sortableColumns?: Set; }; class TreeViewHeader< @@ -91,8 +148,22 @@ class TreeViewHeader< ); }; + _onToggleSortForColumn = (e: React.MouseEvent) => { + const { onToggleSortForColumn } = this.props; + const target = e.currentTarget; + if (target instanceof HTMLElement && target.dataset.column) { + onToggleSortForColumn(target.dataset.column); + } + }; + override render() { - const { fixedColumns, mainColumn, viewOptions } = this.props; + const { + fixedColumns, + mainColumn, + viewOptions, + currentSortedColumn, + sortableColumns, + } = this.props; const columnWidths = viewOptions.fixedColumnWidths; if (fixedColumns.length === 0 && !mainColumn.titleL10nId) { // If there is nothing to display in the header, do not render it. @@ -102,14 +173,50 @@ class TreeViewHeader<
{fixedColumns.map((col, i) => { const width = columnWidths[i] + (col.headerWidthAdjustment || 0); + const isSortable = sortableColumns?.has(col.propName) ?? false; + let sortClass = ''; + let ariaSort: 'ascending' | 'descending' | 'none' | undefined; + if (isSortable) { + if ( + currentSortedColumn && + currentSortedColumn.column === col.propName + ) { + sortClass = currentSortedColumn.ascending + ? 'sortAscending' + : 'sortDescending'; + ariaSort = currentSortedColumn.ascending + ? 'ascending' + : 'descending'; + } else { + sortClass = 'sortInactive'; + ariaSort = 'none'; + } + } + const cellClassName = `treeViewHeaderColumn treeViewFixedColumn ${col.propName}`; return ( - - - + {isSortable ? ( + + + + ) : ( + + + + )} {col.hideDividerAfter !== true ? ( > { +export type SortableColumn = { + name: string; + prefersDescending: boolean; +}; + +export interface Tree> { getDepth(nodeIndex: NodeIndex): number; - getRoots(): NodeIndex[]; + getRoots(sort: ColumnSortState | null): NodeIndex[]; getDisplayData(nodeIndex: NodeIndex): DisplayData; getParent(nodeIndex: NodeIndex): NodeIndex; - getChildren(nodeIndex: NodeIndex): NodeIndex[]; + getChildren(nodeIndex: NodeIndex, sort: ColumnSortState | null): NodeIndex[]; hasChildren(nodeIndex: NodeIndex): boolean; getAllDescendants(nodeIndex: NodeIndex): Set; + + getSortableColumns(): SortableColumn[]; // constant } type TreeViewProps> = { @@ -460,6 +574,8 @@ type TreeViewProps> = { readonly onKeyDown?: (param: React.KeyboardEvent) => void; readonly viewOptions: TableViewOptions; readonly onViewOptionsChange?: (param: TableViewOptions) => void; + readonly sortedColumns?: ColumnSortState; + readonly onColumnSortChange?: (sortedColumns: ColumnSortState) => void; }; type TreeViewState = { @@ -467,6 +583,8 @@ type TreeViewState = { readonly isResizingColumns: boolean; }; +const EMPTY_SORT_STATE = new ColumnSortState([]); + export class TreeView< DisplayData extends Record, > extends React.PureComponent, TreeViewState> { @@ -482,7 +600,7 @@ export class TreeView< initialWidth: CssPixels; } | null = null; - override state = { + override state: TreeViewState = { // This contains the current widths, while or after the user resizes them. fixedColumnWidths: null, @@ -490,6 +608,10 @@ export class TreeView< isResizingColumns: false, }; + _getSortedColumns(): ColumnSortState { + return this.props.sortedColumns ?? EMPTY_SORT_STATE; + } + // This is incremented when a column changed its size. We use this to force a // rerender of the VirtualList component. _columnSizeChangedCounter: number = 0; @@ -519,6 +641,11 @@ export class TreeView< fixedColumns.map((c) => c.initialWidth) ); + _getSortableColumnNames = memoize( + (tree: Tree): Set => + new Set(tree.getSortableColumns().map((c) => c.name)) + ); + // This returns the column widths from several possible sources, in this order: // * the current state (this means the user changed them recently, or is // currently changing them) @@ -609,7 +736,11 @@ export class TreeView< }; _computeAllVisibleRowsMemoized = memoize( - (tree: Tree, expandedNodes: Set) => { + ( + tree: Tree, + expandedNodes: Set, + sortedColumns: ColumnSortState + ) => { function _addVisibleRowsFromNode( tree: Tree, expandedNodes: Set, @@ -620,13 +751,13 @@ export class TreeView< if (!expandedNodes.has(nodeId)) { return; } - const children = tree.getChildren(nodeId); + const children = tree.getChildren(nodeId, sortedColumns); for (let i = 0; i < children.length; i++) { _addVisibleRowsFromNode(tree, expandedNodes, arr, children[i]); } } - const roots = tree.getRoots(); + const roots = tree.getRoots(sortedColumns); const allRows: NodeIndex[] = []; for (let i = 0; i < roots.length; i++) { _addVisibleRowsFromNode(tree, expandedNodes, allRows, roots[i]); @@ -718,7 +849,11 @@ export class TreeView< _getAllVisibleRows(): NodeIndex[] { const { tree } = this.props; - return this._computeAllVisibleRowsMemoized(tree, this._getExpandedNodes()); + return this._computeAllVisibleRowsMemoized( + tree, + this._getExpandedNodes(), + this._getSortedColumns() + ); } _getSpecialItems(): [NodeIndex | void, NodeIndex | void] { @@ -906,7 +1041,10 @@ export class TreeView< // Do KEY_DOWN only if the next element is a child if (this.props.tree.hasChildren(selected)) { this._selectWithKeyboard( - this.props.tree.getChildren(selected)[0] + this.props.tree.getChildren( + selected, + this._getSortedColumns() + )[0] ); } } @@ -931,6 +1069,25 @@ export class TreeView< } }; + _onToggleSortForColumn = (column: string) => { + const { onColumnSortChange } = this.props; + if (!onColumnSortChange) { + return; + } + const sortableColumn = this.props.tree + .getSortableColumns() + .find((c) => c.name === column); + if (sortableColumn === undefined) { + return; + } + onColumnSortChange( + this._getSortedColumns().withToggledSortForColumn( + column, + sortableColumn.prefersDescending + ) + ); + }; + /* This method is used by users of this component. */ /* eslint-disable-next-line react/no-unused-class-component-methods */ focus() { @@ -951,6 +1108,7 @@ export class TreeView< selectedNodeId, } = this.props; const { isResizingColumns } = this.state; + const sortableColumns = this._getSortableColumnNames(this.props.tree); return (
{ + if (column === 'a') { + return x.a - y.a; + } + if (column === 'b') { + return x.b - y.b; + } + throw new Error(`unknown column ${column}`); + }; + + it('sorts stably across multiple criteria with the last column as primary', function () { + const items: Item[] = [ + { a: 1, b: 2 }, + { a: 1, b: 1 }, + { a: 0, b: 9 }, + ]; + // Primary: a ascending; tiebreaker: b ascending. Tiebreakers are listed + // first; the primary is last. + const state = new ColumnSortState([ + { column: 'b', ascending: true }, + { column: 'a', ascending: true }, + ]); + expect(state.sortItemsHelper(items, compareColumn)).toEqual([ + { a: 0, b: 9 }, + { a: 1, b: 1 }, + { a: 1, b: 2 }, + ]); + }); + + it('returns a copy of the input when sortedColumns is empty and does not mutate the input', function () { + const items: Item[] = [ + { a: 1, b: 2 }, + { a: 1, b: 1 }, + { a: 0, b: 9 }, + ]; + const inputSnapshot = items.slice(); + const state = new ColumnSortState([]); + const result = state.sortItemsHelper(items, compareColumn); + + expect(result).toEqual(inputSnapshot); + expect(result).not.toBe(items); + expect(items).toEqual(inputSnapshot); + }); + + it('sorts descending when ascending is false', function () { + const items: Item[] = [ + { a: 1, b: 0 }, + { a: 3, b: 0 }, + { a: 2, b: 0 }, + ]; + const state = new ColumnSortState([{ column: 'a', ascending: false }]); + expect(state.sortItemsHelper(items, compareColumn)).toEqual([ + { a: 3, b: 0 }, + { a: 2, b: 0 }, + { a: 1, b: 0 }, + ]); + }); + }); +}); From e6ef18a890dfe923a5bbc7bf58ccf29f5c6c984a Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Tue, 31 Mar 2026 11:39:10 -0400 Subject: [PATCH 02/19] Implement column sorting in the marker table. The sort is also persisted in the URL. --- src/actions/profile-view.ts | 8 ++ src/app-logic/url-handling.ts | 62 ++++++++++++- src/components/marker-table/index.tsx | 88 +++++++++++++++++-- src/reducers/url-state.ts | 14 +++ src/selectors/url-state.ts | 3 + src/test/components/MarkerTable.test.tsx | 8 +- .../__snapshots__/MarkerTable.test.tsx.snap | 27 ++++-- src/test/url-handling.test.ts | 50 +++++++++++ src/types/actions.ts | 5 ++ src/types/state.ts | 2 + 10 files changed, 247 insertions(+), 20 deletions(-) diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 0c51a7a172..949fcabb34 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -82,6 +82,7 @@ import { import { changeStoredProfileNameInDb } from 'firefox-profiler/app-logic/uploaded-profiles-db'; import type { TabSlug } from '../app-logic/tabs-handling'; import type { CallNodeInfo } from '../profile-logic/call-node-info'; +import type { SingleColumnSortState } from '../components/shared/TreeView'; import { intersectSets } from 'firefox-profiler/utils/set'; /** @@ -1615,6 +1616,13 @@ export function changeMarkersSearchString(searchString: string): Action { }; } +export function changeMarkerTableSort(sort: SingleColumnSortState[]): Action { + return { + type: 'CHANGE_MARKER_TABLE_SORT', + sort, + }; +} + export function changeNetworkSearchString(searchString: string): Action { return { type: 'CHANGE_NETWORK_SEARCH_STRING', diff --git a/src/app-logic/url-handling.ts b/src/app-logic/url-handling.ts index 02b2907421..a8568da7a0 100644 --- a/src/app-logic/url-handling.ts +++ b/src/app-logic/url-handling.ts @@ -52,8 +52,9 @@ import { tabSlugs } from '../app-logic/tabs-handling'; import { StringTable } from 'firefox-profiler/utils/string-table'; import type { ProfileUpgradeInfo } from 'firefox-profiler/profile-logic/processed-profile-versioning'; import type { ProfileAndProfileUpgradeInfo } from 'firefox-profiler/actions/receive-profile'; +import type { SingleColumnSortState } from '../components/shared/TreeView'; -export const CURRENT_URL_VERSION = 16; +export const CURRENT_URL_VERSION = 17; /** * This static piece of state might look like an anti-pattern, but it's a relatively @@ -190,6 +191,7 @@ type CallTreeQuery = BaseQuery & { type MarkersQuery = BaseQuery & { markerSearch: string; // "DOMEvent" marker?: MarkerIndex; // Selected marker index for the current thread, e.g. 42 + markerSort?: string; // "duration:desc,start:asc" — primary first }; type NetworkQuery = BaseQuery & { @@ -228,6 +230,7 @@ type Query = BaseQuery & { // Markers specific markerSearch?: string; marker?: MarkerIndex; + markerSort?: string; // Network specific networkSearch?: string; @@ -394,6 +397,9 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { urlState.profileSpecific.selectedMarkers[selectedThreadsKey] !== null ? urlState.profileSpecific.selectedMarkers[selectedThreadsKey] : undefined; + query.markerSort = convertMarkerTableSortToString( + urlState.profileSpecific.markerTableSort + ); break; case 'network-chart': query = baseQuery as NetworkQueryShape; @@ -632,10 +638,59 @@ export function stateFromLocation( ? query.hiddenThreads.split('-').map((index) => Number(index)) : null, selectedMarkers, + markerTableSort: convertMarkerTableSortFromString(query.markerSort), }, }; } +// MarkerTable sort URL encoding. The internal ColumnSortState stores the +// primary-sorted column last (newest click wins as primary); the URL puts the +// primary first for human readability. +const VALID_MARKER_SORT_COLUMNS = new Set(['start', 'duration', 'name']); + +function convertMarkerTableSortToString( + sort: SingleColumnSortState[] +): string | undefined { + if (sort.length === 0) { + return undefined; + } + // Omit when it matches the marker table's own default. + if (sort.length === 1 && sort[0].column === 'start' && sort[0].ascending) { + return undefined; + } + return sort + .slice() + .reverse() + .map((s) => `${s.column}-${s.ascending ? 'asc' : 'desc'}`) + .join('~'); +} + +function convertMarkerTableSortFromString( + raw: string | null | void +): SingleColumnSortState[] { + if (!raw) { + return []; + } + const parsed: SingleColumnSortState[] = []; + for (const part of raw.split('~')) { + const dashIndex = part.lastIndexOf('-'); + if (dashIndex === -1) { + return []; + } + const column = part.slice(0, dashIndex); + const dir = part.slice(dashIndex + 1); + if ( + !VALID_MARKER_SORT_COLUMNS.has(column) || + (dir !== 'asc' && dir !== 'desc') + ) { + return []; + } + parsed.push({ column, ascending: dir === 'asc' }); + } + // URL is primary-first; internal storage is primary-last. + return parsed.reverse(); +} + function convertGlobalTrackOrderFromString( rawString: string | null | void ): TrackIndex[] { @@ -1443,6 +1498,11 @@ const _upgraders: { .join('~'); } }, + [17]: (_processedLocation: ProcessedLocationBeforeUpgrade) => { + // Adds the optional `markerSort` query parameter for the marker table. + // No migration is necessary: older URLs simply omit it and the default + // (sort by start ascending) is used. + }, }; /** diff --git a/src/components/marker-table/index.tsx b/src/components/marker-table/index.tsx index 762f1ceef4..66354ac0a3 100644 --- a/src/components/marker-table/index.tsx +++ b/src/components/marker-table/index.tsx @@ -6,7 +6,7 @@ import { PureComponent } from 'react'; import memoize from 'memoize-immutable'; import explicitConnect from '../../utils/connect'; -import { type SortableColumn, type Tree, TreeView } from '../shared/TreeView'; +import { ColumnSortState, TreeView } from '../shared/TreeView'; import { MarkerTableEmptyReasons } from './MarkerTableEmptyReasons'; import { getZeroAt, @@ -15,11 +15,15 @@ import { getCurrentTableViewOptions, } from '../../selectors/profile'; import { selectedThreadSelectors } from '../../selectors/per-thread'; -import { getSelectedThreadsKey } from '../../selectors/url-state'; +import { + getSelectedThreadsKey, + getMarkerTableSort, +} from '../../selectors/url-state'; import { changeSelectedMarker, changeRightClickedMarker, changeTableViewOptions, + changeMarkerTableSort, } from '../../actions/profile-view'; import { MarkerSettings } from '../shared/MarkerSettings'; import { formatSeconds, formatTimestamp } from '../../utils/format-numbers'; @@ -37,12 +41,21 @@ import type { TableViewOptions, SelectionContext, } from 'firefox-profiler/types'; +import type { + SingleColumnSortState, + Tree, + SortableColumn, +} from '../shared/TreeView'; import type { ConnectedProps } from '../../utils/connect'; // Limit how many characters in the description get sent to the DOM. const MAX_DESCRIPTION_CHARACTERS = 500; +const DEFAULT_MARKER_TABLE_SORT: SingleColumnSortState[] = [ + { column: 'start', ascending: true }, +]; + type MarkerDisplayData = { start: string; duration: string | null; @@ -73,8 +86,14 @@ class MarkerTree implements Tree { this._getMarkerLabel = getMarkerLabel; } + static _sortableColumns: SortableColumn[] = [ + { name: 'start', prefersDescending: false }, + { name: 'duration', prefersDescending: true }, + { name: 'name', prefersDescending: false }, + ]; + getSortableColumns(): SortableColumn[] { - return []; + return MarkerTree._sortableColumns; } copyTable = ( @@ -171,12 +190,49 @@ class MarkerTree implements Tree { copy(text); }; - getRoots(): MarkerIndex[] { + getRoots(sort: ColumnSortState | null = null): MarkerIndex[] { + if (sort !== null) { + return sort.sortItemsHelper( + this._markerIndexes, + (first: MarkerIndex, second: MarkerIndex, column: string) => { + const firstValue = this._getSortValueForColumn(first, column); + const secondValue = this._getSortValueForColumn(second, column); + if (typeof firstValue === 'string') { + return firstValue.localeCompare(secondValue as string); + } + return (firstValue as number) - (secondValue as number); + } + ); + } return this._markerIndexes; } - getChildren(markerIndex: MarkerIndex): MarkerIndex[] { - return markerIndex === -1 ? this.getRoots() : []; + getChildren( + markerIndex: MarkerIndex, + sort: ColumnSortState | null = null + ): MarkerIndex[] { + return markerIndex === -1 ? this.getRoots(sort) : []; + } + + _getSortValueForColumn( + markerIndex: MarkerIndex, + column: string + ): string | number { + const marker = this._getMarker(markerIndex); + switch (column) { + case 'start': + return marker.start; + case 'duration': { + if (marker.incomplete || marker.end === null) { + return -Infinity; + } + return marker.end - marker.start; + } + case 'name': + return marker.name; + default: + throw new Error('Invalid column ' + column); + } } hasChildren(_markerIndex: MarkerIndex): boolean { @@ -209,10 +265,11 @@ class MarkerTree implements Tree { } let duration = null; + const markerEnd = marker.end; if (marker.incomplete) { duration = 'unknown'; - } else if (marker.end !== null) { - duration = formatTimestamp(marker.end - marker.start); + } else if (markerEnd !== null) { + duration = formatTimestamp(markerEnd - marker.start); } displayData = { @@ -242,12 +299,14 @@ type StateProps = { readonly markerSchemaByName: MarkerSchemaByName; readonly getMarkerLabel: (param: MarkerIndex) => string; readonly tableViewOptions: TableViewOptions; + readonly sort: SingleColumnSortState[]; }; type DispatchProps = { readonly changeSelectedMarker: typeof changeSelectedMarker; readonly changeRightClickedMarker: typeof changeRightClickedMarker; readonly onTableViewOptionsChange: (param: TableViewOptions) => any; + readonly changeMarkerTableSort: typeof changeMarkerTableSort; }; type Props = ConnectedProps<{}, StateProps, DispatchProps>; @@ -284,6 +343,11 @@ class MarkerTableImpl extends PureComponent { this._treeView = treeView; }; + _getSortedColumns = memoize( + (sort: SingleColumnSortState[]) => + new ColumnSortState(sort.length > 0 ? sort : DEFAULT_MARKER_TABLE_SORT) + ); + getMarkerTree = memoize( ( getMarker: any, @@ -335,6 +399,10 @@ class MarkerTableImpl extends PureComponent { changeSelectedMarker(threadsKey, selectedMarker, context); }; + _onColumnSortChange = (sortedColumns: ColumnSortState) => { + this.props.changeMarkerTableSort(sortedColumns.sortedColumns); + }; + _onRightClickSelection = (selectedMarker: MarkerIndex) => { const { threadsKey, changeRightClickedMarker } = this.props; changeRightClickedMarker(threadsKey, selectedMarker); @@ -385,6 +453,8 @@ class MarkerTableImpl extends PureComponent { indentWidth={10} viewOptions={this.props.tableViewOptions} onViewOptionsChange={this.props.onTableViewOptionsChange} + sortedColumns={this._getSortedColumns(this.props.sort)} + onColumnSortChange={this._onColumnSortChange} /> )}
@@ -405,12 +475,14 @@ export const MarkerTable = explicitConnect<{}, StateProps, DispatchProps>({ markerSchemaByName: getMarkerSchemaByName(state), getMarkerLabel: selectedThreadSelectors.getMarkerTableLabelGetter(state), tableViewOptions: getCurrentTableViewOptions(state), + sort: getMarkerTableSort(state), }), mapDispatchToProps: { changeSelectedMarker, changeRightClickedMarker, onTableViewOptionsChange: (tableViewOptions) => changeTableViewOptions('marker-table', tableViewOptions), + changeMarkerTableSort, }, component: MarkerTableImpl, }); diff --git a/src/reducers/url-state.ts b/src/reducers/url-state.ts index 085244f0cc..5c162859f7 100644 --- a/src/reducers/url-state.ts +++ b/src/reducers/url-state.ts @@ -27,6 +27,7 @@ import type { } from 'firefox-profiler/types'; import type { TabSlug } from '../app-logic/tabs-handling'; +import type { SingleColumnSortState } from '../components/shared/TreeView'; import { translateThreadsKey } from 'firefox-profiler/profile-logic/profile-data'; import { translateTransformStack } from 'firefox-profiler/profile-logic/transforms'; @@ -193,6 +194,18 @@ const markersSearchString: Reducer = (state = '', action) => { } }; +const markerTableSort: Reducer = ( + state = [], + action +) => { + switch (action.type) { + case 'CHANGE_MARKER_TABLE_SORT': + return action.sort; + default: + return state; + } +}; + const networkSearchString: Reducer = (state = '', action) => { switch (action.type) { case 'CHANGE_NETWORK_SEARCH_STRING': @@ -793,6 +806,7 @@ const profileSpecific = combineReducers({ showJsTracerSummary, tabFilter, selectedMarkers, + markerTableSort, // The timeline tracks used to be hidden and sorted by thread indexes, rather than // track indexes. The only way to migrate this information to tracks-based data is to // first retrieve the profile, so they can't be upgraded by the normal url upgrading diff --git a/src/selectors/url-state.ts b/src/selectors/url-state.ts index bf2bc8fe65..80c9733a3c 100644 --- a/src/selectors/url-state.ts +++ b/src/selectors/url-state.ts @@ -36,6 +36,7 @@ import type { import type { TabSlug } from '../app-logic/tabs-handling'; import type { MarkerRegExps } from '../profile-logic/marker-data'; +import type { SingleColumnSortState } from '../components/shared/TreeView'; import urlStateReducer from '../reducers/url-state'; import { formatMetaInfoString } from '../profile-logic/profile-metainfo'; @@ -117,6 +118,8 @@ export const getCurrentSearchString: Selector = (state) => getProfileSpecificState(state).callTreeSearchString; export const getMarkersSearchString: Selector = (state) => getProfileSpecificState(state).markersSearchString; +export const getMarkerTableSort: Selector = (state) => + getProfileSpecificState(state).markerTableSort; export const getNetworkSearchString: Selector = (state) => getProfileSpecificState(state).networkSearchString; export const getSelectedTab: Selector = (state) => diff --git a/src/test/components/MarkerTable.test.tsx b/src/test/components/MarkerTable.test.tsx index 39acc17e6b..7de8a582dc 100644 --- a/src/test/components/MarkerTable.test.tsx +++ b/src/test/components/MarkerTable.test.tsx @@ -478,7 +478,9 @@ describe('MarkerTable', function () { let dividerForFirstColumn = ensureExists( document.querySelector('.treeViewHeaderColumnDivider') ); - let firstColumn = screen.getByText('Start'); + let firstColumn = ensureExists( + document.querySelector('.treeViewHeaderColumn.start') + ); expect(firstColumn).toHaveStyle({ width: '95px' }); fireEvent.mouseDown(dividerForFirstColumn, { clientX: 95 }); @@ -505,7 +507,9 @@ describe('MarkerTable', function () { ); // Make sure the first column kept its width - firstColumn = screen.getByText('Start'); + firstColumn = ensureExists( + document.querySelector('.treeViewHeaderColumn.start') + ); expect(firstColumn).toHaveStyle({ width: '80px' }); // Now double click to reset the style. diff --git a/src/test/components/__snapshots__/MarkerTable.test.tsx.snap b/src/test/components/__snapshots__/MarkerTable.test.tsx.snap index df20a0b201..0558e43a1b 100644 --- a/src/test/components/__snapshots__/MarkerTable.test.tsx.snap +++ b/src/test/components/__snapshots__/MarkerTable.test.tsx.snap @@ -205,32 +205,41 @@ exports[`MarkerTable renders some basic markers and updates when needed 1`] = `
- Start - + - Duration - + - Name - + Date: Sun, 21 Jun 2026 10:49:09 -0400 Subject: [PATCH 03/19] Create a non-connected FlameGraph component. This will let us use flame graphs in other places, for example in the function list panel (for callee / caller views) or in a benchmark comparison view. ConnectedFlameGraph is the connected version and works as before. This commit also removes MaybeFlameGraph. The flame graph no longer respects the global "is inverted" flag, so the performance warning in MaybeFlameGraph was never shown. The only other functionality of MaybeFlameGraph was that it displayed "empty reasons" when the preview selection was empty; this part has been subsumed into ConnectedFlameGraph. --- .../flame-graph/ConnectedFlameGraph.tsx | 243 ++++++++++++++++++ src/components/flame-graph/FlameGraph.tsx | 168 +++--------- .../flame-graph/MaybeFlameGraph.css | 19 -- .../flame-graph/MaybeFlameGraph.tsx | 88 ------- src/components/flame-graph/index.tsx | 74 ++++-- 5 files changed, 335 insertions(+), 257 deletions(-) create mode 100644 src/components/flame-graph/ConnectedFlameGraph.tsx delete mode 100644 src/components/flame-graph/MaybeFlameGraph.css delete mode 100644 src/components/flame-graph/MaybeFlameGraph.tsx diff --git a/src/components/flame-graph/ConnectedFlameGraph.tsx b/src/components/flame-graph/ConnectedFlameGraph.tsx new file mode 100644 index 0000000000..7701609abf --- /dev/null +++ b/src/components/flame-graph/ConnectedFlameGraph.tsx @@ -0,0 +1,243 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import * as React from 'react'; + +import { explicitConnectWithForwardRef } from '../../utils/connect'; +import { FlameGraph } from './FlameGraph'; + +import { + getCategories, + getCommittedRange, + getPreviewSelection, + getScrollToSelectionGeneration, + getProfileInterval, + getInnerWindowIDToPageMap, + getProfileUsesMultipleStackTypes, +} from 'firefox-profiler/selectors/profile'; +import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { getSelectedThreadsKey } from '../../selectors/url-state'; +import { + changeSelectedCallNode, + changeRightClickedCallNode, + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, +} from 'firefox-profiler/actions/profile-view'; + +import type { + Thread, + CategoryList, + Milliseconds, + StartEndRange, + WeightType, + SamplesLikeTable, + PreviewSelection, + CallTreeSummaryStrategy, + IndexIntoCallNodeTable, + ThreadsKey, + InnerWindowID, + Page, + SampleCategoriesAndSubcategories, +} from 'firefox-profiler/types'; + +import type { FlameGraphTiming } from 'firefox-profiler/profile-logic/flame-graph'; +import type { CallNodeInfo } from 'firefox-profiler/profile-logic/call-node-info'; + +import type { + CallTree, + CallTreeTimings, +} from 'firefox-profiler/profile-logic/call-tree'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +type StateProps = { + readonly thread: Thread; + readonly weightType: WeightType; + readonly innerWindowIDToPageMap: Map | null; + readonly maxStackDepthPlusOne: number; + readonly timeRange: StartEndRange; + readonly previewSelection: PreviewSelection | null; + readonly flameGraphTiming: FlameGraphTiming; + readonly callTree: CallTree; + readonly callNodeInfo: CallNodeInfo; + readonly threadsKey: ThreadsKey; + readonly selectedCallNodeIndex: IndexIntoCallNodeTable | null; + readonly rightClickedCallNodeIndex: IndexIntoCallNodeTable | null; + readonly scrollToSelectionGeneration: number; + readonly categories: CategoryList; + readonly interval: Milliseconds; + readonly callTreeSummaryStrategy: CallTreeSummaryStrategy; + readonly ctssSamples: SamplesLikeTable; + readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; + readonly tracedTiming: CallTreeTimings | null; + readonly displayStackType: boolean; +}; + +type DispatchProps = { + readonly changeSelectedCallNode: typeof changeSelectedCallNode; + readonly changeRightClickedCallNode: typeof changeRightClickedCallNode; + readonly handleCallNodeTransformShortcut: typeof handleCallNodeTransformShortcut; + readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +export interface ConnectedFlameGraphHandle { + focus(): void; +} + +class ConnectedFlameGraphImpl + extends React.PureComponent + implements ConnectedFlameGraphHandle +{ + _flameGraph: React.RefObject = React.createRef(); + + // eslint-disable-next-line react/no-unused-class-component-methods -- called via ConnectedFlameGraphHandle ref from FlameGraphViewImpl + focus() { + this._flameGraph.current?.focus(); + } + + _onSelectedCallNodeChange = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + const { callNodeInfo, threadsKey, changeSelectedCallNode } = this.props; + changeSelectedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(callNodeIndex) + ); + }; + + _onRightClickedCallNodeChange = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + const { callNodeInfo, threadsKey, changeRightClickedCallNode } = this.props; + changeRightClickedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(callNodeIndex) + ); + }; + + _onCallNodeEnterOrDoubleClick = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + if (callNodeIndex === null) { + return; + } + const { callTree, updateBottomBoxContentsAndMaybeOpen } = this.props; + const bottomBoxInfo = callTree.getBottomBoxInfoForCallNode(callNodeIndex); + updateBottomBoxContentsAndMaybeOpen('flame-graph', bottomBoxInfo); + }; + + _onKeyboardTransformShortcut = ( + event: React.KeyboardEvent, + nodeIndex: IndexIntoCallNodeTable + ) => { + const { threadsKey, callNodeInfo, handleCallNodeTransformShortcut } = + this.props; + handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); + }; + + override render() { + const { + thread, + threadsKey, + maxStackDepthPlusOne, + flameGraphTiming, + callTree, + callNodeInfo, + timeRange, + previewSelection, + rightClickedCallNodeIndex, + selectedCallNodeIndex, + scrollToSelectionGeneration, + callTreeSummaryStrategy, + categories, + interval, + innerWindowIDToPageMap, + weightType, + ctssSamples, + ctssSampleCategoriesAndSubcategories, + tracedTiming, + displayStackType, + } = this.props; + + return ( + + ); + } +} + +export const ConnectedFlameGraph = explicitConnectWithForwardRef< + {}, + StateProps, + DispatchProps, + ConnectedFlameGraphHandle +>({ + mapStateToProps: (state) => ({ + thread: selectedThreadSelectors.getFilteredThread(state), + weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), + // Use the filtered call node max depth, rather than the preview filtered one, so + // that the viewport height is stable across preview selections. + maxStackDepthPlusOne: + selectedThreadSelectors.getFilteredCallNodeMaxDepthPlusOne(state), + flameGraphTiming: selectedThreadSelectors.getFlameGraphTiming(state), + callTree: selectedThreadSelectors.getCallTree(state), + timeRange: getCommittedRange(state), + previewSelection: getPreviewSelection(state), + callNodeInfo: selectedThreadSelectors.getCallNodeInfo(state), + categories: getCategories(state), + threadsKey: getSelectedThreadsKey(state), + selectedCallNodeIndex: + selectedThreadSelectors.getSelectedCallNodeIndex(state), + rightClickedCallNodeIndex: + selectedThreadSelectors.getRightClickedCallNodeIndex(state), + scrollToSelectionGeneration: getScrollToSelectionGeneration(state), + interval: getProfileInterval(state), + callTreeSummaryStrategy: + selectedThreadSelectors.getCallTreeSummaryStrategy(state), + innerWindowIDToPageMap: getInnerWindowIDToPageMap(state), + ctssSamples: selectedThreadSelectors.getPreviewFilteredCtssSamples(state), + ctssSampleCategoriesAndSubcategories: + selectedThreadSelectors.getPreviewFilteredCtssSampleCategoriesAndSubcategories( + state + ), + tracedTiming: selectedThreadSelectors.getTracedTiming(state), + displayStackType: getProfileUsesMultipleStackTypes(state), + }), + mapDispatchToProps: { + changeSelectedCallNode, + changeRightClickedCallNode, + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, + }, + options: { forwardRef: true }, + component: ConnectedFlameGraphImpl, +}); diff --git a/src/components/flame-graph/FlameGraph.tsx b/src/components/flame-graph/FlameGraph.tsx index bfccc38a93..968143e729 100644 --- a/src/components/flame-graph/FlameGraph.tsx +++ b/src/components/flame-graph/FlameGraph.tsx @@ -3,27 +3,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import * as React from 'react'; -import { explicitConnectWithForwardRef } from '../../utils/connect'; import { FlameGraphCanvas } from './Canvas'; - -import { - getCategories, - getCommittedRange, - getPreviewSelection, - getScrollToSelectionGeneration, - getProfileInterval, - getInnerWindowIDToPageMap, - getProfileUsesMultipleStackTypes, -} from 'firefox-profiler/selectors/profile'; -import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; -import { getSelectedThreadsKey } from '../../selectors/url-state'; import { ContextMenuTrigger } from 'firefox-profiler/components/shared/ContextMenuTrigger'; -import { - changeSelectedCallNode, - changeRightClickedCallNode, - handleCallNodeTransformShortcut, - updateBottomBoxContentsAndMaybeOpen, -} from 'firefox-profiler/actions/profile-view'; import { extractNonInvertedCallTreeTimings } from 'firefox-profiler/profile-logic/call-tree'; import { ensureExists } from 'firefox-profiler/utils/types'; @@ -51,8 +32,6 @@ import type { CallTreeTimings, } from 'firefox-profiler/profile-logic/call-tree'; -import type { ConnectedProps } from 'firefox-profiler/utils/connect'; - import './FlameGraph.css'; const STACK_FRAME_HEIGHT = 16; @@ -64,7 +43,7 @@ const STACK_FRAME_HEIGHT = 16; */ const SELECTABLE_THRESHOLD = 0.001; -type StateProps = { +export type Props = { readonly thread: Thread; readonly weightType: WeightType; readonly innerWindowIDToPageMap: Map | null; @@ -85,20 +64,27 @@ type StateProps = { readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; readonly tracedTiming: CallTreeTimings | null; readonly displayStackType: boolean; + readonly contextMenuId?: string; + readonly onSelectedCallNodeChange: ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => void; + readonly onRightClickedCallNodeChange: ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => void; + readonly onCallNodeEnterOrDoubleClick: ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => void; + readonly onKeyboardTransformShortcut: ( + event: React.KeyboardEvent, + nodeIndex: IndexIntoCallNodeTable + ) => void; }; -type DispatchProps = { - readonly changeSelectedCallNode: typeof changeSelectedCallNode; - readonly changeRightClickedCallNode: typeof changeRightClickedCallNode; - readonly handleCallNodeTransformShortcut: typeof handleCallNodeTransformShortcut; - readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; -}; -type Props = ConnectedProps<{}, StateProps, DispatchProps>; export interface FlameGraphHandle { focus(): void; } -class FlameGraphImpl +export class FlameGraph extends React.PureComponent implements FlameGraphHandle { @@ -112,44 +98,13 @@ class FlameGraphImpl document.removeEventListener('copy', this._onCopy, false); } - _onSelectedCallNodeChange = ( - callNodeIndex: IndexIntoCallNodeTable | null - ) => { - const { callNodeInfo, threadsKey, changeSelectedCallNode } = this.props; - changeSelectedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(callNodeIndex) - ); - }; - - _onRightClickedCallNodeChange = ( - callNodeIndex: IndexIntoCallNodeTable | null - ) => { - const { callNodeInfo, threadsKey, changeRightClickedCallNode } = this.props; - changeRightClickedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(callNodeIndex) - ); - }; - - _onCallNodeEnterOrDoubleClick = ( - callNodeIndex: IndexIntoCallNodeTable | null - ) => { - if (callNodeIndex === null) { - return; - } - const { callTree, updateBottomBoxContentsAndMaybeOpen } = this.props; - const bottomBoxInfo = callTree.getBottomBoxInfoForCallNode(callNodeIndex); - updateBottomBoxContentsAndMaybeOpen('flame-graph', bottomBoxInfo); - }; - _shouldDisplayTooltips = () => this.props.rightClickedCallNodeIndex === null; _takeViewportRef = (viewport: HTMLDivElement | null) => { this._viewport = viewport; }; - /* This method is called from MaybeFlameGraph. */ + /* This method is called from ConnectedFlameGraph. */ /* eslint-disable-next-line react/no-unused-class-component-methods */ focus = () => { if (this._viewport) { @@ -211,13 +166,13 @@ class FlameGraphImpl _handleKeyDown = (event: React.KeyboardEvent) => { const { - threadsKey, callTree, callNodeInfo, selectedCallNodeIndex, rightClickedCallNodeIndex, - changeSelectedCallNode, - handleCallNodeTransformShortcut, + onSelectedCallNodeChange, + onCallNodeEnterOrDoubleClick, + onKeyboardTransformShortcut, } = this.props; const callNodeTable = callNodeInfo.getCallNodeTable(); @@ -227,10 +182,7 @@ class FlameGraphImpl ) { if (selectedCallNodeIndex === null) { // Just select the "root" node if we've got no prior selection. - changeSelectedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(0) - ); + onSelectedCallNodeChange(0); return; } @@ -238,10 +190,7 @@ class FlameGraphImpl case 'ArrowDown': { const prefix = callNodeTable.prefix[selectedCallNodeIndex]; if (prefix !== -1) { - changeSelectedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(prefix) - ); + onSelectedCallNodeChange(prefix); } break; } @@ -253,10 +202,7 @@ class FlameGraphImpl // thus the widest box. if (callNodeIndex !== undefined && this._wideEnough(callNodeIndex)) { - changeSelectedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(callNodeIndex) - ); + onSelectedCallNodeChange(callNodeIndex); } break; } @@ -268,10 +214,7 @@ class FlameGraphImpl ); if (callNodeIndex !== undefined) { - changeSelectedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(callNodeIndex) - ); + onSelectedCallNodeChange(callNodeIndex); } break; } @@ -294,11 +237,11 @@ class FlameGraphImpl } if (event.key === 'Enter') { - this._onCallNodeEnterOrDoubleClick(nodeIndex); + onCallNodeEnterOrDoubleClick(nodeIndex); return; } - handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); + onKeyboardTransformShortcut(event, nodeIndex); }; _onCopy = (event: ClipboardEvent) => { @@ -338,6 +281,10 @@ class FlameGraphImpl ctssSampleCategoriesAndSubcategories, tracedTiming, displayStackType, + contextMenuId = 'CallNodeContextMenu', + onSelectedCallNodeChange, + onRightClickedCallNodeChange, + onCallNodeEnterOrDoubleClick, } = this.props; // Get the CallTreeTimingsNonInverted out of tracedTiming. We pass this @@ -358,7 +305,7 @@ class FlameGraphImpl return (
({ - mapStateToProps: (state) => ({ - thread: selectedThreadSelectors.getFilteredThread(state), - weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), - // Use the filtered call node max depth, rather than the preview filtered one, so - // that the viewport height is stable across preview selections. - maxStackDepthPlusOne: - selectedThreadSelectors.getFilteredCallNodeMaxDepthPlusOne(state), - flameGraphTiming: selectedThreadSelectors.getFlameGraphTiming(state), - callTree: selectedThreadSelectors.getCallTree(state), - timeRange: getCommittedRange(state), - previewSelection: getPreviewSelection(state), - callNodeInfo: selectedThreadSelectors.getCallNodeInfo(state), - categories: getCategories(state), - threadsKey: getSelectedThreadsKey(state), - selectedCallNodeIndex: - selectedThreadSelectors.getSelectedCallNodeIndex(state), - rightClickedCallNodeIndex: - selectedThreadSelectors.getRightClickedCallNodeIndex(state), - scrollToSelectionGeneration: getScrollToSelectionGeneration(state), - interval: getProfileInterval(state), - callTreeSummaryStrategy: - selectedThreadSelectors.getCallTreeSummaryStrategy(state), - innerWindowIDToPageMap: getInnerWindowIDToPageMap(state), - ctssSamples: selectedThreadSelectors.getPreviewFilteredCtssSamples(state), - ctssSampleCategoriesAndSubcategories: - selectedThreadSelectors.getPreviewFilteredCtssSampleCategoriesAndSubcategories( - state - ), - tracedTiming: selectedThreadSelectors.getTracedTiming(state), - displayStackType: getProfileUsesMultipleStackTypes(state), - }), - mapDispatchToProps: { - changeSelectedCallNode, - changeRightClickedCallNode, - handleCallNodeTransformShortcut, - updateBottomBoxContentsAndMaybeOpen, - }, - options: { forwardRef: true }, - component: FlameGraphImpl, -}); diff --git a/src/components/flame-graph/MaybeFlameGraph.css b/src/components/flame-graph/MaybeFlameGraph.css deleted file mode 100644 index fcc1267de5..0000000000 --- a/src/components/flame-graph/MaybeFlameGraph.css +++ /dev/null @@ -1,19 +0,0 @@ -.flameGraphDisabledMessage { - --internal-background-color: var(--grey-20); - - display: flex; - flex: 1; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 10px; - background-color: var(--internal-background-color); - box-shadow: inset 0 0 150px var(--base-shadow-color); - font-size: 120%; -} - -:root.dark-mode { - .flameGraphDisabledMessage { - --internal-background-color: var(--grey-80); - } -} diff --git a/src/components/flame-graph/MaybeFlameGraph.tsx b/src/components/flame-graph/MaybeFlameGraph.tsx deleted file mode 100644 index 5dd0f0f227..0000000000 --- a/src/components/flame-graph/MaybeFlameGraph.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import * as React from 'react'; - -import { explicitConnectWithForwardRef } from 'firefox-profiler/utils/connect'; -import { getInvertCallstack } from '../../selectors/url-state'; -import { selectedThreadSelectors } from '../../selectors/per-thread'; -import { changeInvertCallstack } from '../../actions/profile-view'; -import { FlameGraphEmptyReasons } from './FlameGraphEmptyReasons'; -import { FlameGraph, type FlameGraphHandle } from './FlameGraph'; - -import type { ConnectedProps } from 'firefox-profiler/utils/connect'; - -import './MaybeFlameGraph.css'; - -// TODO: This component isn't needed any more. Whenever the selected tab -// is "flame-graph", `invertCallstack` will be `false`. is -// only used in the "flame-graph" tab. - -type StateProps = { - readonly isPreviewSelectionEmpty: boolean; - readonly invertCallstack: boolean; -}; -type DispatchProps = { - readonly changeInvertCallstack: typeof changeInvertCallstack; -}; -type Props = ConnectedProps<{}, StateProps, DispatchProps>; - -class MaybeFlameGraphImpl extends React.PureComponent { - _flameGraph: React.RefObject = React.createRef(); - - _onSwitchToNormalCallstackClick = () => { - this.props.changeInvertCallstack(false); - }; - - override componentDidMount() { - const flameGraph = this._flameGraph.current; - if (flameGraph) { - flameGraph.focus(); - } - } - - override render() { - const { isPreviewSelectionEmpty, invertCallstack } = this.props; - - if (isPreviewSelectionEmpty) { - return ; - } - - if (invertCallstack) { - return ( -
-

The Flame Graph is not available for inverted call stacks

-

- {' '} - to show the Flame Graph. -

-
- ); - } - return ; - } -} - -export const MaybeFlameGraph = explicitConnectWithForwardRef< - {}, - StateProps, - DispatchProps, - MaybeFlameGraphImpl ->({ - mapStateToProps: (state) => { - return { - invertCallstack: getInvertCallstack(state), - isPreviewSelectionEmpty: - !selectedThreadSelectors.getHasPreviewFilteredCtssSamples(state), - }; - }, - mapDispatchToProps: { - changeInvertCallstack, - }, - component: MaybeFlameGraphImpl, -}); diff --git a/src/components/flame-graph/index.tsx b/src/components/flame-graph/index.tsx index 141147302c..70af6f60c0 100644 --- a/src/components/flame-graph/index.tsx +++ b/src/components/flame-graph/index.tsx @@ -2,21 +2,63 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import * as React from 'react'; + import { StackSettings } from '../shared/StackSettings'; import { TransformNavigator } from '../shared/TransformNavigator'; -import { MaybeFlameGraph } from './MaybeFlameGraph'; - -const FlameGraphView = () => ( -
- - - -
-); - -export const FlameGraph = FlameGraphView; +import { + ConnectedFlameGraph, + type ConnectedFlameGraphHandle, +} from './ConnectedFlameGraph'; +import { FlameGraphEmptyReasons } from './FlameGraphEmptyReasons'; +import explicitConnect from 'firefox-profiler/utils/connect'; +import { selectedThreadSelectors } from '../../selectors/per-thread'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +type StateProps = { + readonly isPreviewSelectionEmpty: boolean; +}; + +type Props = ConnectedProps<{}, StateProps, {}>; + +class FlameGraphViewImpl extends React.PureComponent { + _connectedFlameGraph: React.RefObject = + React.createRef(); + + override componentDidMount() { + this._connectedFlameGraph.current?.focus(); + } + + override render() { + const { isPreviewSelectionEmpty } = this.props; + + return ( +
+ + + {isPreviewSelectionEmpty ? ( + + ) : ( + + )} +
+ ); + } +} + +const FlameGraphViewConnected = explicitConnect<{}, StateProps, {}>({ + mapStateToProps: (state) => ({ + isPreviewSelectionEmpty: + !selectedThreadSelectors.getHasPreviewFilteredCtssSamples(state), + }), + mapDispatchToProps: {}, + component: FlameGraphViewImpl, +}); + +export const FlameGraph = FlameGraphViewConnected; From bc0ab9f968c0794bf6869f76db360009e0392021 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 12:06:31 -0400 Subject: [PATCH 04/19] Convert FlameGraphTiming into a class with a getRow() method. This lets us build the timing rows lazily, as the flame graph scrolls them into view. Building the rows isn't that expensive with the regular flame graph, but it'll be more expensive for the inverted "calls to the selected function" icicle flame graph that I'm planning to add to the function list panel. --- src/components/flame-graph/Canvas.tsx | 21 ++- src/components/flame-graph/FlameGraph.tsx | 4 +- src/profile-logic/flame-graph.ts | 45 ++++++- .../__snapshots__/profile-view.test.ts.snap | 126 +++++++++--------- src/test/store/actions.test.ts | 7 +- src/test/store/profile-view.test.ts | 10 +- 6 files changed, 127 insertions(+), 86 deletions(-) diff --git a/src/components/flame-graph/Canvas.tsx b/src/components/flame-graph/Canvas.tsx index 2c5f3745cf..73dd9394da 100644 --- a/src/components/flame-graph/Canvas.tsx +++ b/src/components/flame-graph/Canvas.tsx @@ -243,13 +243,13 @@ class FlameGraphCanvasImpl extends React.PureComponent { // Only draw the stack frames that are vertically within view. // The graph is drawn from bottom to top, in order of increasing depth. for (let depth = startDepth; depth < endDepth; depth++) { - // Get the timing information for a row of stack frames. - const stackTiming = flameGraphTiming[depth]; - - if (!stackTiming) { + if (depth < 0 || depth >= flameGraphTiming.rowCount) { continue; } + // Get the timing information for a row of stack frames. + const stackTiming = flameGraphTiming.getRow(depth); + const cssRowTop: CssPixels = (maxStackDepthPlusOne - depth - 1) * ROW_HEIGHT - viewportTop; const cssRowBottom: CssPixels = @@ -372,10 +372,11 @@ class FlameGraphCanvasImpl extends React.PureComponent { return null; } - const stackTiming = flameGraphTiming[depth]; - if (!stackTiming) { + if (depth < 0 || depth >= flameGraphTiming.rowCount) { return null; } + + const stackTiming = flameGraphTiming.getRow(depth); const callNodeIndex = stackTiming.callNode[flameGraphTimingIndex]; if (callNodeIndex === undefined) { return null; @@ -442,7 +443,7 @@ class FlameGraphCanvasImpl extends React.PureComponent { const { depth, flameGraphTimingIndex } = hoveredItem; const { flameGraphTiming } = this.props; - const stackTiming = flameGraphTiming[depth]; + const stackTiming = flameGraphTiming.getRow(depth); const callNodeIndex = stackTiming.callNode[flameGraphTimingIndex]; return callNodeIndex; } @@ -476,12 +477,10 @@ class FlameGraphCanvasImpl extends React.PureComponent { const depth = Math.floor( maxStackDepthPlusOne - (y + viewportTop) / ROW_HEIGHT ); - const stackTiming = flameGraphTiming[depth]; - - if (!stackTiming) { + if (depth < 0 || depth >= flameGraphTiming.rowCount) { return null; } - + const stackTiming = flameGraphTiming.getRow(depth); for (let i = 0; i < stackTiming.length; i++) { const start = stackTiming.start[i]; const end = stackTiming.end[i]; diff --git a/src/components/flame-graph/FlameGraph.tsx b/src/components/flame-graph/FlameGraph.tsx index bfccc38a93..434efd4a91 100644 --- a/src/components/flame-graph/FlameGraph.tsx +++ b/src/components/flame-graph/FlameGraph.tsx @@ -165,7 +165,7 @@ class FlameGraphImpl const callNodeTable = callNodeInfo.getCallNodeTable(); const depth = callNodeTable.depth[callNodeIndex]; - const row = flameGraphTiming[depth]; + const row = flameGraphTiming.getRow(depth); const columnIndex = row.callNode.indexOf(callNodeIndex); return row.end[columnIndex] - row.start[columnIndex] > SELECTABLE_THRESHOLD; }; @@ -190,7 +190,7 @@ class FlameGraphImpl const callNodeTable = callNodeInfo.getCallNodeTable(); const depth = callNodeTable.depth[callNodeIndex]; - const row = flameGraphTiming[depth]; + const row = flameGraphTiming.getRow(depth); let columnIndex = row.callNode.indexOf(callNodeIndex); do { diff --git a/src/profile-logic/flame-graph.ts b/src/profile-logic/flame-graph.ts index 2cf8e3701d..510ed94fdd 100644 --- a/src/profile-logic/flame-graph.ts +++ b/src/profile-logic/flame-graph.ts @@ -16,9 +16,8 @@ export type FlameGraphDepth = number; export type IndexIntoFlameGraphTiming = number; /** - * FlameGraphTiming is an array containing data used for rendering the - * flame graph. Each element in the array describes one row in the - * graph. Each such element in turn contains one or more functions, + * FlameGraphTimingRow contains the data used for rendering a single + * row of the flame graph. Each row contains one or more functions, * drawn as boxes with start and end positions, represented as unit * intervals of the profile range. It should be noted that start and * end does not represent units of time, but only positions on the @@ -30,13 +29,47 @@ export type IndexIntoFlameGraphTiming = number; * selfRelative contains the self time relative to the total time, * which is used to color the drawn functions. */ -export type FlameGraphTiming = Array<{ +export type FlameGraphTimingRow = { start: UnitIntervalOfProfileRange[]; end: UnitIntervalOfProfileRange[]; selfRelative: Array; callNode: IndexIntoCallNodeTable[]; length: number; -}>; +}; + +/** + * Used by the flame graph canvas to know which boxes to render where. + * + * The flame graph only calls getRow(depth) for on-screen rows; this allows + * the implementation to generate rows lazily as the user scrolls towards + * deeper calls. + */ +export class FlameGraphTiming { + _rows: FlameGraphTimingRow[]; + + constructor(rows: FlameGraphTimingRow[]) { + this._rows = rows; + } + + get rowCount(): number { + return this._rows.length; + } + + getRow(depth: number): FlameGraphTimingRow { + if (depth < 0 || depth >= this.rowCount) { + throw new Error( + `Out-of-bounds call to getRow: ${depth} is outside 0..${this.rowCount}` + ); + } + + return this._rows[depth]; + } + + // Convenience method for tests, don't call in production + getAllRowsForTesting(): FlameGraphTimingRow[] { + return this._rows; + } +} /** * FlameGraphRows is an array of rows, where each row is an array of call node @@ -305,5 +338,5 @@ export function getFlameGraphTiming( }; } - return timing; + return new FlameGraphTiming(timing); } diff --git a/src/test/store/__snapshots__/profile-view.test.ts.snap b/src/test/store/__snapshots__/profile-view.test.ts.snap index 0e69b4671b..9bf40c02a2 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -3181,68 +3181,70 @@ Object { `; exports[`snapshots of selectors/profile matches the last stored run of selectedThreadSelector.getFlameGraphTiming 1`] = ` -Array [ - Object { - "callNode": Array [ - 0, - ], - "end": Array [ - 1, - ], - "length": 1, - "selfRelative": Array [ - 0, - ], - "start": Array [ - 0, - ], - }, - Object { - "callNode": Array [ - 1, - ], - "end": Array [ - 1, - ], - "length": 1, - "selfRelative": Array [ - 0, - ], - "start": Array [ - 0, - ], - }, - Object { - "callNode": Array [ - 5, - ], - "end": Array [ - 1, - ], - "length": 1, - "selfRelative": Array [ - 0, - ], - "start": Array [ - 0, - ], - }, - Object { - "callNode": Array [ - 6, - ], - "end": Array [ - 1, - ], - "length": 1, - "selfRelative": Array [ - 1, - ], - "start": Array [ - 0, - ], - }, -] +Object { + "rows": Array [ + Object { + "callNode": Array [ + 0, + ], + "end": Array [ + 1, + ], + "length": 1, + "selfRelative": Array [ + 0, + ], + "start": Array [ + 0, + ], + }, + Object { + "callNode": Array [ + 1, + ], + "end": Array [ + 1, + ], + "length": 1, + "selfRelative": Array [ + 0, + ], + "start": Array [ + 0, + ], + }, + Object { + "callNode": Array [ + 5, + ], + "end": Array [ + 1, + ], + "length": 1, + "selfRelative": Array [ + 0, + ], + "start": Array [ + 0, + ], + }, + Object { + "callNode": Array [ + 6, + ], + "end": Array [ + 1, + ], + "length": 1, + "selfRelative": Array [ + 1, + ], + "start": Array [ + 0, + ], + }, + ], +} `; exports[`snapshots of selectors/profile matches the last stored run of selectedThreadSelector.getJankMarkersForHeader 1`] = ` diff --git a/src/test/store/actions.test.ts b/src/test/store/actions.test.ts index 7c2b2f17c6..4d26391777 100644 --- a/src/test/store/actions.test.ts +++ b/src/test/store/actions.test.ts @@ -206,7 +206,9 @@ describe('selectors/getFlameGraphTiming', function () { store.getState() ); - return flameGraphTiming.map(({ callNode, end, length, start }) => { + const flameGraphTimingRows = flameGraphTiming.getAllRowsForTesting(); + + return flameGraphTimingRows.map(({ callNode, end, length, start }) => { const lines = []; for (let i = 0; i < length; i++) { const callNodeIndex = callNode[i]; @@ -239,8 +241,9 @@ describe('selectors/getFlameGraphTiming', function () { const flameGraphTiming = selectedThreadSelectors.getFlameGraphTiming( store.getState() ); + const flameGraphTimingRows = flameGraphTiming.getAllRowsForTesting(); - return flameGraphTiming.map(({ selfRelative, callNode, length }) => { + return flameGraphTimingRows.map(({ selfRelative, callNode, length }) => { const lines = []; for (let i = 0; i < length; i++) { const callNodeIndex = callNode[i]; diff --git a/src/test/store/profile-view.test.ts b/src/test/store/profile-view.test.ts index 2b830e08c5..9fda33ec65 100644 --- a/src/test/store/profile-view.test.ts +++ b/src/test/store/profile-view.test.ts @@ -2385,9 +2385,13 @@ describe('snapshots of selectors/profile', function () { it('matches the last stored run of selectedThreadSelector.getFlameGraphTiming', function () { const { getState } = setupStore(); - expect( - selectedThreadSelectors.getFlameGraphTiming(getState()) - ).toMatchSnapshot(); + const timing = selectedThreadSelectors.getFlameGraphTiming(getState()); + // Build a plain shape with the flame graph timing contents, so that the + // snapshot doesn't contain the class name / class fields. + const plainTiming = { + rows: timing.getAllRowsForTesting(), + }; + expect(plainTiming).toMatchSnapshot(); }); it('matches the last stored run of selectedThreadSelector.getFriendlyThreadName', function () { From e1da1db0a0c36fe479f710909df271255a701638 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 13:26:06 -0400 Subject: [PATCH 05/19] Compute FlameGraphTiming rows lazily. This takes advantage of the fact that the flame graph now requests a row only once it's on the screen. Note that there's a difference between "FlameGraphRows" and "FlameGraphTiming" rows. The FlameGraphRows are still computed eagerly. It's just the timing that is now computed lazily per displayed row. The FlameGraphRows are based on the call node info and independent of sample counts and preview selection. When switching to the Flame Graph panel on https://share.firefox.dev/4g4xGue , with a flame graph canvas height of 564px, I'm getting the following profiles: Before: https://share.firefox.dev/4w2QEG3 (410ms tab switch) After: https://share.firefox.dev/3QYB7IA (272ms tab switch) In those profiles you can also see the different memory usage characteristics. --- src/profile-logic/flame-graph.ts | 176 ++++++++++++++++--------------- 1 file changed, 93 insertions(+), 83 deletions(-) diff --git a/src/profile-logic/flame-graph.ts b/src/profile-logic/flame-graph.ts index 510ed94fdd..b6810c9833 100644 --- a/src/profile-logic/flame-graph.ts +++ b/src/profile-logic/flame-graph.ts @@ -45,14 +45,32 @@ export type FlameGraphTimingRow = { * deeper calls. */ export class FlameGraphTiming { - _rows: FlameGraphTimingRow[]; - - constructor(rows: FlameGraphTimingRow[]) { - this._rows = rows; + _flameGraphRows: FlameGraphRows; + _callNodeTable: CallNodeTable; + _callTreeTimings: CallTreeTimingsNonInverted; + + // Populated lazily by _buildNextTimingRow(). + _timingRows: FlameGraphTimingRow[]; + + // Used to position the children call node boxes: For a given parent box, its + // first (left-most) child box starts at the same x position as the parent. + _startPerCallNode: Float32Array; + + constructor( + flameGraphRows: FlameGraphRows, + callNodeTable: CallNodeTable, + callTreeTimings: CallTreeTimingsNonInverted + ) { + this._flameGraphRows = flameGraphRows; + this._callNodeTable = callNodeTable; + this._callTreeTimings = callTreeTimings; + + this._timingRows = []; + this._startPerCallNode = new Float32Array(callNodeTable.length); } get rowCount(): number { - return this._rows.length; + return this._flameGraphRows.length; } getRow(depth: number): FlameGraphTimingRow { @@ -61,13 +79,78 @@ export class FlameGraphTiming { `Out-of-bounds call to getRow: ${depth} is outside 0..${this.rowCount}` ); } - - return this._rows[depth]; + while (this._timingRows.length <= depth) { + this._buildNextTimingRow(); + } + return this._timingRows[depth]; } // Convenience method for tests, don't call in production getAllRowsForTesting(): FlameGraphTimingRow[] { - return this._rows; + const rows = []; + for (let depth = 0; depth < this.rowCount; depth++) { + rows.push(this.getRow(depth)!); + } + return rows; + } + + _buildNextTimingRow(): void { + const { total, self, rootTotalSummary } = this._callTreeTimings; + const { prefix } = this._callNodeTable; + + const depth = this._timingRows.length; + const rowNodes = this._flameGraphRows[depth]; + const startPerCallNode = this._startPerCallNode; + + // Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1858310 + const abs = Math.abs; + + const start: UnitIntervalOfProfileRange[] = []; + const end: UnitIntervalOfProfileRange[] = []; + const selfRelative: number[] = []; + const timingCallNodes: IndexIntoCallNodeTable[] = []; + + // Sibling boxes are adjacent. Whenever the prefix changes, jump ahead to + // the new parent's start so children stay aligned under their parent. + // + // Previous row: [B ][D ] [G ] + // Current row: [C][E][F] [I ] + // (Note: upside down from how the flame graph is usually displayed.) + let currentStart = 0; + let previousPrefixCallNode = -1; + for (let i = 0; i < rowNodes.length; i++) { + const nodeIndex = rowNodes[i]; + const totalVal = total[nodeIndex]; + if (totalVal === 0) { + continue; + } + + const nodePrefix = prefix[nodeIndex]; + if (nodePrefix !== previousPrefixCallNode) { + currentStart = nodePrefix === -1 ? 0 : startPerCallNode[nodePrefix]; + previousPrefixCallNode = nodePrefix; + } + startPerCallNode[nodeIndex] = currentStart; + + const totalRelative = abs(totalVal / rootTotalSummary); + const selfRelativeVal = abs(self[nodeIndex] / rootTotalSummary); + + const currentEnd = currentStart + totalRelative; + start.push(currentStart); + end.push(currentEnd); + selfRelative.push(selfRelativeVal); + timingCallNodes.push(nodeIndex); + + currentStart = currentEnd; + } + + this._timingRows.push({ + start, + end, + selfRelative, + callNode: timingCallNodes, + length: timingCallNodes.length, + }); } } @@ -258,85 +341,12 @@ export function computeFlameGraphRows( } /** - * Build a FlameGraphTiming table from a call tree. + * Build a FlameGraphTiming from a call tree. */ export function getFlameGraphTiming( flameGraphRows: FlameGraphRows, callNodeTable: CallNodeTable, callTreeTimings: CallTreeTimingsNonInverted ): FlameGraphTiming { - const { total, self, rootTotalSummary } = callTreeTimings; - const { prefix } = callNodeTable; - - // This is where we build up the return value, one row at a time. - const timing = []; - - // This is used to adjust the start position of a call node's box based on the - // start position of its prefix node's box. - const startPerCallNode = new Float32Array(callNodeTable.length); - - // Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1858310 - const abs = Math.abs; - - // Go row by row. - for (let depth = 0; depth < flameGraphRows.length; depth++) { - const rowNodes = flameGraphRows[depth]; - - const start = []; - const end = []; - const selfRelative = []; - const timingCallNodes = []; - - // Process the call nodes in this row. Sibling boxes are adjacent to each other. - // Whenever the prefix changes, we need to add a gap so that the child boxes - // start at the same position as the parent box. - // - // Previous row: [B ][D ] [G ] - // Current row: [C][E][F] [I ] - // (Note that this is upside down from how the flame graph is usually displayed) - let currentStart = 0; - let previousPrefixCallNode = -1; - for (let indexInRow = 0; indexInRow < rowNodes.length; indexInRow++) { - const nodeIndex = rowNodes[indexInRow]; - const totalVal = total[nodeIndex]; - if (totalVal === 0) { - // Skip boxes with zero width. - continue; - } - - const nodePrefix = prefix[nodeIndex]; - if (nodePrefix !== previousPrefixCallNode) { - // We have advanced to a node with a different parent, so we need to - // jump ahead to the parent box's start position. - currentStart = startPerCallNode[nodePrefix]; - previousPrefixCallNode = nodePrefix; - } - - // Write down the start position of this call node so that it can be - // checked later by this node's children. - startPerCallNode[nodeIndex] = currentStart; - - // Take the absolute value, as native deallocations can be negative. - const totalRelativeVal = abs(totalVal / rootTotalSummary); - const selfRelativeVal = abs(self[nodeIndex] / rootTotalSummary); - - const currentEnd = currentStart + totalRelativeVal; - start.push(currentStart); - end.push(currentEnd); - selfRelative.push(selfRelativeVal); - timingCallNodes.push(nodeIndex); - - // The start position of the next box is the end position of the current box. - currentStart = currentEnd; - } - timing[depth] = { - start, - end, - selfRelative, - callNode: timingCallNodes, - length: timingCallNodes.length, - }; - } - - return new FlameGraphTiming(timing); + return new FlameGraphTiming(flameGraphRows, callNodeTable, callTreeTimings); } From 864ccb825665deb7fce3a6e1ea22126de01dfb1c Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:56:12 -0400 Subject: [PATCH 06/19] Add a startsAtBottom prop to FlameGraph. startsAtBottom={true} is the regular flame graph layout. startsAtBottom={false} can be used for icicle-style flame graphs. --- src/components/flame-graph/Canvas.tsx | 52 ++++++++----- .../flame-graph/ConnectedFlameGraph.tsx | 1 + src/components/flame-graph/FlameGraph.tsx | 76 ++++++++++--------- 3 files changed, 76 insertions(+), 53 deletions(-) diff --git a/src/components/flame-graph/Canvas.tsx b/src/components/flame-graph/Canvas.tsx index 252add681a..7d40744d9b 100644 --- a/src/components/flame-graph/Canvas.tsx +++ b/src/components/flame-graph/Canvas.tsx @@ -70,6 +70,7 @@ export type OwnProps = { readonly scrollToSelectionGeneration: number; readonly categories: CategoryList; readonly interval: Milliseconds; + readonly startsAtBottom: boolean; readonly callTreeSummaryStrategy: CallTreeSummaryStrategy; readonly ctssSamples: SamplesLikeTable; readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; @@ -123,8 +124,12 @@ class FlameGraphCanvasImpl extends React.PureComponent { // If the stack depth changes (say, when changing the time range // selection or applying a transform), move the viewport // vertically so that its offset from the base of the flame graph - // is maintained. - if (prevProps.maxStackDepthPlusOne !== this.props.maxStackDepthPlusOne) { + // is maintained. In top-down layout the base is at the top, so no + // adjustment is needed when depth grows or shrinks. + if ( + this.props.startsAtBottom && + prevProps.maxStackDepthPlusOne !== this.props.maxStackDepthPlusOne + ) { this.props.viewport.moveViewport( 0, (prevProps.maxStackDepthPlusOne - this.props.maxStackDepthPlusOne) * @@ -150,15 +155,21 @@ class FlameGraphCanvasImpl extends React.PureComponent { } _scrollSelectionIntoView = () => { - const { selectedCallNodeIndex, maxStackDepthPlusOne, callNodeInfo } = - this.props; + const { + selectedCallNodeIndex, + maxStackDepthPlusOne, + callNodeInfo, + startsAtBottom, + } = this.props; if (selectedCallNodeIndex === null) { return; } const depth = callNodeInfo.depthForNode(selectedCallNodeIndex); - const y = (maxStackDepthPlusOne - depth - 1) * ROW_HEIGHT; + const y = startsAtBottom + ? (maxStackDepthPlusOne - depth - 1) * ROW_HEIGHT + : depth * ROW_HEIGHT; if (y < this.props.viewport.viewportTop) { this.props.viewport.moveViewport(0, this.props.viewport.viewportTop - y); @@ -190,6 +201,7 @@ class FlameGraphCanvasImpl extends React.PureComponent { viewportTop, viewportBottom, }, + startsAtBottom, } = this.props; const { hoveredItem } = hoverInfo; @@ -230,12 +242,12 @@ class FlameGraphCanvasImpl extends React.PureComponent { fastFillStyle.set(getBackgroundColor()); ctx.fillRect(0, 0, deviceContainerWidth, deviceContainerHeight); - const startDepth = Math.floor( - maxStackDepthPlusOne - viewportBottom / stackFrameHeight - ); - const endDepth = Math.ceil( - maxStackDepthPlusOne - viewportTop / stackFrameHeight - ); + const startDepth = startsAtBottom + ? Math.floor(maxStackDepthPlusOne - viewportBottom / stackFrameHeight) + : Math.floor(viewportTop / stackFrameHeight); + const endDepth = startsAtBottom + ? Math.ceil(maxStackDepthPlusOne - viewportTop / stackFrameHeight) + : Math.ceil(viewportBottom / stackFrameHeight); // Only draw the stack frames that are vertically within view. // The graph is drawn from bottom to top, in order of increasing depth. @@ -247,10 +259,12 @@ class FlameGraphCanvasImpl extends React.PureComponent { // Get the timing information for a row of stack frames. const stackTiming = flameGraphTiming.getRow(depth); - const cssRowTop: CssPixels = - (maxStackDepthPlusOne - depth - 1) * ROW_HEIGHT - viewportTop; - const cssRowBottom: CssPixels = - (maxStackDepthPlusOne - depth) * ROW_HEIGHT - viewportTop; + const cssRowTop: CssPixels = startsAtBottom + ? (maxStackDepthPlusOne - depth - 1) * ROW_HEIGHT - viewportTop + : depth * ROW_HEIGHT - viewportTop; + const cssRowBottom: CssPixels = startsAtBottom + ? (maxStackDepthPlusOne - depth) * ROW_HEIGHT - viewportTop + : (depth + 1) * ROW_HEIGHT - viewportTop; const deviceRowTop: DevicePixels = snap(cssRowTop * cssToDeviceScale); const deviceRowBottom: DevicePixels = snap(cssRowBottom * cssToDeviceScale) - 1; @@ -469,11 +483,13 @@ class FlameGraphCanvasImpl extends React.PureComponent { flameGraphTiming, maxStackDepthPlusOne, viewport: { viewportTop, containerWidth }, + startsAtBottom, } = this.props; const pos = x / containerWidth; - const depth = Math.floor( - maxStackDepthPlusOne - (y + viewportTop) / ROW_HEIGHT - ); + const depth = startsAtBottom + ? Math.floor(maxStackDepthPlusOne - (y + viewportTop) / ROW_HEIGHT) + : Math.floor((y + viewportTop) / ROW_HEIGHT); + if (depth < 0 || depth >= flameGraphTiming.rowCount) { return null; } diff --git a/src/components/flame-graph/ConnectedFlameGraph.tsx b/src/components/flame-graph/ConnectedFlameGraph.tsx index 7701609abf..e8d9fd8a4a 100644 --- a/src/components/flame-graph/ConnectedFlameGraph.tsx +++ b/src/components/flame-graph/ConnectedFlameGraph.tsx @@ -179,6 +179,7 @@ class ConnectedFlameGraphImpl scrollToSelectionGeneration={scrollToSelectionGeneration} categories={categories} interval={interval} + startsAtBottom={true} callTreeSummaryStrategy={callTreeSummaryStrategy} ctssSamples={ctssSamples} ctssSampleCategoriesAndSubcategories={ diff --git a/src/components/flame-graph/FlameGraph.tsx b/src/components/flame-graph/FlameGraph.tsx index b241b973e1..dd6d888341 100644 --- a/src/components/flame-graph/FlameGraph.tsx +++ b/src/components/flame-graph/FlameGraph.tsx @@ -59,6 +59,7 @@ export type Props = { readonly scrollToSelectionGeneration: number; readonly categories: CategoryList; readonly interval: Milliseconds; + readonly startsAtBottom: boolean; readonly callTreeSummaryStrategy: CallTreeSummaryStrategy; readonly ctssSamples: SamplesLikeTable; readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; @@ -171,6 +172,7 @@ export class FlameGraph onSelectedCallNodeChange, onCallNodeEnterOrDoubleClick, onKeyboardTransformShortcut, + startsAtBottom, } = this.props; if ( @@ -183,43 +185,45 @@ export class FlameGraph return; } - switch (event.key) { - case 'ArrowDown': { - const prefix = callNodeInfo.prefixForNode(selectedCallNodeIndex); - if (prefix !== -1) { - onSelectedCallNodeChange(prefix); - } - break; + // In top-down layout the parent is visually above the selected box, so + // ArrowUp navigates to the parent and ArrowDown to the first child. + // In bottom-up layout it's the other way around. + const isGoToParent = startsAtBottom + ? event.key === 'ArrowDown' + : event.key === 'ArrowUp'; + const isGoToChild = startsAtBottom + ? event.key === 'ArrowUp' + : event.key === 'ArrowDown'; + + if (isGoToParent) { + const prefix = callNodeInfo.prefixForNode(selectedCallNodeIndex); + if (prefix !== -1) { + onSelectedCallNodeChange(prefix); } - case 'ArrowUp': { - const [callNodeIndex] = callTree.getChildren(selectedCallNodeIndex); - // The call nodes returned from getChildren are sorted by - // total time in descending order. The first one in the - // array, which is the one we pick, has the longest time and - // thus the widest box. - - if (callNodeIndex !== undefined && this._wideEnough(callNodeIndex)) { - onSelectedCallNodeChange(callNodeIndex); - } - break; + } else if (isGoToChild) { + const [callNodeIndex] = callTree.getChildren(selectedCallNodeIndex); + // The call nodes returned from getChildren are sorted by + // total time in descending order. The first one in the + // array, which is the one we pick, has the longest time and + // thus the widest box. + + if (callNodeIndex !== undefined && this._wideEnough(callNodeIndex)) { + onSelectedCallNodeChange(callNodeIndex); } - case 'ArrowLeft': - case 'ArrowRight': { - const callNodeIndex = this._nextSelectableInRow( - selectedCallNodeIndex, - event.key === 'ArrowLeft' ? -1 : 1 - ); - - if (callNodeIndex !== undefined) { - onSelectedCallNodeChange(callNodeIndex); - } - break; + } else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + const callNodeIndex = this._nextSelectableInRow( + selectedCallNodeIndex, + event.key === 'ArrowLeft' ? -1 : 1 + ); + + if (callNodeIndex !== undefined) { + onSelectedCallNodeChange(callNodeIndex); } - default: - // We shouldn't arrive here, thanks to the if block at the top. - console.error( - `An unknown key "${event.key}" was pressed, this shouldn't happen.` - ); + } else { + // We shouldn't arrive here, thanks to the if block at the top. + console.error( + `An unknown key "${event.key}" was pressed, this shouldn't happen.` + ); } return; } @@ -271,6 +275,7 @@ export class FlameGraph callTreeSummaryStrategy, categories, interval, + startsAtBottom, innerWindowIDToPageMap, weightType, ctssSamples, @@ -314,7 +319,7 @@ export class FlameGraph maxViewportHeight, maximumZoom: 1, previewSelection, - startsAtBottom: true, + startsAtBottom, disableHorizontalMovement: true, viewportNeedsUpdate, marginLeft: 0, @@ -341,6 +346,7 @@ export class FlameGraph onDoubleClick: onCallNodeEnterOrDoubleClick, shouldDisplayTooltips: this._shouldDisplayTooltips, interval, + startsAtBottom, ctssSamples, ctssSampleCategoriesAndSubcategories, tracedTiming: tracedTimingNonInverted, From b874f289108ea65fd2c4a70249731a3872fbfdce Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:53:03 -0400 Subject: [PATCH 07/19] Add "open bottom box for function X" support code. This will be used by the function list when double-clicking on a function. --- src/profile-logic/bottom-box.ts | 108 ++++++++++++++++++++++++++++++ src/profile-logic/call-tree.ts | 13 +++- src/profile-logic/profile-data.ts | 41 ++++++++++++ src/test/store/bottom-box.test.ts | 83 ++++++++++++++++++++++- 4 files changed, 243 insertions(+), 2 deletions(-) diff --git a/src/profile-logic/bottom-box.ts b/src/profile-logic/bottom-box.ts index cbd935ed8f..45ab325fef 100644 --- a/src/profile-logic/bottom-box.ts +++ b/src/profile-logic/bottom-box.ts @@ -8,12 +8,14 @@ import type { Thread, IndexIntoStackTable, IndexIntoCallNodeTable, + IndexIntoFuncTable, BottomBoxInfo, SamplesLikeTable, } from 'firefox-profiler/types'; import type { CallNodeInfo } from './call-node-info'; import { getCallNodeFramePerStack, + getFunctionFramePerStack, getNativeSymbolInfo, getNativeSymbolsForCallNode, getOriginalPositionForFrame, @@ -202,3 +204,109 @@ export function getBottomBoxInfoForStackFrame( instructionAddress !== -1 ? instructionAddress : null, }; } + +/** + * Calculate the BottomBoxInfo for a function, i.e. information about which + * things should be shown in the profiler UI's "bottom box" when a function is + * double-clicked in the function list. + * + * Unlike getBottomBoxInfoForCallNode, this considers all stacks where the + * function appears anywhere (not just as the self function), using the + * innermost (leaf-most) frame when the function appears multiple times in one + * stack due to recursion. + */ +export function getBottomBoxInfoForFunction( + funcIndex: IndexIntoFuncTable, + thread: Thread, + samples: SamplesLikeTable +): BottomBoxInfo { + const { + stackTable, + frameTable, + funcTable, + stringTable, + resourceTable, + nativeSymbols, + } = thread; + + const { source: sourceIndex, line: funcLine } = getOriginalPositionForFrame( + null, + funcIndex, + frameTable, + funcTable, + thread.sourceLocationTable + ); + const resource = funcTable.resource[funcIndex]; + const libIndex = + resource !== -1 && resourceTable.type[resource] === ResourceType.Library + ? resourceTable.lib[resource] + : null; + + const funcFramePerStack = getFunctionFramePerStack( + funcIndex, + stackTable, + frameTable + ); + + const nativeSymbolsForFunc = getNativeSymbolsForCallNode( + funcFramePerStack, + frameTable + ); + let initialNativeSymbol = null; + const nativeSymbolTimings = getTotalNativeSymbolTimingsForCallNode( + samples, + funcFramePerStack, + frameTable + ); + const hottestNativeSymbol = mapGetKeyWithMaxValue(nativeSymbolTimings); + if (hottestNativeSymbol !== undefined) { + nativeSymbolsForFunc.add(hottestNativeSymbol); + initialNativeSymbol = hottestNativeSymbol; + } + const nativeSymbolsForFuncArr = [...nativeSymbolsForFunc]; + nativeSymbolsForFuncArr.sort((a, b) => a - b); + if (nativeSymbolsForFuncArr.length !== 0 && initialNativeSymbol === null) { + initialNativeSymbol = nativeSymbolsForFuncArr[0]; + } + + const nativeSymbolInfosForFunc = nativeSymbolsForFuncArr.map( + (nativeSymbolIndex) => + getNativeSymbolInfo( + nativeSymbolIndex, + nativeSymbols, + frameTable, + stringTable + ) + ); + + const lineTimings = getTotalLineTimingsForCallNode( + samples, + funcFramePerStack, + frameTable, + funcTable, + funcLine, + thread.sourceLocationTable + ); + const hottestLine = mapGetKeyWithMaxValue(lineTimings); + const addressTimings = getTotalAddressTimingsForCallNode( + samples, + funcFramePerStack, + frameTable, + initialNativeSymbol + ); + const hottestInstructionAddress = mapGetKeyWithMaxValue(addressTimings); + + return { + libIndex, + sourceIndex, + nativeSymbols: nativeSymbolInfosForFunc, + initialNativeSymbol: + initialNativeSymbol !== null + ? nativeSymbolsForFuncArr.indexOf(initialNativeSymbol) + : null, + scrollToLineNumber: hottestLine, + scrollToInstructionAddress: hottestInstructionAddress, + highlightedLineNumber: null, + highlightedInstructionAddress: null, + }; +} diff --git a/src/profile-logic/call-tree.ts b/src/profile-logic/call-tree.ts index 88c1e0ae91..9263120ab2 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -39,7 +39,10 @@ import * as ProfileData from './profile-data'; import type { CallTreeSummaryStrategy } from '../types/actions'; import type { CallNodeInfo, CallNodeInfoInverted } from './call-node-info'; import type { SortableColumn } from '../components/shared/TreeView'; -import { getBottomBoxInfoForCallNode } from './bottom-box'; +import { + getBottomBoxInfoForCallNode, + getBottomBoxInfoForFunction, +} from './bottom-box'; type CallNodeChildren = IndexIntoCallNodeTable[]; @@ -645,6 +648,14 @@ export class CallTree { ); } + getBottomBoxInfoForFunction(funcIndex: IndexIntoFuncTable): BottomBoxInfo { + return getBottomBoxInfoForFunction( + funcIndex, + this._thread, + this._previewFilteredCtssSamples + ); + } + /** * Take a IndexIntoCallNodeTable, and compute an inverted path for it. * diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index 3b6e424499..7dfef1ce91 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -947,6 +947,47 @@ export function getCallNodeFramePerStackInverted( return callNodeFramePerStack; } +/** + * For each stack, returns the innermost (leaf-most) frame whose function matches + * funcIndex, or -1 if funcIndex doesn't appear in that stack at all. + * + * This is used when double-clicking a function in the function list, to find + * which frame to show in the source and assembly views. When a function appears + * multiple times in a stack (due to recursion), we use the innermost occurrence, + * because that is the one doing the most specific work. + * + * Example: for stack A -> B -> C -> B -> D, asking for func B gives: + * - frame of the B in "C -> B" (the innermost B), not the B in "A -> B" + * + * The algorithm takes advantage of the stack table's ordering (parents before + * children): for each stack, we start with the parent's result and overwrite + * whenever we encounter funcIndex again, so the last write wins (innermost). + */ +export function getFunctionFramePerStack( + funcIndex: IndexIntoFuncTable, + stackTable: StackTable, + frameTable: FrameTable +): Int32Array { + const { frame: frameCol, prefix: prefixCol, length: stackCount } = stackTable; + const funcCol = frameTable.func; + + const funcFramePerStack = new Int32Array(stackCount); + + for (let stackIndex = 0; stackIndex < stackCount; stackIndex++) { + const frame = frameCol[stackIndex]; + if (funcCol[frame] === funcIndex) { + // This stack's own frame matches: it is the innermost so far, overwrite. + funcFramePerStack[stackIndex] = frame; + } else { + // Inherit from parent (or -1 if there is no parent). + const prefix = prefixCol[stackIndex]; + funcFramePerStack[stackIndex] = + prefix !== null ? funcFramePerStack[prefix] : -1; + } + } + return funcFramePerStack; +} + /** * Take a samples table, and return an array that contain indexes that point to the * leaf most call node, or null. diff --git a/src/test/store/bottom-box.test.ts b/src/test/store/bottom-box.test.ts index 32a6e1c1a4..600c43db74 100644 --- a/src/test/store/bottom-box.test.ts +++ b/src/test/store/bottom-box.test.ts @@ -8,7 +8,10 @@ import * as UrlStateSelectors from '../../selectors/url-state'; import * as ProfileSelectors from '../../selectors/profile'; import { selectedThreadSelectors } from '../../selectors/per-thread'; import { emptyAddressTimings } from '../../profile-logic/address-timings'; -import { getBottomBoxInfoForCallNode } from '../../profile-logic/bottom-box'; +import { + getBottomBoxInfoForCallNode, + getBottomBoxInfoForFunction, +} from '../../profile-logic/bottom-box'; import { changeSelectedCallNode, updateBottomBoxContentsAndMaybeOpen, @@ -373,3 +376,81 @@ describe('bottom box', function () { // - A test with multiple threads: Open the assembly view for a symbol, switch // to a different thread, check timings }); + +describe('getBottomBoxInfoForFunction', function () { + it('uses the innermost frame when a function appears multiple times due to recursion (A->B->A->B)', function () { + // Stack: A[line:20] -> B[line:30] -> A[line:21] -> B[line:31] + // B appears at line 30 (outer) and line 31 (inner/leaf). + // Double-clicking B in the function list should use line 31 (innermost). + const { derivedThreads, funcNamesDictPerThread } = + getProfileFromTextSamples(` + A[file:a.js][line:20] + B[file:b.js][line:30] + A[file:a.js][line:21] + B[file:b.js][line:31] + `); + const [thread] = derivedThreads; + const [{ B }] = funcNamesDictPerThread; + + const bottomBoxInfo = getBottomBoxInfoForFunction( + B, + thread, + thread.samples + ); + + // scrollToLineNumber should be 31 (the innermost B), not 30 (the outer B). + expect(bottomBoxInfo.scrollToLineNumber).toBe(31); + }); + + it('uses the innermost frame when a function appears multiple times due to recursion via another function (A->B->C->B->D)', function () { + // Stack: A[line:20] -> B[line:30] -> C[line:40] -> B[line:31] -> D[line:50] + // B appears at line 30 (outer) and line 31 (inner), with C and D in between. + // Double-clicking B in the function list should use line 31 (innermost B). + const { derivedThreads, funcNamesDictPerThread } = + getProfileFromTextSamples(` + A[file:a.js][line:20] + B[file:b.js][line:30] + C[file:c.js][line:40] + B[file:b.js][line:31] + D[file:d.js][line:50] + `); + const [thread] = derivedThreads; + const [{ B }] = funcNamesDictPerThread; + + const bottomBoxInfo = getBottomBoxInfoForFunction( + B, + thread, + thread.samples + ); + + // scrollToLineNumber should be 31 (the innermost B), not 30 (the outer B). + expect(bottomBoxInfo.scrollToLineNumber).toBe(31); + }); + + it('opens the source view when double-clicking a function in the function list', function () { + const { profile, derivedThreads, funcNamesDictPerThread } = + getProfileFromTextSamples(` + A[file:a.js][line:20] + B[file:b.js][line:30] + `); + const { dispatch, getState } = storeWithProfile(profile); + const [thread] = derivedThreads; + const [{ B }] = funcNamesDictPerThread; + + dispatch(changeSelectedTab('function-list')); + + const bottomBoxInfo = getBottomBoxInfoForFunction( + B, + thread, + thread.samples + ); + dispatch( + updateBottomBoxContentsAndMaybeOpen('function-list', bottomBoxInfo) + ); + + expect(UrlStateSelectors.getIsBottomBoxOpen(getState())).toBeTrue(); + expect(UrlStateSelectors.getSourceViewScrollToLineNumber(getState())).toBe( + 30 + ); + }); +}); From 6bfe0913aea74c2ce8875c06c1daa5193c7f967e Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:51:35 -0400 Subject: [PATCH 08/19] Add a function list panel. --- locales/en-US/app.ftl | 1 + res/css/style.css | 3 +- src/actions/profile-view.ts | 35 +++ src/app-logic/tabs-handling.ts | 2 + src/app-logic/url-handling.ts | 60 ++++ src/components/app/Details.tsx | 2 + src/components/calltree/CallTree.css | 6 +- src/components/calltree/CallTree.tsx | 4 - src/components/calltree/FunctionList.tsx | 256 ++++++++++++++++++ .../calltree/ProfileFunctionListView.tsx | 20 ++ src/components/calltree/columns.ts | 44 +++ src/components/sidebar/index.tsx | 1 + src/components/timeline/TrackThread.tsx | 5 +- src/profile-logic/call-tree.ts | 38 ++- src/profile-logic/profile-data.ts | 94 +++++++ src/reducers/profile-view.ts | 69 +++++ src/reducers/url-state.ts | 13 + src/selectors/per-thread/stack-sample.ts | 42 +++ src/selectors/url-state.ts | 2 + src/test/components/Details.test.tsx | 3 + src/test/components/DetailsContainer.test.tsx | 1 + .../__snapshots__/profile-view.test.ts.snap | 1 + src/test/store/icons.test.ts | 1 + src/test/store/useful-tabs.test.ts | 4 + src/test/unit/profile-data.test.ts | 144 ++++++++++ src/test/unit/profile-tree.test.ts | 2 + src/test/url-handling.test.ts | 52 ++++ src/types/actions.ts | 16 ++ src/types/profile-derived.ts | 1 + src/types/state.ts | 9 + src/utils/types.ts | 1 + 31 files changed, 921 insertions(+), 11 deletions(-) create mode 100644 src/components/calltree/FunctionList.tsx create mode 100644 src/components/calltree/ProfileFunctionListView.tsx diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl index 86409569e7..f5df2d7937 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -906,6 +906,7 @@ StackSettings--panel-search = ## Tab Bar for the bottom half of the analysis UI. TabBar--calltree-tab = Call Tree +TabBar--function-list-tab = Function List TabBar--flame-graph-tab = Flame Graph TabBar--stack-chart-tab = Stack Chart TabBar--marker-chart-tab = Marker Chart diff --git a/res/css/style.css b/res/css/style.css index fac5320156..ca0c17e3c5 100644 --- a/res/css/style.css +++ b/res/css/style.css @@ -57,7 +57,8 @@ body { flex-shrink: 1; } -.treeAndSidebarWrapper { +.treeAndSidebarWrapper, +.functionTableAndSidebarWrapper { display: flex; flex: 1; flex-flow: column nowrap; diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 949fcabb34..b5c27752c4 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -74,6 +74,7 @@ import type { TableViewOptions, SelectionContext, BottomBoxInfo, + IndexIntoFuncTable, } from 'firefox-profiler/types'; import { funcHasDirectRecursiveCall, @@ -130,6 +131,22 @@ export function changeSelectedCallNode( }; } +/** + * Select a function for a given thread in the function list. + */ +export function changeSelectedFunctionIndex( + threadsKey: ThreadsKey, + selectedFunctionIndex: IndexIntoFuncTable | null, + context: SelectionContext = { source: 'auto' } +): Action { + return { + type: 'CHANGE_SELECTED_FUNCTION', + selectedFunctionIndex, + threadsKey, + context, + }; +} + /** * This action is used when the user right clicks on a call node (in panels such * as the call tree, the flame chart, or the stack chart). It's especially used @@ -146,6 +163,17 @@ export function changeRightClickedCallNode( }; } +export function changeRightClickedFunctionIndex( + threadsKey: ThreadsKey, + functionIndex: IndexIntoFuncTable | null +) { + return { + type: 'CHANGE_RIGHT_CLICKED_FUNCTION', + threadsKey, + functionIndex, + }; +} + /** * Given a threadIndex and a sampleIndex, select the call node which carries the * sample's self time. In the inverted tree, this will be a root node. @@ -1623,6 +1651,13 @@ export function changeMarkerTableSort(sort: SingleColumnSortState[]): Action { }; } +export function changeFunctionListSort(sort: SingleColumnSortState[]): Action { + return { + type: 'CHANGE_FUNCTION_LIST_SORT', + sort, + }; +} + export function changeNetworkSearchString(searchString: string): Action { return { type: 'CHANGE_NETWORK_SEARCH_STRING', diff --git a/src/app-logic/tabs-handling.ts b/src/app-logic/tabs-handling.ts index 3e2f205c3b..20f3742b48 100644 --- a/src/app-logic/tabs-handling.ts +++ b/src/app-logic/tabs-handling.ts @@ -9,6 +9,7 @@ */ export const tabsWithTitleL10nId = { calltree: 'TabBar--calltree-tab', + 'function-list': 'TabBar--function-list-tab', 'flame-graph': 'TabBar--flame-graph-tab', 'stack-chart': 'TabBar--stack-chart-tab', 'marker-chart': 'TabBar--marker-chart-tab', @@ -41,6 +42,7 @@ export const tabsWithTitleL10nIdArray: readonly TabsWithTitleL10nId[] = export const tabsShowingSampleData: readonly TabSlug[] = [ 'calltree', + 'function-list', 'flame-graph', 'stack-chart', ]; diff --git a/src/app-logic/url-handling.ts b/src/app-logic/url-handling.ts index a8568da7a0..01adaa14a7 100644 --- a/src/app-logic/url-handling.ts +++ b/src/app-logic/url-handling.ts @@ -186,6 +186,7 @@ type CallTreeQuery = BaseQuery & { invertCallstack: null | undefined; hideIdleSamples: null | undefined; ctSummary: string; + functionListSort?: string; // "total-desc~self-asc" — primary first }; type MarkersQuery = BaseQuery & { @@ -232,6 +233,9 @@ type Query = BaseQuery & { marker?: MarkerIndex; markerSort?: string; + // Function list specific + functionListSort?: string; + // Network specific networkSearch?: string; @@ -338,6 +342,7 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { : undefined; /* fallsthrough */ case 'flame-graph': + case 'function-list': case 'calltree': { query = baseQuery as CallTreeQueryShape; @@ -385,6 +390,11 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { query.bottomFullscreen = true; } } + if (selectedTab === 'function-list') { + query.functionListSort = convertFunctionListSortToString( + urlState.profileSpecific.functionListSort + ); + } break; } case 'marker-table': @@ -639,6 +649,9 @@ export function stateFromLocation( : null, selectedMarkers, markerTableSort: convertMarkerTableSortFromString(query.markerSort), + functionListSort: convertFunctionListSortFromString( + query.functionListSort + ), }, }; } @@ -691,6 +704,53 @@ function convertMarkerTableSortFromString( return parsed.reverse(); } +// FunctionList sort URL encoding. Same convention as the marker table: +// internal storage is primary-last, URL is primary-first. +const VALID_FUNCTION_LIST_SORT_COLUMNS = new Set(['total', 'self']); + +function convertFunctionListSortToString( + sort: SingleColumnSortState[] +): string | undefined { + if (sort.length === 0) { + return undefined; + } + // Omit when it matches the function list's own default (total descending). + if (sort.length === 1 && sort[0].column === 'total' && !sort[0].ascending) { + return undefined; + } + return sort + .slice() + .reverse() + .map((s) => `${s.column}-${s.ascending ? 'asc' : 'desc'}`) + .join('~'); +} + +function convertFunctionListSortFromString( + raw: string | null | void +): SingleColumnSortState[] { + if (!raw) { + return []; + } + const parsed: SingleColumnSortState[] = []; + for (const part of raw.split('~')) { + const dashIndex = part.lastIndexOf('-'); + if (dashIndex === -1) { + return []; + } + const column = part.slice(0, dashIndex); + const dir = part.slice(dashIndex + 1); + if ( + !VALID_FUNCTION_LIST_SORT_COLUMNS.has(column) || + (dir !== 'asc' && dir !== 'desc') + ) { + return []; + } + parsed.push({ column, ascending: dir === 'asc' }); + } + // URL is primary-first; internal storage is primary-last. + return parsed.reverse(); +} + function convertGlobalTrackOrderFromString( rawString: string | null | void ): TrackIndex[] { diff --git a/src/components/app/Details.tsx b/src/components/app/Details.tsx index 643e6c5929..c571fd6945 100644 --- a/src/components/app/Details.tsx +++ b/src/components/app/Details.tsx @@ -10,6 +10,7 @@ import explicitConnect from 'firefox-profiler/utils/connect'; import { TabBar } from './TabBar'; import { LocalizedErrorBoundary } from './ErrorBoundary'; import { ProfileCallTreeView } from 'firefox-profiler/components/calltree/ProfileCallTreeView'; +import { ProfileFunctionListView } from 'firefox-profiler/components/calltree/ProfileFunctionListView'; import { MarkerTable } from 'firefox-profiler/components/marker-table'; import { StackChart } from 'firefox-profiler/components/stack-chart/'; import { MarkerChart } from 'firefox-profiler/components/marker-chart/'; @@ -122,6 +123,7 @@ class ProfileViewerImpl extends PureComponent { { { calltree: , + 'function-list': , 'flame-graph': , 'stack-chart': , 'marker-chart': , diff --git a/src/components/calltree/CallTree.css b/src/components/calltree/CallTree.css index 73e7f3935c..2efc3e8b38 100644 --- a/src/components/calltree/CallTree.css +++ b/src/components/calltree/CallTree.css @@ -7,8 +7,9 @@ text-align: right; } -/* The header for the totalPercent column is not visible */ -.treeViewHeaderColumn.totalPercent { +/* The headers for the percent columns are not visible */ +.treeViewHeaderColumn.totalPercent, +.treeViewHeaderColumn.selfPercent { display: none; } @@ -26,6 +27,7 @@ .treeViewRowColumn.total, .treeViewRowColumn.totalPercent, .treeViewRowColumn.self, +.treeViewRowColumn.selfPercent, .treeViewRowColumn.timestamp { text-align: right; } diff --git a/src/components/calltree/CallTree.tsx b/src/components/calltree/CallTree.tsx index a86053f130..9ec2279d9d 100644 --- a/src/components/calltree/CallTree.tsx +++ b/src/components/calltree/CallTree.tsx @@ -7,7 +7,6 @@ import { TreeView } from 'firefox-profiler/components/shared/TreeView'; import { CallTreeEmptyReasons } from './CallTreeEmptyReasons'; import { getInvertCallstack, - getImplementationFilter, getSearchStringsAsRegExp, getSelectedThreadsKey, } from 'firefox-profiler/selectors/url-state'; @@ -31,7 +30,6 @@ import { import type { State, - ImplementationFilter, ThreadsKey, IndexIntoCategoryList, IndexIntoCallNodeTable, @@ -61,7 +59,6 @@ type StateProps = { readonly searchStringsRegExp: RegExp | null; readonly disableOverscan: boolean; readonly invertCallstack: boolean; - readonly implementationFilter: ImplementationFilter; readonly callNodeMaxDepthPlusOne: number; readonly weightType: WeightType; readonly tableViewOptions: TableViewOptions; @@ -291,7 +288,6 @@ export const CallTree = explicitConnect<{}, StateProps, DispatchProps>({ searchStringsRegExp: getSearchStringsAsRegExp(state), disableOverscan: getPreviewSelectionIsBeingModified(state), invertCallstack: getInvertCallstack(state), - implementationFilter: getImplementationFilter(state), // Use the filtered call node max depth, rather than the preview filtered call node // max depth so that the width of the TreeView component is stable across preview // selections. diff --git a/src/components/calltree/FunctionList.tsx b/src/components/calltree/FunctionList.tsx new file mode 100644 index 0000000000..cf8577c0ae --- /dev/null +++ b/src/components/calltree/FunctionList.tsx @@ -0,0 +1,256 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @flow + +import { PureComponent } from 'react'; +import memoize from 'memoize-immutable'; +import explicitConnect from 'firefox-profiler/utils/connect'; +import { + TreeView, + ColumnSortState, +} from 'firefox-profiler/components/shared/TreeView'; +import { CallTreeEmptyReasons } from './CallTreeEmptyReasons'; +import { + getSearchStringsAsRegExp, + getSelectedThreadsKey, + getFunctionListSort, +} from 'firefox-profiler/selectors/url-state'; +import { + getScrollToSelectionGeneration, + getFocusCallTreeGeneration, + getCurrentTableViewOptions, + getPreviewSelectionIsBeingModified, +} from 'firefox-profiler/selectors/profile'; +import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { + changeRightClickedFunctionIndex, + changeSelectedFunctionIndex, + addTransformToStack, + changeTableViewOptions, + updateBottomBoxContentsAndMaybeOpen, + changeFunctionListSort, +} from 'firefox-profiler/actions/profile-view'; +import { + nameColumn, + libColumn, + functionListColumnsForWeightType, +} from './columns'; + +import type { + State, + ThreadsKey, + IndexIntoFuncTable, + CallNodeDisplayData, + WeightType, + TableViewOptions, + SelectionContext, +} from 'firefox-profiler/types'; +import type { CallTree } from 'firefox-profiler/profile-logic/call-tree'; + +import type { SingleColumnSortState } from 'firefox-profiler/components/shared/TreeView'; +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +import './CallTree.css'; + +const DEFAULT_FUNCTION_LIST_SORT: SingleColumnSortState[] = [ + { column: 'total', ascending: false }, +]; + +type StateProps = { + readonly threadsKey: ThreadsKey; + readonly scrollToSelectionGeneration: number; + readonly focusCallTreeGeneration: number; + readonly tree: CallTree; + readonly selectedFunctionIndex: IndexIntoFuncTable | null; + readonly rightClickedFunctionIndex: IndexIntoFuncTable | null; + readonly searchStringsRegExp: RegExp | null; + readonly disableOverscan: boolean; + readonly weightType: WeightType; + readonly tableViewOptions: TableViewOptions; + readonly sort: SingleColumnSortState[]; +}; + +type DispatchProps = { + readonly changeSelectedFunctionIndex: typeof changeSelectedFunctionIndex; + readonly changeRightClickedFunctionIndex: typeof changeRightClickedFunctionIndex; + readonly addTransformToStack: typeof addTransformToStack; + readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; + readonly onTableViewOptionsChange: (opts: TableViewOptions) => any; + readonly changeFunctionListSort: typeof changeFunctionListSort; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +class FunctionListImpl extends PureComponent { + _treeView: TreeView | null = null; + _takeTreeViewRef = (treeView: TreeView | null) => { + this._treeView = treeView; + }; + + _expandedIndexes: Array = []; + + _getSortedColumns = memoize( + (sort: SingleColumnSortState[]) => + new ColumnSortState(sort.length > 0 ? sort : DEFAULT_FUNCTION_LIST_SORT) + ); + + _onColumnSortChange = (sortedColumns: ColumnSortState) => { + this.props.changeFunctionListSort(sortedColumns.sortedColumns); + }; + + override componentDidMount() { + this.focus(); + this.maybeProcureInitialSelection(); + + if (this.props.selectedFunctionIndex !== null && this._treeView) { + this._treeView.scrollSelectionIntoView(); + } + } + + override componentDidUpdate(prevProps: Props) { + if ( + this.props.focusCallTreeGeneration > prevProps.focusCallTreeGeneration + ) { + this.focus(); + } + + if ( + this.props.selectedFunctionIndex !== null && + this.props.scrollToSelectionGeneration > + prevProps.scrollToSelectionGeneration && + this._treeView + ) { + this._treeView.scrollSelectionIntoView(); + } + } + + maybeProcureInitialSelection() { + if (this.props.selectedFunctionIndex !== null) { + return; + } + const { tree, threadsKey, changeSelectedFunctionIndex } = this.props; + const firstRoot = tree.getRoots()[0]; + if (firstRoot !== undefined) { + changeSelectedFunctionIndex(threadsKey, firstRoot, { source: 'auto' }); + } + } + + focus() { + if (this._treeView) { + this._treeView.focus(); + } + } + + _onSelectionChange = ( + newSelectedFunction: IndexIntoFuncTable, + context: SelectionContext + ) => { + const { threadsKey, changeSelectedFunctionIndex } = this.props; + changeSelectedFunctionIndex(threadsKey, newSelectedFunction, context); + }; + + _onRightClickSelection = (newSelectedFunction: IndexIntoFuncTable) => { + const { threadsKey, changeRightClickedFunctionIndex } = this.props; + changeRightClickedFunctionIndex(threadsKey, newSelectedFunction); + }; + + _onExpandedCallNodesChange = ( + _newExpandedCallNodeIndexes: Array + ) => {}; + + _onKeyDown = (_event: React.KeyboardEvent) => { + // const { + // selectedFunctionIndex, + // rightClickedFunctionIndex, + // threadsKey, + // } = this.props; + // const nodeIndex = + // rightClickedFunctionIndex !== null + // ? rightClickedFunctionIndex + // : selectedFunctionIndex; + // if (nodeIndex === null) { + // return; + // } + // handleCallNodeTransformShortcut(event, threadsKey, nodeIndex); + }; + + _onEnterOrDoubleClick = (nodeId: IndexIntoFuncTable) => { + const { tree, updateBottomBoxContentsAndMaybeOpen } = this.props; + const bottomBoxInfo = tree.getBottomBoxInfoForFunction(nodeId); + updateBottomBoxContentsAndMaybeOpen('function-list', bottomBoxInfo); + }; + + override render() { + const { + tree, + selectedFunctionIndex, + rightClickedFunctionIndex, + searchStringsRegExp, + disableOverscan, + weightType, + tableViewOptions, + onTableViewOptionsChange, + sort, + } = this.props; + if (tree.getRoots().length === 0) { + return ; + } + return ( + + ); + } +} + +export const FunctionList = explicitConnect<{}, StateProps, DispatchProps>({ + mapStateToProps: (state: State) => ({ + threadsKey: getSelectedThreadsKey(state), + scrollToSelectionGeneration: getScrollToSelectionGeneration(state), + focusCallTreeGeneration: getFocusCallTreeGeneration(state), + tree: selectedThreadSelectors.getFunctionListTree(state), + selectedFunctionIndex: + selectedThreadSelectors.getSelectedFunctionIndex(state), + rightClickedFunctionIndex: + selectedThreadSelectors.getRightClickedFunctionIndex(state), + searchStringsRegExp: getSearchStringsAsRegExp(state), + disableOverscan: getPreviewSelectionIsBeingModified(state), + weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), + tableViewOptions: getCurrentTableViewOptions(state), + sort: getFunctionListSort(state), + }), + mapDispatchToProps: { + changeSelectedFunctionIndex, + changeRightClickedFunctionIndex, + addTransformToStack, + updateBottomBoxContentsAndMaybeOpen, + onTableViewOptionsChange: (options: TableViewOptions) => + changeTableViewOptions('calltree', options), + changeFunctionListSort, + }, + component: FunctionListImpl, +}); diff --git a/src/components/calltree/ProfileFunctionListView.tsx b/src/components/calltree/ProfileFunctionListView.tsx new file mode 100644 index 0000000000..7695a6c4d2 --- /dev/null +++ b/src/components/calltree/ProfileFunctionListView.tsx @@ -0,0 +1,20 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { FunctionList } from './FunctionList'; +import { StackSettings } from 'firefox-profiler/components/shared/StackSettings'; +import { TransformNavigator } from 'firefox-profiler/components/shared/TransformNavigator'; + +export const ProfileFunctionListView = () => ( +
+ + + +
+); diff --git a/src/components/calltree/columns.ts b/src/components/calltree/columns.ts index 14f597410a..a3f92fa837 100644 --- a/src/components/calltree/columns.ts +++ b/src/components/calltree/columns.ts @@ -128,3 +128,47 @@ export function treeColumnsForWeightType( throw assertExhaustiveCheck(weightType, 'Unhandled WeightType.'); } } + +function withSelfPercentColumn( + columns: MaybeResizableColumn[] +): MaybeResizableColumn[] { + const selfPercentColumn = { + propName: 'selfPercent', + titleL10nId: '', + initialWidth: 55, + hideDividerAfter: true, + }; + const selfColumnIndex = columns.findIndex((c) => c.propName === 'self'); + const selfColumn = { + ...columns[selfColumnIndex], + headerWidthAdjustment: selfPercentColumn.initialWidth, + }; + const newColumns = columns.slice(); + newColumns[selfColumnIndex] = selfColumn; + newColumns.splice(selfColumnIndex, 0, selfPercentColumn); + return newColumns; +} + +export const functionListColumnsForTracingMs: MaybeResizableColumn[] = + withSelfPercentColumn(treeColumnsForTracingMs); +export const functionListColumnsForSamples: MaybeResizableColumn[] = + withSelfPercentColumn(treeColumnsForSamples); +export const functionListColumnsForBytes: MaybeResizableColumn[] = + withSelfPercentColumn(treeColumnsForBytes); + +// Like `treeColumnsForWeightType`, but for the FunctionList table (which has +// an extra selfPercent column compared to the call-tree variants). +export function functionListColumnsForWeightType( + weightType: WeightType +): MaybeResizableColumn[] { + switch (weightType) { + case 'tracing-ms': + return functionListColumnsForTracingMs; + case 'samples': + return functionListColumnsForSamples; + case 'bytes': + return functionListColumnsForBytes; + default: + throw assertExhaustiveCheck(weightType, 'Unhandled WeightType.'); + } +} diff --git a/src/components/sidebar/index.tsx b/src/components/sidebar/index.tsx index a8115372a3..e4a6901069 100644 --- a/src/components/sidebar/index.tsx +++ b/src/components/sidebar/index.tsx @@ -15,6 +15,7 @@ export function selectSidebar( ): React.ComponentType<{}> | null { return { calltree: CallTreeSidebar, + 'function-list': CallTreeSidebar, 'flame-graph': CallTreeSidebar, 'stack-chart': null, 'marker-chart': null, diff --git a/src/components/timeline/TrackThread.tsx b/src/components/timeline/TrackThread.tsx index b0ee5338a6..b1f24edaa7 100644 --- a/src/components/timeline/TrackThread.tsx +++ b/src/components/timeline/TrackThread.tsx @@ -25,6 +25,7 @@ import { getImplementationFilter, getZeroAt, getProfileTimelineUnit, + getSelectedTab, } from 'firefox-profiler/selectors'; import { TimelineMarkersJank, @@ -349,7 +350,9 @@ export const TimelineTrackThread = explicitConnect< hasFileIoMarkers: selectors.getTimelineFileIoMarkerIndexes(state).length !== 0, sampleSelectedStates: - selectors.getSampleSelectedStatesInFilteredThread(state), + getSelectedTab(state) === 'function-list' + ? selectors.getSampleSelectedStatesForFunctionListTab(state) + : selectors.getSampleSelectedStatesInFilteredThread(state), treeOrderSampleComparator: selectors.getTreeOrderComparatorInFilteredThread(state), selectedThreadIndexes, diff --git a/src/profile-logic/call-tree.ts b/src/profile-logic/call-tree.ts index 9263120ab2..9d7b16573a 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -38,7 +38,10 @@ import { checkBit } from '../utils/bitset'; import * as ProfileData from './profile-data'; import type { CallTreeSummaryStrategy } from '../types/actions'; import type { CallNodeInfo, CallNodeInfoInverted } from './call-node-info'; -import type { SortableColumn } from '../components/shared/TreeView'; +import type { + ColumnSortState, + SortableColumn, +} from '../components/shared/TreeView'; import { getBottomBoxInfoForCallNode, getBottomBoxInfoForFunction, @@ -405,6 +408,12 @@ export class CallTree { } getSortableColumns(): SortableColumn[] { + if (this._internal instanceof CallTreeInternalFunctionList) { + return [ + { name: 'total', prefersDescending: true }, + { name: 'self', prefersDescending: true }, + ]; + } return []; } @@ -412,7 +421,28 @@ export class CallTree { return this._rootTotalSummary; } - getRoots() { + getRoots(sort: ColumnSortState | null = null): IndexIntoCallNodeTable[] { + if ( + sort !== null && + sort.sortedColumns.length > 0 && + this._internal instanceof CallTreeInternalFunctionList + ) { + const internal = this._internal; + return sort.sortItemsHelper( + this._roots, + ( + a: IndexIntoCallNodeTable, + b: IndexIntoCallNodeTable, + column: string + ) => { + const aValue = + column === 'self' ? internal.getSelf(a) : internal.getTotal(a); + const bValue = + column === 'self' ? internal.getSelf(b) : internal.getTotal(b); + return aValue - bValue; + } + ); + } return this._roots; } @@ -516,7 +546,7 @@ export class CallTree { let displayData: CallNodeDisplayData | void = this._displayDataByIndex.get(callNodeIndex); if (displayData === undefined) { - const { funcName, total, totalRelative, self } = + const { funcName, total, totalRelative, self, selfRelative } = this.getNodeData(callNodeIndex); const funcIndex = this._callNodeInfo.funcForNode(callNodeIndex); const categoryIndex = this._callNodeInfo.categoryForNode(callNodeIndex); @@ -554,6 +584,7 @@ export class CallTree { self ); const totalPercent = `${formatPercent(totalRelative)}`; + const selfPercent = `${formatPercent(selfRelative)}`; let ariaLabel; let totalWithUnit; @@ -604,6 +635,7 @@ export class CallTree { self: self === 0 ? '—' : formattedSelf, selfWithUnit: self === 0 ? '—' : selfWithUnit, totalPercent, + selfPercent, name: funcName, lib: libName.slice(0, 1000), // Dim platform pseudo-stacks. diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index 7dfef1ce91..9bd9182b60 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -1176,6 +1176,65 @@ export function getSampleSelectedStates( ); } +/** + * Go through the samples, and determine their current state. + * + * For samples that are neither 'FILTERED_OUT_*' nor 'SELECTED', + * this function uses 'UNSELECTED_ORDERED_AFTER_SELECTED'. It uses the same + * ordering as the function compareCallNodes in getTreeOrderComparator. + */ +export function getSamplesSelectedStatesForFunction( + sampleCallNodes: Array, + selectedFunctionIndex: IndexIntoFuncTable | null, + callNodeTable: CallNodeTable +): Uint8Array { + if (selectedFunctionIndex === null) { + return _getSampleSelectedStatesForNoSelection(sampleCallNodes); + } + + const sampleCount = sampleCallNodes.length; + + // Go through each call node, and label it as containing the function or not. + // callNodeContainsFunc is a callNodeIndex => bool map, implemented as a U8 typed + // array for better performance. 0 means false, 1 means true. + const callNodeCount = callNodeTable.length; + const callNodeContainsFunc = new Uint8Array(callNodeCount); + for (let callNodeIndex = 0; callNodeIndex < callNodeCount; callNodeIndex++) { + const prefix = callNodeTable.prefix[callNodeIndex]; + const funcIndex = callNodeTable.func[callNodeIndex]; + if ( + funcIndex === selectedFunctionIndex || + // The parent of this stack contained the function. + (prefix !== -1 && callNodeContainsFunc[prefix] === 1) + ) { + callNodeContainsFunc[callNodeIndex] = 1; + } + } + + // Go through each sample, and label its state. + const samplesSelectedStates = new Uint8Array(sampleCount); + for ( + let sampleIndex = 0; + sampleIndex < sampleCallNodes.length; + sampleIndex++ + ) { + let sampleSelectedState: SelectedState = SelectedState.Selected; + const callNodeIndex = sampleCallNodes[sampleIndex]; + if (callNodeIndex !== null) { + if (callNodeContainsFunc[callNodeIndex] === 1) { + sampleSelectedState = SelectedState.Selected; + } else { + sampleSelectedState = SelectedState.UnselectedOrderedBeforeSelected; + } + } else { + // This sample was filtered out. + sampleSelectedState = SelectedState.FilteredOutByTransform; + } + samplesSelectedStates[sampleIndex] = sampleSelectedState; + } + return samplesSelectedStates; +} + /** * This function returns the function index for a specific call node path. This * is the last element of this path, or the leaf element of the path. @@ -1478,6 +1537,41 @@ export function computeCallNodeFuncIsDuplicate( return nodeFuncIsDuplicateBitSet; } +/** + * This function returns the timings for a specific function. + * + * Note that the unfilteredThread should be the original thread before any filtering + * (by range or other) happens. Also sampleIndexOffset needs to be properly + * specified and is the offset to be applied on thread's indexes to access + * the same samples in unfilteredThread. + */ +export function getTimingsForFunction( + _funcIndex: IndexIntoFuncTable | null, + _interval: Milliseconds, + _thread: Thread, + _unfilteredThread: Thread, + _sampleIndexOffset: number, + _categories: CategoryList, + _samples: SamplesLikeTable, + _unfilteredSamples: SamplesLikeTable, + _displayImplementation: boolean +): TimingsForPath { + // TODO + return { + forPath: { + selfTime: { + value: 0, + breakdownByCategory: null, + }, + totalTime: { + value: 0, + breakdownByCategory: null, + }, + }, + rootTime: 1, + }; +} + // This function computes the time range for a thread, using both its samples // and markers data. It's memoized and exported below, because it's called both // here in getTimeRangeIncludingAllThreads, and in selectors when dealing with diff --git a/src/reducers/profile-view.ts b/src/reducers/profile-view.ts index b2a58f78c0..fa2b3fd6c8 100644 --- a/src/reducers/profile-view.ts +++ b/src/reducers/profile-view.ts @@ -30,6 +30,7 @@ import type { ThreadsKey, Milliseconds, TableViewOptions, + RightClickedFunction, } from 'firefox-profiler/types'; import { applyFuncSubstitutionToCallPath, @@ -188,6 +189,7 @@ export const defaultThreadViewOptions: ThreadViewOptions = { selectedInvertedCallNodePath: [], expandedNonInvertedCallNodePaths: new PathSet(), expandedInvertedCallNodePaths: new PathSet(), + selectedFunctionIndex: null, selectedNetworkMarker: null, lastSeenTransformCount: 0, }; @@ -315,6 +317,23 @@ const viewOptionsPerThread: Reducer = ( } ); } + case 'CHANGE_SELECTED_FUNCTION': { + const { selectedFunctionIndex, threadsKey } = action; + + const threadState = _getThreadViewOptions(state, threadsKey); + + const previousSelectedFunction = threadState.selectedFunctionIndex; + + // If the selected function doesn't actually change, let's return the previous + // state to avoid rerenders. + if (selectedFunctionIndex === previousSelectedFunction) { + return state; + } + + return _updateThreadViewOptions(state, threadsKey, { + selectedFunctionIndex, + }); + } case 'CHANGE_INVERT_CALLSTACK': { const { newSelectedCallNodePath, @@ -636,6 +655,7 @@ const scrollToSelectionGeneration: Reducer = (state = 0, action) => { case 'CHANGE_NETWORK_SEARCH_STRING': return state + 1; case 'CHANGE_SELECTED_CALL_NODE': + case 'CHANGE_SELECTED_FUNCTION': case 'CHANGE_SELECTED_MARKER': case 'CHANGE_SELECTED_NETWORK_MARKER': if (action.context.source === 'pointer') { @@ -781,6 +801,54 @@ const rightClickedCallNode: Reducer = ( } }; +const rightClickedFunction: Reducer = ( + state = null, + action +) => { + switch (action.type) { + case 'BULK_SYMBOLICATION': { + if (state === null) { + return null; + } + + const { oldFuncToNewFuncsMap } = action; + const functionIndexes = oldFuncToNewFuncsMap.get(state.functionIndex); + if (functionIndexes === undefined || functionIndexes.length === 0) { + return null; + } + + return { + ...state, + functionIndex: functionIndexes[0], + }; + } + case 'CHANGE_RIGHT_CLICKED_FUNCTION': + if (action.functionIndex !== null) { + return { + threadsKey: action.threadsKey, + functionIndex: action.functionIndex, + }; + } + + return null; + case 'SET_CONTEXT_MENU_VISIBILITY': + // We want to change the state only when the menu is hidden. + if (action.isVisible) { + return state; + } + + return null; + case 'PROFILE_LOADED': + case 'CHANGE_INVERT_CALLSTACK': + case 'ADD_TRANSFORM_TO_STACK': + case 'POP_TRANSFORMS_FROM_STACK': + case 'CHANGE_IMPLEMENTATION_FILTER': + return null; + default: + return state; + } +}; + const rightClickedMarker: Reducer = ( state = null, action @@ -882,6 +950,7 @@ const profileViewReducer: Reducer = wrapReducerInResetter( lastNonShiftClick, rightClickedTrack, rightClickedCallNode, + rightClickedFunction, rightClickedMarker, hoveredMarker, mouseTimePosition, diff --git a/src/reducers/url-state.ts b/src/reducers/url-state.ts index 5c162859f7..f3902a4fff 100644 --- a/src/reducers/url-state.ts +++ b/src/reducers/url-state.ts @@ -206,6 +206,18 @@ const markerTableSort: Reducer = ( } }; +const functionListSort: Reducer = ( + state = [], + action +) => { + switch (action.type) { + case 'CHANGE_FUNCTION_LIST_SORT': + return action.sort; + default: + return state; + } +}; + const networkSearchString: Reducer = (state = '', action) => { switch (action.type) { case 'CHANGE_NETWORK_SEARCH_STRING': @@ -807,6 +819,7 @@ const profileSpecific = combineReducers({ tabFilter, selectedMarkers, markerTableSort, + functionListSort, // The timeline tracks used to be hidden and sorted by thread indexes, rather than // track indexes. The only way to migrate this information to tracks-based data is to // first retrieve the profile, so they can't be upgraded by the normal url upgrading diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index 21bbecb964..181868fac1 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -42,6 +42,7 @@ import type { CallNodeSelfAndSummary, State, CallNodeTableBitSet, + IndexIntoFuncTable, } from 'firefox-profiler/types'; import type { CallNodeInfo, @@ -208,6 +209,14 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getSelectedFunctionIndex: Selector = + createSelector( + threadSelectors.getViewOptions, + (threadViewOptions): IndexIntoFuncTable | null => { + return threadViewOptions.selectedFunctionIndex; + } + ); + const getSelectedCallNodePath: Selector = createSelector( threadSelectors.getViewOptions, UrlState.getInvertCallstack, @@ -292,6 +301,19 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getSampleSelectedStatesForFunctionListTab: Selector = + createSelector( + getSampleIndexToNonInvertedCallNodeIndexForFilteredThread, + _getCallNodeTable, + getSelectedFunctionIndex, + (sampleCallNodes, callNodeTable, selectedFunctionIndex) => + ProfileData.getSamplesSelectedStatesForFunction( + sampleCallNodes, + selectedFunctionIndex, + callNodeTable + ) + ); + const getTreeOrderComparatorInFilteredThread: Selector< ( sampleIndexA: IndexIntoSamplesTable, @@ -498,6 +520,23 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getRightClickedFunctionIndex: Selector = + createSelector( + ProfileSelectors.getProfileViewOptions, + (profileViewOptions) => { + const rightClickedFunctionInfo = + profileViewOptions.rightClickedFunction; + if ( + rightClickedFunctionInfo !== null && + threadsKey === rightClickedFunctionInfo.threadsKey + ) { + return rightClickedFunctionInfo.functionIndex; + } + + return null; + } + ); + return { unfilteredSamplesRange, getWeightTypeForCallTree, @@ -507,10 +546,12 @@ export function getStackAndSampleSelectorsPerThread( getAssemblyViewStackAddressInfo, getSelectedCallNodePath, getSelectedCallNodeIndex, + getSelectedFunctionIndex, getExpandedCallNodePaths, getExpandedCallNodeIndexes, getSampleIndexToNonInvertedCallNodeIndexForFilteredThread, getSampleSelectedStatesInFilteredThread, + getSampleSelectedStatesForFunctionListTab, getTreeOrderComparatorInFilteredThread, getCallTree, getFunctionListTree, @@ -524,5 +565,6 @@ export function getStackAndSampleSelectorsPerThread( getFilteredCallNodeMaxDepthPlusOne, getFlameGraphTiming, getRightClickedCallNodeIndex, + getRightClickedFunctionIndex, }; } diff --git a/src/selectors/url-state.ts b/src/selectors/url-state.ts index 80c9733a3c..5fa38566ce 100644 --- a/src/selectors/url-state.ts +++ b/src/selectors/url-state.ts @@ -120,6 +120,8 @@ export const getMarkersSearchString: Selector = (state) => getProfileSpecificState(state).markersSearchString; export const getMarkerTableSort: Selector = (state) => getProfileSpecificState(state).markerTableSort; +export const getFunctionListSort: Selector = (state) => + getProfileSpecificState(state).functionListSort; export const getNetworkSearchString: Selector = (state) => getProfileSpecificState(state).networkSearchString; export const getSelectedTab: Selector = (state) => diff --git a/src/test/components/Details.test.tsx b/src/test/components/Details.test.tsx index 73b05db864..0970c890d0 100644 --- a/src/test/components/Details.test.tsx +++ b/src/test/components/Details.test.tsx @@ -20,6 +20,9 @@ import type { TabSlug } from '../../app-logic/tabs-handling'; jest.mock('../../components/calltree/ProfileCallTreeView', () => ({ ProfileCallTreeView: 'call-tree', })); +jest.mock('../../components/calltree/ProfileFunctionListView', () => ({ + ProfileFunctionListView: 'function-list', +})); jest.mock('../../components/flame-graph', () => ({ FlameGraph: 'flame-graph', })); diff --git a/src/test/components/DetailsContainer.test.tsx b/src/test/components/DetailsContainer.test.tsx index 54c142dba0..59d449fccb 100644 --- a/src/test/components/DetailsContainer.test.tsx +++ b/src/test/components/DetailsContainer.test.tsx @@ -37,6 +37,7 @@ describe('app/DetailsContainer', function () { const expectedSidebar: { [slug in TabSlug]: boolean } = { calltree: true, + 'function-list': true, 'flame-graph': true, 'stack-chart': false, 'marker-chart': false, diff --git a/src/test/store/__snapshots__/profile-view.test.ts.snap b/src/test/store/__snapshots__/profile-view.test.ts.snap index 9bf40c02a2..771dd6d473 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -4431,6 +4431,7 @@ Object { }, }, "lastSeenTransformCount": 1, + "selectedFunctionIndex": null, "selectedInvertedCallNodePath": Array [], "selectedNetworkMarker": null, "selectedNonInvertedCallNodePath": Array [ diff --git a/src/test/store/icons.test.ts b/src/test/store/icons.test.ts index 3a9d3df13f..c1b77810b9 100644 --- a/src/test/store/icons.test.ts +++ b/src/test/store/icons.test.ts @@ -37,6 +37,7 @@ describe('actions/icons', function () { totalPercent: '0', self: '0', selfWithUnit: '0 ms', + selfPercent: '0', name: 'icon', lib: 'icon', isFrameLabel: false, diff --git a/src/test/store/useful-tabs.test.ts b/src/test/store/useful-tabs.test.ts index 2e770a2581..7f6444c07e 100644 --- a/src/test/store/useful-tabs.test.ts +++ b/src/test/store/useful-tabs.test.ts @@ -21,6 +21,7 @@ describe('getUsefulTabs', function () { const { getState } = storeWithProfile(profile); expect(selectedThreadSelectors.getUsefulTabs(getState())).toEqual([ 'calltree', + 'function-list', 'flame-graph', 'stack-chart', 'marker-chart', @@ -59,6 +60,7 @@ describe('getUsefulTabs', function () { }); expect(selectedThreadSelectors.getUsefulTabs(getState())).toEqual([ 'calltree', + 'function-list', 'flame-graph', 'stack-chart', 'marker-chart', @@ -84,6 +86,7 @@ describe('getUsefulTabs', function () { const { getState } = storeWithProfile(profile); expect(selectedThreadSelectors.getUsefulTabs(getState())).toEqual([ 'calltree', + 'function-list', 'flame-graph', 'stack-chart', 'marker-chart', @@ -116,6 +119,7 @@ describe('getUsefulTabs', function () { const { getState } = storeWithProfile(profile); expect(selectedThreadSelectors.getUsefulTabs(getState())).toEqual([ 'calltree', + 'function-list', 'flame-graph', 'stack-chart', 'marker-chart', diff --git a/src/test/unit/profile-data.test.ts b/src/test/unit/profile-data.test.ts index be69a1c439..62149a0ac3 100644 --- a/src/test/unit/profile-data.test.ts +++ b/src/test/unit/profile-data.test.ts @@ -19,6 +19,7 @@ import { getSampleIndexToCallNodeIndex, getTreeOrderComparator, getSampleSelectedStates, + getSamplesSelectedStatesForFunction, extractProfileFilterPageData, findAddressProofForFile, calculateFunctionSizeLowerBound, @@ -1235,6 +1236,149 @@ describe('getSampleSelectedStates', function () { }); }); +describe('getSamplesSelectedStatesForFunction', function () { + function setup(textSamples: string) { + const { + derivedThreads, + funcNamesDictPerThread: [funcNamesDict], + } = getProfileFromTextSamples(textSamples); + const [thread] = derivedThreads; + const callNodeInfo = getCallNodeInfo( + thread.stackTable, + thread.frameTable, + 0 + ); + const sampleCallNodes = getSampleIndexToCallNodeIndex( + thread.samples.stack, + callNodeInfo.getStackIndexToNonInvertedCallNodeIndex() + ); + return { + callNodeTable: callNodeInfo.getCallNodeTable(), + sampleCallNodes, + funcNamesDict, + }; + } + + it('marks all non-filtered samples as selected when nothing is selected', function () { + const { callNodeTable, sampleCallNodes } = setup(` + A A A + B C + `); + expect( + Array.from( + getSamplesSelectedStatesForFunction( + sampleCallNodes, + null, + callNodeTable + ) + ) + ).toEqual([ + SelectedState.Selected, + SelectedState.Selected, + SelectedState.Selected, + ]); + }); + + it('marks samples as selected when their call stack contains the selected function', function () { + // 0 1 2 3 4 + // A A A A A + // B D B D D + // C E F G + const { + callNodeTable, + sampleCallNodes, + funcNamesDict: { B, D }, + } = setup(` + A A A A A + B D B D D + C E F G + `); + + // Selecting function B: samples 0, 2 have B in their stack + expect( + Array.from( + getSamplesSelectedStatesForFunction(sampleCallNodes, B, callNodeTable) + ) + ).toEqual([ + SelectedState.Selected, + SelectedState.UnselectedOrderedBeforeSelected, + SelectedState.Selected, + SelectedState.UnselectedOrderedBeforeSelected, + SelectedState.UnselectedOrderedBeforeSelected, + ]); + + // Selecting function D: samples 1, 3, 4 have D in their stack + expect( + Array.from( + getSamplesSelectedStatesForFunction(sampleCallNodes, D, callNodeTable) + ) + ).toEqual([ + SelectedState.UnselectedOrderedBeforeSelected, + SelectedState.Selected, + SelectedState.UnselectedOrderedBeforeSelected, + SelectedState.Selected, + SelectedState.Selected, + ]); + }); + + it('marks filtered-out samples as FilteredOutByTransform', function () { + const { + callNodeTable, + sampleCallNodes, + funcNamesDict: { B }, + } = setup(` + A A A + B C + `); + // Sample 2 has no stack (null), treated as filtered out. + // Manually null out sample 2's call node. + sampleCallNodes[2] = null; + + expect( + Array.from( + getSamplesSelectedStatesForFunction(sampleCallNodes, B, callNodeTable) + ) + ).toEqual([ + SelectedState.Selected, + SelectedState.UnselectedOrderedBeforeSelected, + SelectedState.FilteredOutByTransform, + ]); + }); + + it('selects samples whose ancestor call node contains the function, not just the leaf', function () { + // 0 1 + // A A + // B C + // D + // Selecting A should match all samples (A is an ancestor of everything). + const { + callNodeTable, + sampleCallNodes, + funcNamesDict: { A, B }, + } = setup(` + A A + B C + D + `); + + expect( + Array.from( + getSamplesSelectedStatesForFunction(sampleCallNodes, A, callNodeTable) + ) + ).toEqual([SelectedState.Selected, SelectedState.Selected]); + + // Selecting B only matches sample 0 (B is in the path A->B->D). + expect( + Array.from( + getSamplesSelectedStatesForFunction(sampleCallNodes, B, callNodeTable) + ) + ).toEqual([ + SelectedState.Selected, + SelectedState.UnselectedOrderedBeforeSelected, + ]); + }); +}); + describe('extractProfileFilterPageData', function () { const pages = { mozilla: { diff --git a/src/test/unit/profile-tree.test.ts b/src/test/unit/profile-tree.test.ts index 566bedf105..de37b8d418 100644 --- a/src/test/unit/profile-tree.test.ts +++ b/src/test/unit/profile-tree.test.ts @@ -330,6 +330,7 @@ describe('unfiltered call tree', function () { name: 'A', self: '—', selfWithUnit: '—', + selfPercent: '0%', total: '3', totalWithUnit: '3 samples', totalPercent: '100%', @@ -346,6 +347,7 @@ describe('unfiltered call tree', function () { name: 'I', self: '1', selfWithUnit: '1 sample', + selfPercent: '33%', total: '1', totalWithUnit: '1 sample', totalPercent: '33%', diff --git a/src/test/url-handling.test.ts b/src/test/url-handling.test.ts index 2ad21c34e0..3bdf75cc3f 100644 --- a/src/test/url-handling.test.ts +++ b/src/test/url-handling.test.ts @@ -23,6 +23,7 @@ import { changeIncludeIdleSamples, changeSelectedMarker, changeMarkerTableSort, + changeFunctionListSort, } from '../actions/profile-view'; import { changeSelectedTab, changeProfilesToCompare } from '../actions/app'; import { @@ -543,6 +544,57 @@ describe('marker table sort', function () { }); }); +describe('function list sort', function () { + function _getStoreOnFunctionList() { + const store = _getStoreWithURL(); + store.dispatch(changeSelectedTab('function-list')); + return store; + } + + it('omits the default sort from the URL', function () { + const { getState } = _getStoreOnFunctionList(); + expect(getQueryStringFromState(getState())).not.toContain( + 'functionListSort' + ); + }); + + it('serializes a non-default sort with primary first', function () { + const { getState, dispatch } = _getStoreOnFunctionList(); + // self desc primary, total asc tiebreaker (internal: primary last) + dispatch( + changeFunctionListSort([ + { column: 'total', ascending: true }, + { column: 'self', ascending: false }, + ]) + ); + expect(getQueryStringFromState(getState())).toContain( + 'functionListSort=self-desc~total-asc' + ); + }); + + it('round-trips a non-default sort through the URL', function () { + const { getState, dispatch } = _getStoreOnFunctionList(); + dispatch(changeFunctionListSort([{ column: 'self', ascending: false }])); + const url = urlFromState(getState().urlState); + const restored = stateFromLocation({ + pathname: new URL(url, 'http://localhost').pathname, + search: new URL(url, 'http://localhost').search, + hash: '', + }); + expect(restored.profileSpecific.functionListSort).toEqual([ + { column: 'self', ascending: false }, + ]); + }); + + it('falls back to the default when the URL has an invalid column', function () { + const { getState } = _getStoreWithURL({ + pathname: '/public/abc/function-list/', + search: '?functionListSort=bogus-desc', + }); + expect(getState().urlState.profileSpecific.functionListSort).toEqual([]); + }); +}); + describe('profileName', function () { it('serializes the profileName in the URL', function () { const { getState, dispatch } = _getStoreWithURL(); diff --git a/src/types/actions.ts b/src/types/actions.ts index 278f3d5cc6..9ceb650bc5 100644 --- a/src/types/actions.ts +++ b/src/types/actions.ts @@ -18,6 +18,7 @@ import type { FrameTable, SourceLocationTable, SourceTable, + IndexIntoFuncTable, } from './profile'; import type { Thread, @@ -192,6 +193,12 @@ type ProfileAction = readonly optionalExpandedToCallNodePath: CallNodePath | undefined; readonly context: SelectionContext; } + | { + readonly type: 'CHANGE_SELECTED_FUNCTION'; + readonly threadsKey: ThreadsKey; + readonly selectedFunctionIndex: IndexIntoFuncTable | null; + readonly context: SelectionContext; + } | { readonly type: 'UPDATE_TRACK_THREAD_HEIGHT'; readonly height: CssPixels; @@ -202,6 +209,11 @@ type ProfileAction = readonly threadsKey: ThreadsKey; readonly callNodePath: CallNodePath | null; } + | { + readonly type: 'CHANGE_RIGHT_CLICKED_FUNCTION'; + readonly threadsKey: ThreadsKey; + readonly functionIndex: IndexIntoFuncTable | null; + } | { readonly type: 'FOCUS_CALL_TREE'; } @@ -563,6 +575,10 @@ type UrlStateAction = readonly type: 'CHANGE_MARKER_TABLE_SORT'; readonly sort: SingleColumnSortState[]; } + | { + readonly type: 'CHANGE_FUNCTION_LIST_SORT'; + readonly sort: SingleColumnSortState[]; + } | { readonly type: 'CHANGE_NETWORK_SEARCH_STRING'; readonly searchString: string; diff --git a/src/types/profile-derived.ts b/src/types/profile-derived.ts index 89e7cfab68..a3c6eaaffb 100644 --- a/src/types/profile-derived.ts +++ b/src/types/profile-derived.ts @@ -516,6 +516,7 @@ export type CallNodeDisplayData = Readonly<{ totalPercent: string; self: string; selfWithUnit: string; + selfPercent: string; name: string; lib: string; isFrameLabel: boolean; diff --git a/src/types/state.ts b/src/types/state.ts index 0db1ffe6d4..a73b8b4772 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -23,6 +23,7 @@ import type { TabID, IndexIntoLibs, IndexIntoSourceTable, + IndexIntoFuncTable, } from './profile'; import type { @@ -61,6 +62,7 @@ export type ThreadViewOptions = { readonly selectedInvertedCallNodePath: CallNodePath; readonly expandedNonInvertedCallNodePaths: PathSet; readonly expandedInvertedCallNodePaths: PathSet; + readonly selectedFunctionIndex: IndexIntoFuncTable | null; readonly selectedNetworkMarker: MarkerIndex | null; // Track the number of transforms to detect when they change via browser // navigation. This helps us know when to reset paths that may be invalid @@ -83,6 +85,11 @@ export type RightClickedCallNode = { readonly callNodePath: CallNodePath; }; +export type RightClickedFunction = { + readonly threadsKey: ThreadsKey; + readonly functionIndex: IndexIntoFuncTable; +}; + export type MarkerReference = { readonly threadsKey: ThreadsKey; readonly markerIndex: MarkerIndex; @@ -108,6 +115,7 @@ export type ProfileViewState = { lastNonShiftClick: LastNonShiftClickInformation | null; rightClickedTrack: TrackReference | null; rightClickedCallNode: RightClickedCallNode | null; + rightClickedFunction: RightClickedFunction | null; rightClickedMarker: MarkerReference | null; hoveredMarker: MarkerReference | null; mouseTimePosition: Milliseconds | null; @@ -385,6 +393,7 @@ export type ProfileSpecificUrlState = { legacyHiddenThreads: ThreadIndex[] | null; selectedMarkers: SelectedMarkersPerThread; markerTableSort: SingleColumnSortState[]; + functionListSort: SingleColumnSortState[]; }; export type UrlState = { diff --git a/src/utils/types.ts b/src/utils/types.ts index 1d6df05f5e..9b41c5b1d9 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -42,6 +42,7 @@ export function toValidTabSlug(tabSlug: any): TabSlug | null { const coercedTabSlug = tabSlug as TabSlug; switch (coercedTabSlug) { case 'calltree': + case 'function-list': case 'stack-chart': case 'marker-chart': case 'network-chart': From 6616f460648ff05a6b9ffd72a0a85abe30479993 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:52:56 -0400 Subject: [PATCH 09/19] Implement function list context menu. --- src/actions/profile-view.ts | 2 +- src/components/app/Details.tsx | 2 + .../shared/FunctionListContextMenu.tsx | 488 ++++++++++++++++++ .../FunctionListContextMenu.test.tsx | 119 +++++ .../FunctionListContextMenu.test.tsx.snap | 216 ++++++++ 5 files changed, 826 insertions(+), 1 deletion(-) create mode 100644 src/components/shared/FunctionListContextMenu.tsx create mode 100644 src/test/components/FunctionListContextMenu.test.tsx create mode 100644 src/test/components/__snapshots__/FunctionListContextMenu.test.tsx.snap diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index b5c27752c4..5e9ddacb82 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -166,7 +166,7 @@ export function changeRightClickedCallNode( export function changeRightClickedFunctionIndex( threadsKey: ThreadsKey, functionIndex: IndexIntoFuncTable | null -) { +): Action { return { type: 'CHANGE_RIGHT_CLICKED_FUNCTION', threadsKey, diff --git a/src/components/app/Details.tsx b/src/components/app/Details.tsx index c571fd6945..85d92cc99a 100644 --- a/src/components/app/Details.tsx +++ b/src/components/app/Details.tsx @@ -27,6 +27,7 @@ import { getSelectedTab } from 'firefox-profiler/selectors/url-state'; import { getIsSidebarOpen } from 'firefox-profiler/selectors/app'; import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; import { CallNodeContextMenu } from 'firefox-profiler/components/shared/CallNodeContextMenu'; +import { FunctionListContextMenu } from 'firefox-profiler/components/shared/FunctionListContextMenu'; import { MaybeMarkerContextMenu } from 'firefox-profiler/components/shared/MarkerContextMenu'; import { toValidTabSlug } from 'firefox-profiler/utils/types'; @@ -135,6 +136,7 @@ class ProfileViewerImpl extends PureComponent { +
); diff --git a/src/components/shared/FunctionListContextMenu.tsx b/src/components/shared/FunctionListContextMenu.tsx new file mode 100644 index 0000000000..ccbdeb54a8 --- /dev/null +++ b/src/components/shared/FunctionListContextMenu.tsx @@ -0,0 +1,488 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import * as React from 'react'; +import { PureComponent } from 'react'; +import { MenuItem } from '@firefox-devtools/react-contextmenu'; +import { Localized } from '@fluent/react'; + +import { ContextMenu } from './ContextMenu'; +import explicitConnect from 'firefox-profiler/utils/connect'; +import { + funcHasDirectRecursiveCall, + funcHasRecursiveCall, +} from 'firefox-profiler/profile-logic/transforms'; +import { getFunctionName } from 'firefox-profiler/profile-logic/function-info'; + +import copy from 'copy-to-clipboard'; +import { + addTransformToStack, + addCollapseResourceTransformToStack, + setContextMenuVisibility, +} from 'firefox-profiler/actions/profile-view'; +import { getImplementationFilter } from 'firefox-profiler/selectors/url-state'; +import { getThreadSelectorsFromThreadsKey } from 'firefox-profiler/selectors/per-thread'; +import { + getProfileViewOptions, + getShouldDisplaySearchfox, +} from 'firefox-profiler/selectors/profile'; +import { oneLine } from 'common-tags'; + +import { + convertToTransformType, + assertExhaustiveCheck, +} from 'firefox-profiler/utils/types'; + +import type { + TransformType, + ImplementationFilter, + IndexIntoFuncTable, + Thread, + ThreadsKey, + CallNodeTable, + State, +} from 'firefox-profiler/types'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +import './CallNodeContextMenu.css'; + +type StateProps = { + readonly thread: Thread | null; + readonly threadsKey: ThreadsKey | null; + readonly rightClickedFunctionIndex: IndexIntoFuncTable | null; + readonly callNodeTable: CallNodeTable | null; + readonly implementation: ImplementationFilter; + readonly displaySearchfox: boolean; +}; + +type DispatchProps = { + readonly addTransformToStack: typeof addTransformToStack; + readonly addCollapseResourceTransformToStack: typeof addCollapseResourceTransformToStack; + readonly setContextMenuVisibility: typeof setContextMenuVisibility; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +class FunctionListContextMenuImpl extends PureComponent { + _hidingTimeout: NodeJS.Timeout | null = null; + + _onShow = () => { + if (this._hidingTimeout) { + clearTimeout(this._hidingTimeout); + } + this.props.setContextMenuVisibility(true); + }; + + _onHide = () => { + this._hidingTimeout = setTimeout(() => { + this._hidingTimeout = null; + this.props.setContextMenuVisibility(false); + }); + }; + + _getRightClickedInfo(): null | { + readonly thread: Thread; + readonly threadsKey: ThreadsKey; + readonly funcIndex: IndexIntoFuncTable; + readonly callNodeTable: CallNodeTable; + } { + const { thread, threadsKey, rightClickedFunctionIndex, callNodeTable } = + this.props; + if ( + thread !== null && + threadsKey !== null && + rightClickedFunctionIndex !== null && + callNodeTable !== null + ) { + return { + thread, + threadsKey, + funcIndex: rightClickedFunctionIndex, + callNodeTable, + }; + } + return null; + } + + _getFunctionName(): string { + const info = this._getRightClickedInfo(); + if (info === null) { + throw new Error( + "The context menu assumes there is a right-clicked function and there wasn't one." + ); + } + const { + thread: { stringTable, funcTable }, + funcIndex, + } = info; + const isJS = funcTable.isJS[funcIndex]; + const functionCall = stringTable.getString(funcTable.name[funcIndex]); + return isJS ? functionCall : getFunctionName(functionCall); + } + + lookupFunctionOnSearchfox(): void { + window.open( + `https://searchfox.org/mozilla-central/search?q=${encodeURIComponent( + this._getFunctionName() + )}`, + '_blank' + ); + } + + copyFunctionName(): void { + copy(this._getFunctionName()); + } + + getNameForSelectedResource(): string | null { + const info = this._getRightClickedInfo(); + if (info === null) { + throw new Error( + "The context menu assumes there is a right-clicked function and there wasn't one." + ); + } + const { + thread: { funcTable, stringTable, resourceTable, sources }, + funcIndex, + } = info; + const isJS = funcTable.isJS[funcIndex]; + if (isJS) { + const sourceIndex = funcTable.source[funcIndex]; + if (sourceIndex === null) { + return null; + } + return stringTable.getString(sources.filename[sourceIndex]); + } + const resourceIndex = funcTable.resource[funcIndex]; + if (resourceIndex === -1) { + return null; + } + return stringTable.getString(resourceTable.name[resourceIndex]); + } + + addTransformToStack(type: TransformType): void { + const { + addTransformToStack, + addCollapseResourceTransformToStack, + implementation, + } = this.props; + const info = this._getRightClickedInfo(); + if (info === null) { + throw new Error( + "The context menu assumes there is a right-clicked function and there wasn't one." + ); + } + const { threadsKey, thread, funcIndex } = info; + + switch (type) { + case 'focus-function': + addTransformToStack(threadsKey, { + type: 'focus-function', + funcIndex, + }); + break; + case 'focus-self': + addTransformToStack(threadsKey, { + type: 'focus-self', + funcIndex, + implementation, + }); + break; + case 'merge-function': + addTransformToStack(threadsKey, { + type: 'merge-function', + funcIndex, + }); + break; + case 'drop-function': + addTransformToStack(threadsKey, { + type: 'drop-function', + funcIndex, + }); + break; + case 'collapse-resource': { + const resourceIndex = thread.funcTable.resource[funcIndex]; + addCollapseResourceTransformToStack( + threadsKey, + resourceIndex, + implementation + ); + break; + } + case 'collapse-direct-recursion': + addTransformToStack(threadsKey, { + type: 'collapse-direct-recursion', + funcIndex, + implementation, + }); + break; + case 'collapse-recursion': + addTransformToStack(threadsKey, { + type: 'collapse-recursion', + funcIndex, + }); + break; + case 'collapse-function-subtree': + addTransformToStack(threadsKey, { + type: 'collapse-function-subtree', + funcIndex, + }); + break; + case 'focus-subtree': + case 'merge-call-node': + case 'focus-category': + case 'filter-samples': + throw new Error( + `The transform "${type}" is not supported in the function list context menu.` + ); + default: + assertExhaustiveCheck(type); + } + } + + _handleClick = ( + _event: React.ChangeEvent, + data: { type: string } + ): void => { + const { type } = data; + + const transformType = convertToTransformType(type); + if (transformType) { + this.addTransformToStack(transformType); + return; + } + + switch (type) { + case 'searchfox': + this.lookupFunctionOnSearchfox(); + break; + case 'copy-function-name': + this.copyFunctionName(); + break; + default: + throw new Error(`Unknown type ${type}`); + } + }; + + renderTransformMenuItem(props: { + readonly l10nId: string; + readonly content: React.ReactNode; + readonly onClick: ( + event: React.ChangeEvent, + data: { type: string } + ) => void; + readonly transform: string; + readonly shortcut: string; + readonly icon: string; + readonly title: string; + readonly l10nVars?: Record; + readonly l10nElems?: Record; + }) { + return ( + + + +
+ {props.content} +
+
+ {props.shortcut} +
+ ); + } + + renderContextMenuContents() { + const { displaySearchfox } = this.props; + const info = this._getRightClickedInfo(); + + if (info === null) { + console.error( + "The context menu assumes there is a right-clicked function and there wasn't one." + ); + return
; + } + + const { funcIndex, callNodeTable } = info; + const nameForResource = this.getNameForSelectedResource(); + + return ( + <> + {this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-merge-function', + shortcut: 'm', + icon: 'Merge', + onClick: this._handleClick, + transform: 'merge-function', + title: '', + content: 'Merge function', + })} + + {this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-focus-function', + shortcut: 'f', + icon: 'Focus', + onClick: this._handleClick, + transform: 'focus-function', + title: '', + content: 'Focus on function', + })} + + {this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-focus-self', + shortcut: 'S', + icon: 'FocusSelf', + onClick: this._handleClick, + transform: 'focus-self', + title: '', + content: 'Focus on self only', + })} + + {this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-collapse-function-subtree', + shortcut: 'c', + icon: 'Collapse', + onClick: this._handleClick, + transform: 'collapse-function-subtree', + title: '', + content: 'Collapse function', + })} + + {nameForResource + ? this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-collapse-resource', + l10nVars: { nameForResource }, + l10nElems: { strong: }, + shortcut: 'C', + icon: 'Collapse', + onClick: this._handleClick, + transform: 'collapse-resource', + title: '', + content: `Collapse ${nameForResource}`, + }) + : null} + + {funcHasRecursiveCall(callNodeTable, funcIndex) + ? this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-collapse-recursion', + shortcut: 'r', + icon: 'Collapse', + onClick: this._handleClick, + transform: 'collapse-recursion', + title: '', + content: 'Collapse recursion', + }) + : null} + + {funcHasDirectRecursiveCall(callNodeTable, funcIndex) + ? this.renderTransformMenuItem({ + l10nId: + 'CallNodeContextMenu--transform-collapse-direct-recursion-only', + shortcut: 'R', + icon: 'Collapse', + onClick: this._handleClick, + transform: 'collapse-direct-recursion', + title: '', + content: 'Collapse direct recursion only', + }) + : null} + + {this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-drop-function', + shortcut: 'd', + icon: 'Drop', + onClick: this._handleClick, + transform: 'drop-function', + title: '', + content: 'Drop samples with this function', + })} + +
+ + {displaySearchfox ? ( + + + Look up the function name on Searchfox + + + ) : null} + + + Copy function name + + + + ); + } + + override render() { + if (this._getRightClickedInfo() === null) { + return null; + } + + return ( + + {this.renderContextMenuContents()} + + ); + } +} + +export const FunctionListContextMenu = explicitConnect< + {}, + StateProps, + DispatchProps +>({ + mapStateToProps: (state: State) => { + const rightClickedFunction = + getProfileViewOptions(state).rightClickedFunction; + + let thread = null; + let threadsKey = null; + let rightClickedFunctionIndex = null; + let callNodeTable = null; + + if (rightClickedFunction !== null) { + const selectors = getThreadSelectorsFromThreadsKey( + rightClickedFunction.threadsKey + ); + thread = selectors.getFilteredThread(state); + threadsKey = rightClickedFunction.threadsKey; + rightClickedFunctionIndex = rightClickedFunction.functionIndex; + // Use the non-inverted call node table for recursion detection. + callNodeTable = selectors.getCallNodeInfo(state).getCallNodeTable(); + } + + return { + thread, + threadsKey, + rightClickedFunctionIndex, + callNodeTable, + implementation: getImplementationFilter(state), + displaySearchfox: getShouldDisplaySearchfox(state), + }; + }, + mapDispatchToProps: { + addTransformToStack, + addCollapseResourceTransformToStack, + setContextMenuVisibility, + }, + component: FunctionListContextMenuImpl, +}); diff --git a/src/test/components/FunctionListContextMenu.test.tsx b/src/test/components/FunctionListContextMenu.test.tsx new file mode 100644 index 0000000000..6f85957870 --- /dev/null +++ b/src/test/components/FunctionListContextMenu.test.tsx @@ -0,0 +1,119 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Provider } from 'react-redux'; +import copy from 'copy-to-clipboard'; + +import { render, screen } from 'firefox-profiler/test/fixtures/testing-library'; +import { FunctionListContextMenu } from '../../components/shared/FunctionListContextMenu'; +import { storeWithProfile } from '../fixtures/stores'; +import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; +import { fireFullClick } from '../fixtures/utils'; +import { + changeRightClickedFunctionIndex, + setContextMenuVisibility, +} from '../../actions/profile-view'; +import { selectedThreadSelectors } from '../../selectors/per-thread'; +import { ensureExists } from '../../utils/types'; + +describe('FunctionListContextMenu', function () { + // Create a profile that exercises all the conditional menu items: + // - B[lib:XUL] appears three times in a row (direct + indirect recursion) + // - B[lib:XUL] belongs to the XUL library (collapse-resource) + function createStore() { + const { + profile, + funcNamesDictPerThread: [{ B }], + } = getProfileFromTextSamples(` + A A A + B[lib:XUL] B[lib:XUL] B[lib:XUL] + B[lib:XUL] B[lib:XUL] B[lib:XUL] + B[lib:XUL] B[lib:XUL] B[lib:XUL] + C C H + D F I + E E + `); + const store = storeWithProfile(profile); + store.dispatch(changeRightClickedFunctionIndex(0, B)); + return store; + } + + function setup(store = createStore()) { + store.dispatch(setContextMenuVisibility(true)); + const renderResult = render( + + + + ); + return { ...renderResult, getState: store.getState }; + } + + describe('basic rendering', function () { + it('does not render when no function is right-clicked', () => { + const store = storeWithProfile(getProfileFromTextSamples('A').profile); + store.dispatch(setContextMenuVisibility(true)); + const { container } = render( + + + + ); + expect(container.querySelector('.react-contextmenu')).toBeNull(); + }); + + it('renders a full context menu when a function is right-clicked', () => { + const { container } = setup(); + expect( + ensureExists( + container.querySelector('.react-contextmenu'), + `Couldn't find the context menu root component .react-contextmenu` + ).children.length > 1 + ).toBeTruthy(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('does not include call-node-specific transforms', () => { + setup(); + expect(screen.queryByText(/Merge node only/)).not.toBeInTheDocument(); + expect( + screen.queryByText(/Focus on subtree only/) + ).not.toBeInTheDocument(); + expect(screen.queryByText(/Expand all/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Copy stack/)).not.toBeInTheDocument(); + }); + }); + + describe('clicking on transforms', function () { + const fixtures = [ + { matcher: /Merge function/, type: 'merge-function' }, + { matcher: /Focus on function/, type: 'focus-function' }, + { matcher: /Focus on self only/, type: 'focus-self' }, + { matcher: /Collapse function/, type: 'collapse-function-subtree' }, + { matcher: /XUL/, type: 'collapse-resource' }, + { matcher: /^Collapse recursion/, type: 'collapse-recursion' }, + { + matcher: /Collapse direct recursion/, + type: 'collapse-direct-recursion', + }, + { matcher: /Drop samples/, type: 'drop-function' }, + ]; + + fixtures.forEach(({ matcher, type }) => { + it(`adds a transform for "${type}"`, function () { + const { getState } = setup(); + fireFullClick(screen.getByText(matcher)); + expect( + selectedThreadSelectors.getTransformStack(getState())[0].type + ).toBe(type); + }); + }); + }); + + describe('clicking on utility items', function () { + it('can copy a function name', function () { + setup(); + fireFullClick(screen.getByText('Copy function name')); + expect(copy).toHaveBeenCalledWith('B'); + }); + }); +}); diff --git a/src/test/components/__snapshots__/FunctionListContextMenu.test.tsx.snap b/src/test/components/__snapshots__/FunctionListContextMenu.test.tsx.snap new file mode 100644 index 0000000000..ce0c6f9ce3 --- /dev/null +++ b/src/test/components/__snapshots__/FunctionListContextMenu.test.tsx.snap @@ -0,0 +1,216 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`FunctionListContextMenu basic rendering renders a full context menu when a function is right-clicked 1`] = ` +
+ +
+`; From f8e3e10d57020986dfe626fc3819f4c913cfd087 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:53:02 -0400 Subject: [PATCH 10/19] Implement transform shortcut keys for function list --- src/actions/profile-view.ts | 94 ++++++---- src/components/calltree/FunctionList.tsx | 36 ++-- .../components/TransformShortcuts.test.tsx | 176 ++++++++++++++++++ 3 files changed, 260 insertions(+), 46 deletions(-) diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 5e9ddacb82..161fc139b7 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -2065,49 +2065,89 @@ export function handleCallNodeTransformShortcut( if (event.metaKey || event.ctrlKey || event.altKey) { return; } - const threadSelectors = getThreadSelectorsFromThreadsKey(threadsKey); - const unfilteredThread = threadSelectors.getThread(getState()); - const implementation = getImplementationFilter(getState()); - const inverted = getInvertCallstack(getState()); - const callNodePath = callNodeInfo.getCallNodePathFromIndex(callNodeIndex); const funcIndex = callNodeInfo.funcForNode(callNodeIndex); - const category = callNodeInfo.categoryForNode(callNodeIndex); - - const callNodeTable = callNodeInfo.getCallNodeTable(); switch (event.key) { - case 'F': + case 'F': { + const callNodePath = + callNodeInfo.getCallNodePathFromIndex(callNodeIndex); + const implementation = getImplementationFilter(getState()); + const inverted = getInvertCallstack(getState()); dispatch( addTransformToStack(threadsKey, { type: 'focus-subtree', - callNodePath: callNodePath, + callNodePath, implementation, inverted, }) ); break; - case 'f': + } + case 'M': { + const callNodePath = + callNodeInfo.getCallNodePathFromIndex(callNodeIndex); + const implementation = getImplementationFilter(getState()); dispatch( addTransformToStack(threadsKey, { - type: 'focus-function', - funcIndex, + type: 'merge-call-node', + callNodePath, + implementation, }) ); break; - case 'S': + } + case 'g': { + const category = callNodeInfo.categoryForNode(callNodeIndex); dispatch( addTransformToStack(threadsKey, { - type: 'focus-self', + type: 'focus-category', + category, + }) + ); + break; + } + default: + dispatch( + handleFunctionTransformShortcut( + event, + threadsKey, + callNodeInfo, + funcIndex + ) + ); + } + }; +} + +export function handleFunctionTransformShortcut( + event: React.KeyboardEvent, + threadsKey: ThreadsKey, + callNodeInfo: CallNodeInfo, + funcIndex: IndexIntoFuncTable +): ThunkAction { + return (dispatch, getState) => { + if (event.metaKey || event.ctrlKey || event.altKey) { + return; + } + const threadSelectors = getThreadSelectorsFromThreadsKey(threadsKey); + const implementation = getImplementationFilter(getState()); + const callNodeTable = callNodeInfo.getCallNodeTable(); + const unfilteredThread = threadSelectors.getThread(getState()); + + switch (event.key) { + case 'f': + dispatch( + addTransformToStack(threadsKey, { + type: 'focus-function', funcIndex, - implementation, }) ); break; - case 'M': + case 'S': dispatch( addTransformToStack(threadsKey, { - type: 'merge-call-node', - callNodePath: callNodePath, + type: 'focus-self', + funcIndex, implementation, }) ); @@ -2129,8 +2169,7 @@ export function handleCallNodeTransformShortcut( ); break; case 'C': { - const { funcTable } = unfilteredThread; - const resourceIndex = funcTable.resource[funcIndex]; + const resourceIndex = unfilteredThread.funcTable.resource[funcIndex]; dispatch( addCollapseResourceTransformToStack( threadsKey, @@ -2163,7 +2202,7 @@ export function handleCallNodeTransformShortcut( } break; } - case 'c': { + case 'c': dispatch( addTransformToStack(threadsKey, { type: 'collapse-function-subtree', @@ -2171,17 +2210,8 @@ export function handleCallNodeTransformShortcut( }) ); break; - } - case 'g': - dispatch( - addTransformToStack(threadsKey, { - type: 'focus-category', - category, - }) - ); - break; default: - // This did not match a call tree transform. + // This did not match a function transform. } }; } diff --git a/src/components/calltree/FunctionList.tsx b/src/components/calltree/FunctionList.tsx index cf8577c0ae..9742f1890a 100644 --- a/src/components/calltree/FunctionList.tsx +++ b/src/components/calltree/FunctionList.tsx @@ -30,6 +30,7 @@ import { changeTableViewOptions, updateBottomBoxContentsAndMaybeOpen, changeFunctionListSort, + handleFunctionTransformShortcut, } from 'firefox-profiler/actions/profile-view'; import { nameColumn, @@ -47,6 +48,7 @@ import type { SelectionContext, } from 'firefox-profiler/types'; import type { CallTree } from 'firefox-profiler/profile-logic/call-tree'; +import type { CallNodeInfo } from 'firefox-profiler/profile-logic/call-node-info'; import type { SingleColumnSortState } from 'firefox-profiler/components/shared/TreeView'; import type { ConnectedProps } from 'firefox-profiler/utils/connect'; @@ -59,6 +61,7 @@ const DEFAULT_FUNCTION_LIST_SORT: SingleColumnSortState[] = [ type StateProps = { readonly threadsKey: ThreadsKey; + readonly callNodeInfo: CallNodeInfo; readonly scrollToSelectionGeneration: number; readonly focusCallTreeGeneration: number; readonly tree: CallTree; @@ -76,6 +79,7 @@ type DispatchProps = { readonly changeRightClickedFunctionIndex: typeof changeRightClickedFunctionIndex; readonly addTransformToStack: typeof addTransformToStack; readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; + readonly handleFunctionTransformShortcut: typeof handleFunctionTransformShortcut; readonly onTableViewOptionsChange: (opts: TableViewOptions) => any; readonly changeFunctionListSort: typeof changeFunctionListSort; }; @@ -159,20 +163,22 @@ class FunctionListImpl extends PureComponent { _newExpandedCallNodeIndexes: Array ) => {}; - _onKeyDown = (_event: React.KeyboardEvent) => { - // const { - // selectedFunctionIndex, - // rightClickedFunctionIndex, - // threadsKey, - // } = this.props; - // const nodeIndex = - // rightClickedFunctionIndex !== null - // ? rightClickedFunctionIndex - // : selectedFunctionIndex; - // if (nodeIndex === null) { - // return; - // } - // handleCallNodeTransformShortcut(event, threadsKey, nodeIndex); + _onKeyDown = (event: React.KeyboardEvent) => { + const { + selectedFunctionIndex, + rightClickedFunctionIndex, + threadsKey, + callNodeInfo, + handleFunctionTransformShortcut, + } = this.props; + const funcIndex = + rightClickedFunctionIndex !== null + ? rightClickedFunctionIndex + : selectedFunctionIndex; + if (funcIndex === null) { + return; + } + handleFunctionTransformShortcut(event, threadsKey, callNodeInfo, funcIndex); }; _onEnterOrDoubleClick = (nodeId: IndexIntoFuncTable) => { @@ -230,6 +236,7 @@ class FunctionListImpl extends PureComponent { export const FunctionList = explicitConnect<{}, StateProps, DispatchProps>({ mapStateToProps: (state: State) => ({ threadsKey: getSelectedThreadsKey(state), + callNodeInfo: selectedThreadSelectors.getCallNodeInfo(state), scrollToSelectionGeneration: getScrollToSelectionGeneration(state), focusCallTreeGeneration: getFocusCallTreeGeneration(state), tree: selectedThreadSelectors.getFunctionListTree(state), @@ -248,6 +255,7 @@ export const FunctionList = explicitConnect<{}, StateProps, DispatchProps>({ changeRightClickedFunctionIndex, addTransformToStack, updateBottomBoxContentsAndMaybeOpen, + handleFunctionTransformShortcut, onTableViewOptionsChange: (options: TableViewOptions) => changeTableViewOptions('calltree', options), changeFunctionListSort, diff --git a/src/test/components/TransformShortcuts.test.tsx b/src/test/components/TransformShortcuts.test.tsx index aefbcfbd4a..dd17a20645 100644 --- a/src/test/components/TransformShortcuts.test.tsx +++ b/src/test/components/TransformShortcuts.test.tsx @@ -11,6 +11,8 @@ import { storeWithProfile } from '../fixtures/stores'; import { changeSelectedCallNode, changeRightClickedCallNode, + changeSelectedFunctionIndex, + changeRightClickedFunctionIndex, } from '../../actions/profile-view'; import { FlameGraph } from '../../components/flame-graph'; import { selectedThreadSelectors } from 'firefox-profiler/selectors'; @@ -18,6 +20,7 @@ import { ensureExists, objectEntries } from '../../utils/types'; import { fireFullKeyPress } from '../fixtures/utils'; import { autoMockCanvasContext } from '../fixtures/mocks/canvas-context'; import { ProfileCallTreeView } from '../../components/calltree/ProfileCallTreeView'; +import { ProfileFunctionListView } from '../../components/calltree/ProfileFunctionListView'; import { StackChart } from 'firefox-profiler/components/stack-chart'; import type { Transform, @@ -186,6 +189,104 @@ const pressKeyBuilder = (className: string) => (options: KeyPressOptions) => { fireFullKeyPress(div, options); }; +function testFunctionTransformKeyboardShortcuts( + setup: () => { + getTransform: () => null | Transform; + pressKey: (options: KeyPressOptions) => void; + expectedFuncIndex: IndexIntoFuncTable; + expectedResourceIndex: IndexIntoResourceTable; + } +) { + describe('function shortcuts', () => { + it('handles focus-function', () => { + const { pressKey, getTransform, expectedFuncIndex } = setup(); + pressKey({ key: 'f' }); + expect(getTransform()).toEqual({ + type: 'focus-function', + funcIndex: expectedFuncIndex, + }); + }); + + it('handles focus-self', () => { + const { pressKey, getTransform, expectedFuncIndex } = setup(); + pressKey({ key: 'S' }); + expect(getTransform()).toMatchObject({ + type: 'focus-self', + funcIndex: expectedFuncIndex, + }); + }); + + it('handles merge function', () => { + const { pressKey, getTransform, expectedFuncIndex } = setup(); + pressKey({ key: 'm' }); + expect(getTransform()).toEqual({ + type: 'merge-function', + funcIndex: expectedFuncIndex, + }); + }); + + it('handles drop function', () => { + const { pressKey, getTransform, expectedFuncIndex } = setup(); + pressKey({ key: 'd' }); + expect(getTransform()).toEqual({ + type: 'drop-function', + funcIndex: expectedFuncIndex, + }); + }); + + it('handles collapse resource', () => { + const { pressKey, getTransform, expectedResourceIndex } = setup(); + pressKey({ key: 'C' }); + expect(getTransform()).toMatchObject({ + type: 'collapse-resource', + resourceIndex: expectedResourceIndex, + }); + }); + + it('handles collapse recursion', () => { + const { pressKey, getTransform, expectedFuncIndex } = setup(); + pressKey({ key: 'r' }); + expect(getTransform()).toMatchObject({ + type: 'collapse-recursion', + funcIndex: expectedFuncIndex, + }); + }); + + it('handles collapse direct recursion', () => { + const { pressKey, getTransform, expectedFuncIndex } = setup(); + pressKey({ key: 'R' }); + expect(getTransform()).toMatchObject({ + type: 'collapse-direct-recursion', + funcIndex: expectedFuncIndex, + }); + }); + + it('handles collapse function subtree', () => { + const { pressKey, getTransform, expectedFuncIndex } = setup(); + pressKey({ key: 'c' }); + expect(getTransform()).toEqual({ + type: 'collapse-function-subtree', + funcIndex: expectedFuncIndex, + }); + }); + + it('does not handle call-node-specific shortcuts', () => { + const { pressKey, getTransform } = setup(); + pressKey({ key: 'F' }); // focus-subtree + pressKey({ key: 'M' }); // merge-call-node + pressKey({ key: 'g' }); // focus-category + expect(getTransform()).toBeNull(); + }); + + it('ignores shortcuts with modifiers', () => { + const { pressKey, getTransform } = setup(); + pressKey({ key: 'c', ctrlKey: true }); + pressKey({ key: 'c', metaKey: true }); + expect(getTransform()).toBeNull(); + }); + }); // end describe('function shortcuts') +} + /* eslint-disable jest/no-standalone-expect */ // Disable the jest/no-standalone-expect rule because eslint doesn't know that // these expectations will run in a test block later. @@ -326,3 +427,78 @@ describe('stack chart transform shortcuts', () => { }); } }); + +/* eslint-disable jest/no-standalone-expect */ +const functionListActions = { + 'a selected function': ( + { dispatch, getState }: Store, + { B }: FuncNamesDict + ) => { + act(() => { + dispatch(changeSelectedFunctionIndex(0, B)); + }); + expect( + selectedThreadSelectors.getSelectedFunctionIndex(getState()) + ).not.toBeNull(); + expect( + selectedThreadSelectors.getRightClickedFunctionIndex(getState()) + ).toBeNull(); + }, + 'a right-clicked function': ( + { dispatch, getState }: Store, + { B }: FuncNamesDict + ) => { + act(() => { + dispatch(changeSelectedFunctionIndex(0, null)); + }); + act(() => { + dispatch(changeRightClickedFunctionIndex(0, B)); + }); + expect( + selectedThreadSelectors.getSelectedFunctionIndex(getState()) + ).toBeNull(); + expect( + selectedThreadSelectors.getRightClickedFunctionIndex(getState()) + ).not.toBeNull(); + }, + 'both a selected and a right-clicked function': ( + { dispatch, getState }: Store, + { A, B }: FuncNamesDict + ) => { + act(() => { + dispatch(changeSelectedFunctionIndex(0, A)); + }); + act(() => { + dispatch(changeRightClickedFunctionIndex(0, B)); + }); + expect( + selectedThreadSelectors.getSelectedFunctionIndex(getState()) + ).not.toBeNull(); + expect( + selectedThreadSelectors.getRightClickedFunctionIndex(getState()) + ).not.toBeNull(); + }, +}; +/* eslint-enable jest/no-standalone-expect */ + +describe('function list transform shortcuts', () => { + for (const [name, action] of objectEntries(functionListActions)) { + describe(`with ${name}`, () => { + testFunctionTransformKeyboardShortcuts(() => { + const { store, funcNames, getTransform } = setupStore( + + ); + + const { B } = funcNames; + action(store, funcNames); + + return { + getTransform, + pressKey: pressKeyBuilder('treeViewBody'), + expectedFuncIndex: B, + expectedResourceIndex: 0, + }; + }); + }); + } +}); From fa98515fe778292bb26c0149355386094dfc2f8c Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:53:03 -0400 Subject: [PATCH 11/19] Select self function when clicking in the activity graph --- src/actions/profile-view.ts | 34 +++++++++++++++++++++++++ src/components/timeline/TrackThread.tsx | 5 ++++ 2 files changed, 39 insertions(+) diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 161fc139b7..4f4f05f4a6 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -236,6 +236,40 @@ export function selectSelfCallNode( }; } +/** + * Like selectSelfCallNode, but selects the function of the self call node + * instead. Used when the function list tab is active. + */ +export function selectSelfFunction( + threadsKey: ThreadsKey, + sampleIndex: IndexIntoSamplesTable | null +): ThunkAction { + return (dispatch, getState) => { + if (sampleIndex === null || sampleIndex < 0) { + dispatch(changeSelectedFunctionIndex(threadsKey, null)); + return; + } + const threadSelectors = getThreadSelectorsFromThreadsKey(threadsKey); + const sampleCallNodes = + threadSelectors.getSampleIndexToNonInvertedCallNodeIndexForFilteredThread( + getState() + ); + if (sampleIndex >= sampleCallNodes.length) { + dispatch(changeSelectedFunctionIndex(threadsKey, null)); + return; + } + const nonInvertedSelfCallNode = sampleCallNodes[sampleIndex]; + if (nonInvertedSelfCallNode === null) { + dispatch(changeSelectedFunctionIndex(threadsKey, null)); + return; + } + const callNodeInfo = threadSelectors.getCallNodeInfo(getState()); + const funcIndex = + callNodeInfo.getCallNodeTable().func[nonInvertedSelfCallNode]; + dispatch(changeSelectedFunctionIndex(threadsKey, funcIndex)); + }; +} + /** * This selects a set of thread from thread indexes. * Please use it in tests only. diff --git a/src/components/timeline/TrackThread.tsx b/src/components/timeline/TrackThread.tsx index b1f24edaa7..7b713077b9 100644 --- a/src/components/timeline/TrackThread.tsx +++ b/src/components/timeline/TrackThread.tsx @@ -38,6 +38,7 @@ import { changeSelectedCallNode, focusCallTree, selectSelfCallNode, + selectSelfFunction, } from 'firefox-profiler/actions/profile-view'; import { reportTrackThreadHeight } from 'firefox-profiler/actions/app'; import { EmptyThreadIndicator } from './EmptyThreadIndicator'; @@ -100,6 +101,7 @@ type DispatchProps = { readonly changeSelectedCallNode: typeof changeSelectedCallNode; readonly focusCallTree: typeof focusCallTree; readonly selectSelfCallNode: typeof selectSelfCallNode; + readonly selectSelfFunction: typeof selectSelfFunction; readonly reportTrackThreadHeight: typeof reportTrackThreadHeight; }; @@ -123,6 +125,7 @@ class TimelineTrackThreadImpl extends PureComponent { const { threadsKey, selectSelfCallNode, + selectSelfFunction, focusCallTree, selectedThreadIndexes, callTreeVisible, @@ -130,6 +133,7 @@ class TimelineTrackThreadImpl extends PureComponent { // Sample clicking only works for one thread. See issue #2709 if (selectedThreadIndexes.size === 1) { + selectSelfFunction(threadsKey, sampleIndex); selectSelfCallNode(threadsKey, sampleIndex); if (sampleIndex !== null && callTreeVisible) { @@ -368,6 +372,7 @@ export const TimelineTrackThread = explicitConnect< changeSelectedCallNode, focusCallTree, selectSelfCallNode, + selectSelfFunction, reportTrackThreadHeight, }, component: withSize(TimelineTrackThreadImpl), From 182daca81172ef48ddf554687f3cd7d3bd3d64c9 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:55:52 -0400 Subject: [PATCH 12/19] lower wing support code --- src/profile-logic/call-tree.ts | 57 ++++++++++++++++++++++++++++++ src/test/fixtures/utils.ts | 50 ++++++++++++++++++++++++++ src/test/unit/profile-tree.test.ts | 42 ++++++++++++++++++++++ 3 files changed, 149 insertions(+) diff --git a/src/profile-logic/call-tree.ts b/src/profile-logic/call-tree.ts index 9d7b16573a..3e2d7e38c6 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -887,6 +887,63 @@ export function computeCallTreeTimingsInverted( }; } +// Returns a "self" array indexed by non-inverted call node. Only root-most +// entries to selectedFuncIndex are non-zero, and at each one we store the +// *inclusive* time of that invocation (self + descendants). Feeding this +// through computeCallTreeTimingsInverted then attributes each invocation's +// inclusive time to the lower-wing node whose path is its caller chain. +function _computeLowerWingCallNodeSelf( + callNodeSelf: Float64Array, + callNodeTable: CallNodeTable, + selectedFuncIndex: IndexIntoFuncTable +): Float64Array { + const callNodeCount = callNodeTable.length; + const funcCol = callNodeTable.func; + const subtreeEndCol = callNodeTable.subtreeRangeEnd; + const mappedSelf = new Float64Array(callNodeCount); + for (let i = 0; i < callNodeCount; i++) { + if (funcCol[i] !== selectedFuncIndex) { + continue; + } + + // Call node i is the root of a subtree for the selected function. + const subtreeEnd = subtreeEndCol[i]; + let subtreeTotal = 0; + for (let j = i; j < subtreeEnd; j++) { + subtreeTotal += callNodeSelf[j]; + } + mappedSelf[i] = subtreeTotal; + // Skip nested re-entries of selectedFuncIndex; their time is already + // included in the outer subtree total above. + i = subtreeEnd - 1; + } + return mappedSelf; +} + +export function computeLowerWingTimings( + callNodeInfo: CallNodeInfoInverted, + { callNodeSelf, rootTotalSummary }: CallNodeSelfAndSummary, + selectedFuncIndex: IndexIntoFuncTable | null +): CallTreeTimings { + const callNodeTable = callNodeInfo.getCallNodeTable(); + const mappedSelf = + selectedFuncIndex !== null + ? _computeLowerWingCallNodeSelf( + callNodeSelf, + callNodeTable, + selectedFuncIndex + ) + : new Float64Array(callNodeSelf.length); + + return { + type: 'INVERTED', + timings: computeCallTreeTimingsInverted(callNodeInfo, { + callNodeSelf: mappedSelf, + rootTotalSummary, + }), + }; +} + export function computeCallTreeTimings( callNodeInfo: CallNodeInfo, callNodeSelfAndSummary: CallNodeSelfAndSummary diff --git a/src/test/fixtures/utils.ts b/src/test/fixtures/utils.ts index 31b3093efa..d6ee431804 100644 --- a/src/test/fixtures/utils.ts +++ b/src/test/fixtures/utils.ts @@ -7,6 +7,7 @@ import { computeCallNodeSelfAndSummary, computeCallTreeTimings, computeFunctionListTimings, + computeLowerWingTimings, type CallTree, } from 'firefox-profiler/profile-logic/call-tree'; import { getEmptyThread } from 'firefox-profiler/profile-logic/data-structures'; @@ -265,6 +266,55 @@ export function functionListTreeFromProfile( ); } +/** + * This function creates the "lower wing" CallTree for a profile and a selected + * function. The lower wing is an inverted call tree where each root's total + * counts only samples where the selected function appears in the call stack. + */ +export function lowerWingTreeFromProfile( + profile: Profile, + selectedFuncName: string, + threadIndex: number = 0 +): CallTree { + const { derivedThreads, defaultCategory } = getProfileWithDicts(profile); + const thread = derivedThreads[threadIndex]; + const callNodeInfo = getCallNodeInfo( + thread.stackTable, + thread.frameTable, + defaultCategory + ); + const invertedCallNodeInfo = getInvertedCallNodeInfo( + callNodeInfo, + defaultCategory, + thread.funcTable.length + ); + const selectedFunc = + thread.funcTable.name.findIndex( + (i) => thread.stringTable.getString(i) === selectedFuncName + ) ?? null; + const selfAndSummary = computeCallNodeSelfAndSummary( + thread.samples, + getSampleIndexToCallNodeIndex( + thread.samples.stack, + callNodeInfo.getStackIndexToNonInvertedCallNodeIndex() + ), + callNodeInfo.getCallNodeTable().length + ); + const timings = computeLowerWingTimings( + invertedCallNodeInfo, + selfAndSummary, + selectedFunc === -1 ? null : selectedFunc + ); + return getCallTree( + thread, + invertedCallNodeInfo, + ensureExists(profile.meta.categories), + thread.samples, + timings, + 'samples' + ); +} + /** * This function formats a call tree into a human readable form, to make it easy * to assert certain relationships about the data structure in a really terse diff --git a/src/test/unit/profile-tree.test.ts b/src/test/unit/profile-tree.test.ts index de37b8d418..aad3511810 100644 --- a/src/test/unit/profile-tree.test.ts +++ b/src/test/unit/profile-tree.test.ts @@ -24,6 +24,7 @@ import { ResourceType } from 'firefox-profiler/types'; import { callTreeFromProfile, functionListTreeFromProfile, + lowerWingTreeFromProfile, formatTree, formatTreeIncludeCategories, addSourceToTable, @@ -579,6 +580,47 @@ describe('function list', function () { }); }); +describe('lower wing', function () { + // Samples: A->B->C, A->B->D, A->E->C, A->E->F + const textSamples = ` + A A A A + B B E E + C D C F + `; + + it('shows callers of the selected function as inverted roots', function () { + const { profile } = getProfileFromTextSamples(textSamples); + // Select C: C has self-time in both A->B->C and A->E->C, so C becomes the + // inverted root with total 2. Its callers B and E appear as children. + const callTree = lowerWingTreeFromProfile(profile, 'C'); + expect(formatTree(callTree)).toEqual([ + '- C (total: 2, self: 2)', + ' - B (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - E (total: 1, self: —)', + ' - A (total: 1, self: —)', + ]); + }); + + it('only counts samples where the selected function is present', function () { + const { profile } = getProfileFromTextSamples(textSamples); + // Select B: the self-time of B's subtree (C and D) gets attributed to B in + // the non-inverted table, so the inverted tree shows B as the root with + // total 2, and its caller A as a child. + const callTree = lowerWingTreeFromProfile(profile, 'B'); + expect(formatTree(callTree)).toEqual([ + '- B (total: 2, self: 2)', + ' - A (total: 2, self: —)', + ]); + }); + + it('returns an empty tree when no function is selected', function () { + const { profile } = getProfileFromTextSamples(textSamples); + const callTree = lowerWingTreeFromProfile(profile, 'NONEXISTENT'); + expect(formatTree(callTree)).toEqual([]); + }); +}); + describe('diffing trees', function () { function getProfile() { return getMergedProfileFromTextSamples([ From 5a414d32170b93e3cd22f38c0de33fb58b80dec1 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:56:09 -0400 Subject: [PATCH 13/19] Upper wing support code --- src/profile-logic/profile-data.ts | 439 ++++++++++++++++++++++++++++- src/test/fixtures/utils.ts | 50 ++++ src/test/unit/profile-tree.test.ts | 36 +++ 3 files changed, 513 insertions(+), 12 deletions(-) diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index 9bd9182b60..f05ad7755e 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -184,7 +184,10 @@ export function computeCallNodeTable( } const hierarchy = _computeCallNodeTableHierarchy(stackTable, frameTable); - const dfsOrder = _computeCallNodeTableDFSOrder(hierarchy); + const dfsOrder = _computeCallNodeTableDFSOrder( + hierarchy, + hierarchy.stackIndexToCallNodeIndex + ); const { stackIndexToCallNodeIndex } = dfsOrder; const frameInlinedIntoCol = _computeFrameTableInlinedIntoColumn(frameTable); const extraColumns = _computeCallNodeTableExtraColumns( @@ -231,7 +234,6 @@ type CallNodeTableHierarchy = { // there are no call nodes. firstRoot: IndexIntoCallNodeTable; length: number; - stackIndexToCallNodeIndex: Int32Array; }; /** @@ -296,7 +298,7 @@ type CallNodeTableExtraColumns = { function _computeCallNodeTableHierarchy( stackTable: StackTable, frameTable: FrameTable -): CallNodeTableHierarchy { +): CallNodeTableHierarchy & { stackIndexToCallNodeIndex: Int32Array } { const stackIndexToCallNodeIndex = new Int32Array(stackTable.length); // The callNodeTable components. @@ -447,16 +449,10 @@ function _computeCallNodeTableHierarchy( * siblings as what's in the `hierarchy` argument.) */ function _computeCallNodeTableDFSOrder( - hierarchy: CallNodeTableHierarchy + hierarchy: CallNodeTableHierarchy, + stackIndexToCallNodeIndex: Int32Array ): CallNodeTableDFSOrder { - const { - prefix, - firstChild, - firstRoot, - nextSibling, - length, - stackIndexToCallNodeIndex, - } = hierarchy; + const { prefix, firstChild, firstRoot, nextSibling, length } = hierarchy; const prefixSorted = new Int32Array(length); const nextSiblingSorted = new Int32Array(length); @@ -550,6 +546,116 @@ function _computeCallNodeTableDFSOrder( }; } +// mutates originalCallNodeToCallNodeIndex +function _computeCallNodeTableDFSOrder2( + hierarchy: CallNodeTableHierarchy, + originalCallNodeToCallNodeIndex: Int32Array, + stackIndexToOriginalCallNodeIndex: Int32Array +): CallNodeTableDFSOrder { + const { prefix, firstChild, firstRoot, nextSibling, length } = hierarchy; + + const prefixSorted = new Int32Array(length); + const nextSiblingSorted = new Int32Array(length); + const subtreeRangeEndSorted = new Uint32Array(length); + const depthSorted = new Int32Array(length); + let maxDepth = 0; + + if (length === 0) { + return { + prefixSorted, + subtreeRangeEndSorted, + nextSiblingSorted, + depthSorted, + maxDepth, + length, + stackIndexToCallNodeIndex: stackIndexToOriginalCallNodeIndex, + }; + } + + // Traverse the entire tree, as follows: + // 1. nextOldIndex is the next node in DFS order. Copy over all values from + // the unsorted columns into the sorted columns. + // 2. Find the next node in DFS order, set nextOldIndex to it, and continue + // to the next loop iteration. + // Start at firstRoot because, with func-sorted siblings, the head of the + // roots' sibling list is not necessarily call node 0. + const oldIndexToNewIndex = new Uint32Array(length); + let nextOldIndex = firstRoot; + let nextNewIndex = 0; + let currentDepth = 0; + let currentOldPrefix = -1; + let currentNewPrefix = -1; + while (nextOldIndex !== -1) { + const oldIndex = nextOldIndex; + const newIndex = nextNewIndex; + oldIndexToNewIndex[oldIndex] = newIndex; + nextNewIndex++; + + prefixSorted[newIndex] = currentNewPrefix; + depthSorted[newIndex] = currentDepth; + // The remaining two columns, nextSiblingSorted and subtreeRangeEndSorted, + // will be filled in when we get to the end of the current subtree. + + // Find the next index in DFS order: If we have children, then our first child + // is next. Otherwise, we need to advance to our next sibling, if we have one, + // otherwise to the next sibling of the first ancestor which has one. + const oldFirstChild = firstChild[oldIndex]; + if (oldFirstChild !== -1) { + // We have children. Our first child is the next node in DFS order. + currentOldPrefix = oldIndex; + currentNewPrefix = newIndex; + nextOldIndex = oldFirstChild; + currentDepth++; + if (currentDepth > maxDepth) { + maxDepth = currentDepth; + } + continue; + } + + // We have no children. The next node is the next sibling of this node or + // of an ancestor node. Now is also a good time to fill in the values for + // subtreeRangeEnd and nextSibling. + subtreeRangeEndSorted[newIndex] = nextNewIndex; + nextOldIndex = nextSibling[oldIndex]; + nextSiblingSorted[newIndex] = nextOldIndex === -1 ? -1 : nextNewIndex; + while (nextOldIndex === -1 && currentOldPrefix !== -1) { + subtreeRangeEndSorted[currentNewPrefix] = nextNewIndex; + const oldPrefixNextSibling = nextSibling[currentOldPrefix]; + nextSiblingSorted[currentNewPrefix] = + oldPrefixNextSibling === -1 ? -1 : nextNewIndex; + nextOldIndex = oldPrefixNextSibling; + currentOldPrefix = prefix[currentOldPrefix]; + currentNewPrefix = prefixSorted[currentNewPrefix]; + currentDepth--; + } + } + + for (let i = 0; i < originalCallNodeToCallNodeIndex.length; i++) { + const oldCallNodeIndex = originalCallNodeToCallNodeIndex[i]; + if (oldCallNodeIndex !== -1) { + originalCallNodeToCallNodeIndex[i] = oldIndexToNewIndex[oldCallNodeIndex]; + } + } + + const stackIndexToCallNodeIndex = new Int32Array( + stackIndexToOriginalCallNodeIndex.length + ); + for (let i = 0; i < stackIndexToCallNodeIndex.length; i++) { + stackIndexToCallNodeIndex[i] = + originalCallNodeToCallNodeIndex[stackIndexToOriginalCallNodeIndex[i]]; + } + + return { + prefixSorted, + subtreeRangeEndSorted, + nextSiblingSorted, + depthSorted, + maxDepth, + length, + stackIndexToCallNodeIndex, + }; +} + /** * Used as part of creating the call node table. * @@ -639,6 +745,81 @@ function _computeCallNodeTableExtraColumns( }; } +function _computeCallNodeTableExtraColumns2( + originalCallNodeTable: CallNodeTable, + oldCallNodeToNewCallNode: Int32Array, + callNodeCount: number, + defaultCategory: IndexIntoCategoryList +): CallNodeTableExtraColumns { + const originalCallNodeTableCategoryCol = originalCallNodeTable.category; + const originalCallNodeTableSubcategoryCol = originalCallNodeTable.subcategory; + const funcCol = new Int32Array(callNodeCount); + const categoryCol = new Int32Array(callNodeCount); + const subcategoryCol = new Int32Array(callNodeCount); + const innerWindowIDCol = new Float64Array(callNodeCount); + const inlinedIntoCol = new Int32Array(callNodeCount); + + const haveFilled = new Uint8Array(callNodeCount); + + for (let i = 0; i < originalCallNodeTable.length; i++) { + const category = originalCallNodeTableCategoryCol[i]; + const subcategory = originalCallNodeTableSubcategoryCol[i]; + const inlinedIntoSymbol = + originalCallNodeTable.sourceFramesInlinedIntoSymbol[i]; + + const callNodeIndex = oldCallNodeToNewCallNode[i]; + if (callNodeIndex === -1) { + continue; + } + + if (haveFilled[callNodeIndex] === 0) { + funcCol[callNodeIndex] = originalCallNodeTable.func[i]; + + categoryCol[callNodeIndex] = category; + subcategoryCol[callNodeIndex] = subcategory; + inlinedIntoCol[callNodeIndex] = inlinedIntoSymbol; + + const innerWindowID = originalCallNodeTable.innerWindowID[i]; + if (innerWindowID !== null && innerWindowID !== 0) { + // Set innerWindowID when it's not zero. Otherwise the value is already + // zero because typed arrays are initialized to zero. + innerWindowIDCol[callNodeIndex] = innerWindowID; + } + + haveFilled[callNodeIndex] = 1; + } else { + // Resolve category conflicts, by resetting a conflicting subcategory or + // category to the default category. + if (categoryCol[callNodeIndex] !== category) { + // Conflicting origin stack categories -> default category + subcategory. + categoryCol[callNodeIndex] = defaultCategory; + subcategoryCol[callNodeIndex] = 0; + } else if (subcategoryCol[callNodeIndex] !== subcategory) { + // Conflicting origin stack subcategories -> "Other" subcategory. + subcategoryCol[callNodeIndex] = 0; + } + + // Resolve "inlined into" conflicts. This can happen if you have two + // function calls A -> B where only one of the B calls is inlined, or + // if you use call tree transforms in such a way that a function B which + // was inlined into two different callers (A -> B, C -> B) gets collapsed + // into one call node. + if (inlinedIntoCol[callNodeIndex] !== inlinedIntoSymbol) { + // Conflicting inlining: -1. + inlinedIntoCol[callNodeIndex] = -1; + } + } + } + + return { + funcCol, + categoryCol, + subcategoryCol, + innerWindowIDCol, + inlinedIntoCol, + }; +} + /** * Generate the inverted CallNodeInfo for a thread. */ @@ -4914,3 +5095,237 @@ export function computeStackTableFromRawStackTable( length: rawStackTable.length, }; } + +export function createUpperWingCallNodeInfo( + callNodeInfo: CallNodeInfo, + selectedFunc: IndexIntoFuncTable | null, + stackTable: StackTable, + frameTable: FrameTable, + funcCount: number, + defaultCategory: IndexIntoCategoryList +): CallNodeInfo { + const originalCallNodeTable = callNodeInfo.getCallNodeTable(); + const originalStackIndexToCallNodeIndex = + callNodeInfo.getStackIndexToNonInvertedCallNodeIndex(); + const { callNodeTable, stackIndexToCallNodeIndex } = + _computeSelectedFuncCallNodeTable3( + selectedFunc, + originalCallNodeTable, + originalStackIndexToCallNodeIndex, + stackTable, + frameTable, + funcCount, + defaultCategory + ); + return new CallNodeInfoNonInverted(callNodeTable, stackIndexToCallNodeIndex); +} + +/** + * Like `_computeCallNodeTableHierarchy`, but iterates an existing call node + * table rather than the stack table, applying the upper-wing transform: every + * occurrence of `selectedFuncIndex` becomes a root, and original nodes outside + * a selected-func subtree are dropped. + * + * Multiple original nodes can collapse into one new node — every selected-func + * instance merges into a single root, and their descendants can further merge + * when they share a (mapped-prefix, func) pair. We therefore need the same + * "find existing sibling or insert in sorted order" logic the regular hierarchy + * builder uses, including the `lastUsed` hot path and early-out/late-enter on + * the sorted sibling lists. + */ +function _computeSelectedFuncCallNodeTableHierarchy( + originalCallNodeTable: CallNodeTable, + selectedFuncIndex: IndexIntoFuncTable | null +): CallNodeTableHierarchy & { + originalCallNodeToCallNodeIndex: Int32Array; +} { + const prefix = new Array(); + const firstChild = new Array(); + const nextSibling = new Array(); + const func = new Array(); + + const originalCallNodeToCallNodeIndex = new Int32Array( + originalCallNodeTable.length + ); + + let length = 0; + + // An extra column that only gets used while the table is built up: For each + // node A, lastUsedChild[A] is the child of A that we most recently matched + // or inserted. It is -1 while A has no children. + const lastUsedChild: Array = []; + + // The root counterparts to firstChild / lastUsedChild for the "virtual" + // parent -1. + let firstRoot = -1; + let lastUsedRoot = -1; + + for (let i = 0; i < originalCallNodeTable.length; i++) { + const funcIndex = originalCallNodeTable.func[i]; + + const originalPrefixCallNode = originalCallNodeTable.prefix[i]; + // We know that at this point the following condition holds: + // assert(originalPrefixCallNode === -1 || originalPrefixCallNode < i); + const prefixCallNode = + funcIndex === selectedFuncIndex || originalPrefixCallNode === -1 + ? -1 + : originalCallNodeToCallNodeIndex[originalPrefixCallNode]; + + if (prefixCallNode === -1 && funcIndex !== selectedFuncIndex) { + originalCallNodeToCallNodeIndex[i] = -1; + continue; + } + + const firstSibling = + prefixCallNode === -1 ? firstRoot : firstChild[prefixCallNode]; + + // Locate this (prefixCallNode, funcIndex) in the sorted sibling list. + // Either we find an existing match and reuse it, or we find the insertion + // point for a new node. When we need to insert, prevSibling is the node + // our new node should be linked after, or -1 if it should become the new + // head of the sibling list. + let callNodeIndex = -1; + let prevSibling = -1; // used for insertion, if callNodeIndex === -1 + + if (firstSibling !== -1) { + // Get the sibling that we used most recently for this parent. + // We know lastUsed is !== -1 because we know there is at least one sibling. + const lastUsed = + prefixCallNode === -1 ? lastUsedRoot : lastUsedChild[prefixCallNode]; + + if (funcIndex === func[lastUsed]) { + // Hot path: same func as the last child we touched for this parent. + callNodeIndex = lastUsed; + } else { + // We'll have to scan (at least part of) the list of siblings. + let sibling = firstSibling; + if (funcIndex > func[lastUsed]) { + // Since the list of siblings is ordered by func, we now know that can + // skip the part of the list that's before lastUsed. + // If lastUsed is the tail, sibling starts at -1 and we append without + // scanning. + prevSibling = lastUsed; + sibling = nextSibling[lastUsed]; + } + while (sibling !== -1) { + const siblingFunc = func[sibling]; + if (siblingFunc === funcIndex) { + // Found a match! + callNodeIndex = sibling; + break; + } + if (siblingFunc > funcIndex) { + // No match, and we can stop scanning here due to the ordering. + // We'll insert the new node before `sibling`; prevSibling is + // already its predecessor. + break; + } + prevSibling = sibling; + sibling = nextSibling[sibling]; + } + } + } + + if (callNodeIndex !== -1) { + originalCallNodeToCallNodeIndex[i] = callNodeIndex; + if (prefixCallNode === -1) { + lastUsedRoot = callNodeIndex; + } else { + lastUsedChild[prefixCallNode] = callNodeIndex; + } + continue; + } + + // New call node. + callNodeIndex = length++; + originalCallNodeToCallNodeIndex[i] = callNodeIndex; + + prefix[callNodeIndex] = prefixCallNode; + func[callNodeIndex] = funcIndex; + firstChild[callNodeIndex] = -1; + lastUsedChild[callNodeIndex] = -1; + + // Splice the new node into the sibling list. + if (prevSibling === -1) { + // Insert at head. + nextSibling[callNodeIndex] = firstSibling; + if (prefixCallNode === -1) { + firstRoot = callNodeIndex; + } else { + firstChild[prefixCallNode] = callNodeIndex; + } + } else { + // Insert after prevSibling. + nextSibling[callNodeIndex] = nextSibling[prevSibling]; + nextSibling[prevSibling] = callNodeIndex; + } + + if (prefixCallNode === -1) { + lastUsedRoot = callNodeIndex; + } else { + lastUsedChild[prefixCallNode] = callNodeIndex; + } + } + + return { + prefix, + firstRoot, + firstChild, + nextSibling, + originalCallNodeToCallNodeIndex, + length, + }; +} + +function _computeSelectedFuncCallNodeTable3( + selectedFuncIndex: IndexIntoFuncTable | null, + originalCallNodeTable: CallNodeTable, + stackIndexToOriginalCallNodeIndex: Int32Array, + _stackTable: StackTable, + _frameTable: FrameTable, + _funcCount: number, + defaultCategory: IndexIntoCategoryList +): CallNodeTableAndStackMap { + if (originalCallNodeTable.length === 0) { + return { + callNodeTable: getEmptyCallNodeTable(), + stackIndexToCallNodeIndex: new Int32Array(0), + }; + } + + const hierarchy = _computeSelectedFuncCallNodeTableHierarchy( + originalCallNodeTable, + selectedFuncIndex + ); + const { originalCallNodeToCallNodeIndex } = hierarchy; + const dfsOrder = _computeCallNodeTableDFSOrder2( + hierarchy, + originalCallNodeToCallNodeIndex, + stackIndexToOriginalCallNodeIndex + ); + const { stackIndexToCallNodeIndex } = dfsOrder; + const extraColumns = _computeCallNodeTableExtraColumns2( + originalCallNodeTable, + originalCallNodeToCallNodeIndex, + hierarchy.length, + defaultCategory + ); + + const callNodeTable = { + prefix: dfsOrder.prefixSorted, + nextSibling: dfsOrder.nextSiblingSorted, + subtreeRangeEnd: dfsOrder.subtreeRangeEndSorted, + func: extraColumns.funcCol, + category: extraColumns.categoryCol, + subcategory: extraColumns.subcategoryCol, + innerWindowID: extraColumns.innerWindowIDCol, + sourceFramesInlinedIntoSymbol: extraColumns.inlinedIntoCol, + depth: dfsOrder.depthSorted, + maxDepth: dfsOrder.maxDepth, + length: hierarchy.length, + }; + return { + callNodeTable, + stackIndexToCallNodeIndex, + }; +} diff --git a/src/test/fixtures/utils.ts b/src/test/fixtures/utils.ts index d6ee431804..1fa7032894 100644 --- a/src/test/fixtures/utils.ts +++ b/src/test/fixtures/utils.ts @@ -20,6 +20,7 @@ import { createThreadFromDerivedTables, computeStackTableFromRawStackTable, computeSamplesTableFromRawSamplesTable, + createUpperWingCallNodeInfo, } from 'firefox-profiler/profile-logic/profile-data'; import { getProfileWithDicts } from './profiles/processed-profile'; import { StringTable } from '../../utils/string-table'; @@ -266,6 +267,55 @@ export function functionListTreeFromProfile( ); } +/** + * This function creates the "upper wing" CallTree for a profile and a selected + * function. The upper wing shows the call subtrees that are rooted at the + * selected function, i.e. it answers "where is this function called from / what + * does it call". + */ +export function upperWingTreeFromProfile( + profile: Profile, + selectedFuncName: string, + threadIndex: number = 0 +): CallTree { + const { derivedThreads, defaultCategory } = getProfileWithDicts(profile); + const thread = derivedThreads[threadIndex]; + const callNodeInfo = getCallNodeInfo( + thread.stackTable, + thread.frameTable, + defaultCategory + ); + const selectedFunc = + thread.funcTable.name.findIndex( + (i) => thread.stringTable.getString(i) === selectedFuncName + ) ?? null; + const upperWingCallNodeInfo = createUpperWingCallNodeInfo( + callNodeInfo, + selectedFunc === -1 ? null : selectedFunc, + thread.stackTable, + thread.frameTable, + thread.funcTable.length, + defaultCategory + ); + const selfAndSummary = computeCallNodeSelfAndSummary( + thread.samples, + getSampleIndexToCallNodeIndex( + thread.samples.stack, + upperWingCallNodeInfo.getStackIndexToNonInvertedCallNodeIndex() + ), + upperWingCallNodeInfo.getCallNodeTable().length + ); + const timings = computeCallTreeTimings(upperWingCallNodeInfo, selfAndSummary); + return getCallTree( + thread, + upperWingCallNodeInfo, + ensureExists(profile.meta.categories), + thread.samples, + timings, + 'samples' + ); +} + /** * This function creates the "lower wing" CallTree for a profile and a selected * function. The lower wing is an inverted call tree where each root's total diff --git a/src/test/unit/profile-tree.test.ts b/src/test/unit/profile-tree.test.ts index aad3511810..466996c7d1 100644 --- a/src/test/unit/profile-tree.test.ts +++ b/src/test/unit/profile-tree.test.ts @@ -24,6 +24,7 @@ import { ResourceType } from 'firefox-profiler/types'; import { callTreeFromProfile, functionListTreeFromProfile, + upperWingTreeFromProfile, lowerWingTreeFromProfile, formatTree, formatTreeIncludeCategories, @@ -580,6 +581,41 @@ describe('function list', function () { }); }); +describe('upper wing', function () { + // Samples: A->B->C, A->B->D, A->E->C, A->E->F + const textSamples = ` + A A A A + B B E E + C D C F + `; + + it('shows all callee subtrees of the selected function', function () { + const { profile } = getProfileFromTextSamples(textSamples); + // Select B: show subtrees rooted at B (i.e. B->C and B->D) + const callTree = upperWingTreeFromProfile(profile, 'B'); + expect(formatTree(callTree)).toEqual([ + '- B (total: 2, self: —)', + ' - C (total: 1, self: 1)', + ' - D (total: 1, self: 1)', + ]); + }); + + it('merges call nodes with the same function across different callers', function () { + const { profile } = getProfileFromTextSamples(textSamples); + // C appears under both B and E; the upper wing for C should show both + // subtrees merged into one root C node + const callTree = upperWingTreeFromProfile(profile, 'C'); + expect(formatTree(callTree)).toEqual(['- C (total: 2, self: 2)']); + }); + + it('returns an empty tree when no function is selected', function () { + const { profile } = getProfileFromTextSamples(textSamples); + // null selection: no subtrees to show + const callTree = upperWingTreeFromProfile(profile, 'NONEXISTENT'); + expect(formatTree(callTree)).toEqual([]); + }); +}); + describe('lower wing', function () { // Samples: A->B->C, A->B->D, A->E->C, A->E->F const textSamples = ` From 30ecc7419408d40e2857274dc6b4234d13ece9a9 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:56:12 -0400 Subject: [PATCH 14/19] LowerWingCallNodeInfo (data part) --- src/profile-logic/call-node-info.ts | 697 +++++++++++++++++++++++++++- src/profile-logic/call-tree.ts | 67 +-- src/profile-logic/profile-data.ts | 33 +- src/test/fixtures/utils.ts | 28 +- src/test/unit/profile-tree.test.ts | 95 ++++ 5 files changed, 873 insertions(+), 47 deletions(-) diff --git a/src/profile-logic/call-node-info.ts b/src/profile-logic/call-node-info.ts index 7cf3a8daf3..c524db0969 100644 --- a/src/profile-logic/call-node-info.ts +++ b/src/profile-logic/call-node-info.ts @@ -581,8 +581,60 @@ type ChildrenInfo = { export type SuffixOrderIndex = number; /** - * The CallNodeInfo implementation for the inverted tree, with additional - * functionality for the inverted call tree. + * The interface for the inverted-mode call node info, which extends + * CallNodeInfo with the additional functionality that consumers of inverted + * call trees need (suffix order, root identification, function count). + * + * Two implementations exist: + * - LazyInvertedCallNodeInfo, which has one root per func and materializes + * non-root nodes on demand. Used for the regular "invert call stack" mode + * and the function list tab, where any func can be a displayed root. + * - LowerWingCallNodeInfo, which is built eagerly from the entry points of + * a single selected function. Used for the lower wing. + */ +export interface CallNodeInfoInverted extends CallNodeInfo { + // Get a mapping SuffixOrderIndex -> IndexIntoCallNodeTable. + // The contents may be mutated by the implementation as inverted nodes get + // materialized on demand. Callers should not hold on to this array across + // calls that may create new inverted nodes (getChildren, getCallNodeIndexFromPath). + getSuffixOrderedCallNodes(): Uint32Array; + + // The inverse of getSuffixOrderedCallNodes: IndexIntoCallNodeTable -> SuffixOrderIndex. + // Same staleness caveat as above. + getSuffixOrderIndexes(): Uint32Array; + + // The [start, exclusiveEnd] suffix order index range for this inverted node. + getSuffixOrderIndexRangeForCallNode( + nodeHandle: IndexIntoCallNodeTable + ): [SuffixOrderIndex, SuffixOrderIndex]; + + // The number of functions in the func table this CNI was built from. + // (Sizes per-func scratch buffers in callers; not directly tied to the + // number of inverted-tree roots — see getRootCount.) + getFuncCount(): number; + + // The number of roots in the inverted tree. Root call node handles are + // 0..getRootCount()-1. For the full inverted tree this equals funcCount + // (one root per func); for a lower-wing CNI it is 1. + getRootCount(): number; + + // Returns the inverted root call node handle for `funcIndex`, or -1 if no + // root in this CNI corresponds to that function (e.g. a lower-wing CNI only + // has a root for its selected func). + getRootNodeForFunc( + funcIndex: IndexIntoFuncTable + ): IndexIntoCallNodeTable | -1; + + // True if the given node is a root of the inverted tree. + isRoot(nodeHandle: IndexIntoCallNodeTable): boolean; + + // Materialize and return the children of this inverted node. Sorted by func. + getChildren(nodeIndex: IndexIntoCallNodeTable): IndexIntoCallNodeTable[]; +} + +/** + * The lazy CallNodeInfoInverted implementation. It has one root per func and + * materializes non-root nodes on demand. * * # The Suffix Order * @@ -902,7 +954,7 @@ export type SuffixOrderIndex = number; * deep nodes. They're not needed anymore. So _takeDeepNodesForInvertedNode * nulls out the stored deepNodes for an inverted node when it's called. */ -export class CallNodeInfoInverted implements CallNodeInfo { +export class LazyInvertedCallNodeInfo implements CallNodeInfoInverted { // The non-inverted call node table. _callNodeTable: CallNodeTable; @@ -1020,13 +1072,23 @@ export class CallNodeInfoInverted implements CallNodeInfo { return this._suffixOrderIndexes; } - // Get the number of functions. There is one root per function. - // So this is also the number of roots at the same time. - // The inverted call node index for a root is the same as the function index. + // Get the number of functions. There is one root per function in this + // (full inverted) CNI, so it also equals getRootCount(). + // For roots, the inverted call node handle equals the function index. getFuncCount(): number { return this._rootCount; } + getRootCount(): number { + return this._rootCount; + } + + // For the full inverted tree, each func has its own root and the root + // handle equals the func index. + getRootNodeForFunc(funcIndex: IndexIntoFuncTable): InvertedCallNodeHandle { + return funcIndex; + } + // Returns whether the given node is a root node. isRoot(nodeHandle: InvertedCallNodeHandle): boolean { return nodeHandle < this._rootCount; @@ -1716,3 +1778,626 @@ export class CallNodeInfoInverted implements CallNodeInfo { ]; } } + +// The lower wing's inverted call node table. Index 0 is the selected-func +// root; indices 1..length-1 are non-root nodes. The public InvertedCallNodeHandle +// IS the table index (no translation): handle 0 is the root, handles +// 1..length-1 are non-roots. +// +// Layout note: rows are appended in BFS-by-inverted-depth order, with each +// parent's children emitted contiguously, sorted by func index. Index 0 is the +// root (depth 0); indices 1..length-1 have monotonically non-decreasing depth. +// +// The columns are plain `number[]` (not typed arrays). The CNI grows them by +// `push()` as the BFS extends; `getLowerWingTableUpToDepth(k)` returns a +// snapshot object that aliases the same backing arrays (no copy). Consumers +// must iterate `[0, length)` only — entries beyond `length` may be appended +// later as the BFS expands further, and `children[i]` for `i >= _nextK` is the +// shared empty placeholder. +export type LowerWingTable = { + prefix: number[]; // -> InvertedCallNodeHandle + func: number[]; // -> IndexIntoFuncTable + category: number[]; + subcategory: number[]; + innerWindowID: number[]; + sourceFramesInlinedIntoSymbol: number[]; + depth: number[]; + suffixOrderIndexRangeStart: number[]; + suffixOrderIndexRangeEnd: number[]; + children: Array; // Sorted by func + length: number; +}; + +/** + * Lazy, depth-incremental CallNodeInfoInverted for the lower wing. + * + * Mirrors LazyInvertedCallNodeInfo's role but only for the single selected + * function: the lower wing's "root" is the inverted node for `selectedFuncIndex`, + * and every other logical root is empty. Construction is cheap: only Pass 1 + * (entry-point collection) runs upfront. The rest of the BFS is resumable and + * runs on demand via `getLowerWingTableUpToDepth(k)` or any accessor that needs + * to read a node's children. + * + * Why lazy: + * - For deeply-nested selections (e.g. an allocator called from everywhere), + * the full inverted subtree can have thousands of rows. The flame graph + * only renders the rows currently in the viewport, so building deeper than + * necessary is wasted work. + * + * Handle scheme: + * - There is exactly one root, with handle === 0. Its func is `selectedFuncIndex` + * (or 0 when no selection — the row is then a placeholder with no children). + * - Non-root handles are 1..length-1 and equal their table index. Handles + * other than 0 only exist after the BFS has expanded to materialize them. + * - `getRootNodeForFunc(f)` returns 0 iff f === selectedFuncIndex, else -1. + * - `getSuffixOrderedCallNodes()` and `getSuffixOrderIndexes()` are kept in + * sync as the BFS refines partitions. + */ +export class LowerWingCallNodeInfo implements CallNodeInfoInverted { + _callNodeTable: CallNodeTable; + _stackIndexToNonInvertedCallNodeIndex: Int32Array; + _defaultCategory: IndexIntoCategoryList; + // Number of functions in the originating func table. Used to size per-func + // scratch buffers in `_extendToDepth`; unrelated to the number of roots + // (which is always 1 — see getRootCount). + _funcCount: number; + _selectedFuncIndex: IndexIntoFuncTable | null; + + // Suffix-ordered entry points: maps suffix order index -> non-inverted call + // node index. Length === number of entry points. Permuted in-place as the + // BFS refines partitions, so consumers that hold the reference across an + // extension see updated positions. The set of entries in any node's + // [soStart, soEnd) range stays invariant. + _selfNodes: Uint32Array; + + // Inverse mapping: non-inverted call node -> suffix order index. Length === + // non-inverted call node count. Non-entry-point nodes get 0xFFFFFFFF. Kept + // in sync with _selfNodes during every partition step. + _suffixOrderIndexes: Uint32Array; + + // Per-suffix-position "deep" cursor advanced by the BFS. `_deepNodes[i]` is + // the non-inverted ancestor of `_selfNodes[i]` at the inverted depth that + // has currently been expanded for that suffix-order slot — i.e. how far up + // the original (non-inverted) call chain we've walked for that entry. The + // BFS steps it via `prefixCol[deepNodes[i]]` once per row. -1 marks an + // entry whose chain bottomed out (no more callers to expand). + _deepNodes: Int32Array; + + // Column-store of the growing inverted call node table. Index 0 is the + // selected-func root; indices 1..length-1 are non-roots. `_tChildren` + // always has the same length as the other columns — entries for not-yet- + // processed nodes hold the shared empty placeholder, so the snapshot is + // safe to index at any valid table index. + _tPrefix: number[]; + _tFunc: number[]; + _tCategory: number[]; + _tSubcategory: number[]; + _tInnerWindowID: number[]; + _tInlinedInto: number[]; + _tDepth: number[]; + _tSoStart: number[]; + _tSoEnd: number[]; + _tChildren: Array; + + // BFS cursor. Nodes with table index < _nextK have been "processed" (their + // children at depth+1 have been emitted and `_tChildren[idx]` is final). + // Nodes with index >= _nextK exist in the table but their children entries + // still hold the placeholder. + _nextK: number; + + // Pre-allocated scratch buffers reused across every BFS iteration inside + // `_extendToDepth`. None of these carry state across iterations — each + // iteration writes the slice it needs before reading it back. + _scratchSelf: Uint32Array; + _scratchDeep: Int32Array; + _countPerFunc: Int32Array; + _funcToChildIdx: Int32Array; + _startPerChild: Uint32Array; + _writePerChild: Uint32Array; + + // Cached snapshot wrapper. Reused as long as the table length hasn't grown + // since it was built. + _cachedTable: LowerWingTable | null; + _cachedTableLength: number; + + constructor( + callNodeTable: CallNodeTable, + stackIndexToNonInvertedCallNodeIndex: Int32Array, + defaultCategory: IndexIntoCategoryList, + funcCount: number, + selectedFuncIndex: IndexIntoFuncTable | null + ) { + this._callNodeTable = callNodeTable; + this._stackIndexToNonInvertedCallNodeIndex = + stackIndexToNonInvertedCallNodeIndex; + this._defaultCategory = defaultCategory; + this._funcCount = funcCount; + this._selectedFuncIndex = selectedFuncIndex; + + // Pass 1: collect root-most non-inverted nodes whose func is the selected + // one. The subtree skip-ahead prevents nested re-entries from being + // counted twice (an `f` called by `f` only contributes once). + const callNodeCount = callNodeTable.length; + const funcCol = callNodeTable.func; + const entries: number[] = []; + if (selectedFuncIndex !== null) { + const subtreeEndCol = callNodeTable.subtreeRangeEnd; + for (let i = 0; i < callNodeCount; i++) { + if (funcCol[i] !== selectedFuncIndex) { + continue; + } + entries.push(i); + i = subtreeEndCol[i] - 1; + } + } + + const N = entries.length; + this._selfNodes = new Uint32Array(N); + this._deepNodes = new Int32Array(N); + this._suffixOrderIndexes = new Uint32Array(callNodeCount); + this._suffixOrderIndexes.fill(0xffffffff); + + // Resolve root attributes. With entries, scan them for category/subcategory/ + // inlined conflicts (same rule as a non-root). Without entries, fall back + // to the same defaults the old `_emptyLowerWingTree` used so accessor + // results don't change for empty trees. + let rootCategory: number; + let rootSubcategory: number; + let rootInnerWindowID: number; + let rootInlined: number; + let rootFunc: number; + if (N === 0) { + rootCategory = defaultCategory; + rootSubcategory = 0; + rootInnerWindowID = 0; + rootInlined = -2; + rootFunc = selectedFuncIndex ?? 0; + } else { + const categoryCol = callNodeTable.category; + const subcategoryCol = callNodeTable.subcategory; + const innerWindowIDCol = callNodeTable.innerWindowID; + const inlinedCol = callNodeTable.sourceFramesInlinedIntoSymbol; + const e0 = entries[0]; + rootCategory = categoryCol[e0]; + rootSubcategory = subcategoryCol[e0]; + rootInnerWindowID = innerWindowIDCol[e0]; + rootInlined = inlinedCol[e0]; + this._selfNodes[0] = e0; + this._deepNodes[0] = e0; + this._suffixOrderIndexes[e0] = 0; + for (let k = 1; k < N; k++) { + const e = entries[k]; + this._selfNodes[k] = e; + this._deepNodes[k] = e; + this._suffixOrderIndexes[e] = k; + if (categoryCol[e] !== rootCategory) { + rootCategory = defaultCategory; + rootSubcategory = 0; + } else if (subcategoryCol[e] !== rootSubcategory) { + rootSubcategory = 0; + } + if (inlinedCol[e] !== rootInlined) { + rootInlined = -1; + } + } + rootFunc = selectedFuncIndex as number; + } + + // Seed the table with the root row. `_tChildren[0]` starts as the empty + // placeholder and gets overwritten by `_processNode(0)` when the root is + // expanded. + this._tPrefix = [-1]; + this._tFunc = [rootFunc]; + this._tCategory = [rootCategory]; + this._tSubcategory = [rootSubcategory]; + this._tInnerWindowID = [rootInnerWindowID]; + this._tInlinedInto = [rootInlined]; + this._tDepth = [0]; + this._tSoStart = [0]; + this._tSoEnd = [N]; + this._tChildren = [EMPTY_LOWER_WING_CHILDREN]; + + this._scratchSelf = new Uint32Array(N); + this._scratchDeep = new Int32Array(N); + this._countPerFunc = new Int32Array(funcCount); + this._funcToChildIdx = new Int32Array(funcCount); + this._startPerChild = new Uint32Array(funcCount); + this._writePerChild = new Uint32Array(funcCount); + + this._nextK = 0; + this._cachedTable = null; + this._cachedTableLength = -1; + } + + isInverted(): boolean { + return true; + } + + asInverted(): CallNodeInfoInverted | null { + return this; + } + + getCallNodeTable(): CallNodeTable { + return this._callNodeTable; + } + + getStackIndexToNonInvertedCallNodeIndex(): Int32Array { + return this._stackIndexToNonInvertedCallNodeIndex; + } + + getSuffixOrderedCallNodes(): Uint32Array { + return this._selfNodes; + } + + getSuffixOrderIndexes(): Uint32Array { + return this._suffixOrderIndexes; + } + + getFuncCount(): number { + return this._funcCount; + } + + getRootCount(): number { + return 1; + } + + // Only the selected func has a root in this CNI. Other funcs return -1. + getRootNodeForFunc( + funcIndex: IndexIntoFuncTable + ): InvertedCallNodeHandle | -1 { + return funcIndex === this._selectedFuncIndex ? 0 : -1; + } + + isRoot(nodeHandle: InvertedCallNodeHandle): boolean { + return nodeHandle === 0; + } + + getSuffixOrderIndexRangeForCallNode( + nodeHandle: InvertedCallNodeHandle + ): [SuffixOrderIndex, SuffixOrderIndex] { + return [this._tSoStart[nodeHandle], this._tSoEnd[nodeHandle]]; + } + + getChildren(nodeIndex: InvertedCallNodeHandle): InvertedCallNodeHandle[] { + // Extend the BFS far enough for this node to have its children + // materialized (depth 0 for the root, depth d for a node at depth d). + this._extendToDepth(this._tDepth[nodeIndex]); + return this._tChildren[nodeIndex]; + } + + getCallNodePathFromIndex( + callNodeHandle: InvertedCallNodeHandle | null + ): CallNodePath { + if (callNodeHandle === null || callNodeHandle === -1) { + return []; + } + const callNodePath: number[] = []; + let current = callNodeHandle; + while (current !== 0) { + callNodePath.push(this._tFunc[current]); + current = this._tPrefix[current]; + } + callNodePath.push(this._tFunc[0]); + callNodePath.reverse(); + return callNodePath; + } + + getCallNodeIndexFromPath( + callNodePath: CallNodePath + ): InvertedCallNodeHandle | null { + if (callNodePath.length === 0) { + return null; + } + // The only root in this CNI is for the selected func. Any path that doesn't + // start there has no match. + if (callNodePath[0] !== this._selectedFuncIndex) { + return null; + } + let handle: InvertedCallNodeHandle = 0; + for (let i = 1; i < callNodePath.length; i++) { + const next = this.getCallNodeIndexFromParentAndFunc( + handle, + callNodePath[i] + ); + if (next === null) { + return null; + } + handle = next; + } + return handle; + } + + getCallNodeIndexFromParentAndFunc( + parent: InvertedCallNodeHandle | -1, + func: IndexIntoFuncTable + ): InvertedCallNodeHandle | null { + if (parent === -1) { + return this.getRootNodeForFunc(func) === -1 ? null : 0; + } + const children = this.getChildren(parent); + // Children are sorted by func; bisect to find a match. + const index = bisectionRightByKey(children, func, (node) => + this.funcForNode(node) + ); + if (index === 0) { + return null; + } + const candidate = children[index - 1]; + return this.funcForNode(candidate) === func ? candidate : null; + } + + prefixForNode( + callNodeHandle: InvertedCallNodeHandle + ): InvertedCallNodeHandle | -1 { + return this._tPrefix[callNodeHandle]; + } + + funcForNode(callNodeHandle: InvertedCallNodeHandle): IndexIntoFuncTable { + return this._tFunc[callNodeHandle]; + } + + categoryForNode( + callNodeHandle: InvertedCallNodeHandle + ): IndexIntoCategoryList { + return this._tCategory[callNodeHandle]; + } + + subcategoryForNode( + callNodeHandle: InvertedCallNodeHandle + ): IndexIntoSubcategoryListForCategory { + return this._tSubcategory[callNodeHandle]; + } + + innerWindowIDForNode( + callNodeHandle: InvertedCallNodeHandle + ): IndexIntoCategoryList { + return this._tInnerWindowID[callNodeHandle]; + } + + depthForNode(callNodeHandle: InvertedCallNodeHandle): number { + return this._tDepth[callNodeHandle]; + } + + sourceFramesInlinedIntoSymbolForNode( + callNodeHandle: InvertedCallNodeHandle + ): IndexIntoNativeSymbolTable | -1 | -2 { + return this._tInlinedInto[callNodeHandle] as + | IndexIntoNativeSymbolTable + | -1 + | -2; + } + + // Returns a snapshot of the lower-wing inverted call node table containing + // at least every node with inverted depth <= `targetDepth`. The snapshot may + // also include nodes at `targetDepth + 1` (children emitted when the deepest + // requested level is processed) — callers must iterate up to `length`. + // + // Nodes whose `children` array is the shared `EMPTY_LOWER_WING_CHILDREN` + // placeholder are those that haven't been processed yet. They sit at + // `targetDepth + 1` (or are true leaves whose entry chains bottomed out). + // Calling `getLowerWingTableUpToDepth(k')` for any k' >= their depth will + // expand them on the next call. + getLowerWingTableUpToDepth(targetDepth: number): LowerWingTable { + this._extendToDepth(targetDepth); + return this._getSnapshotTable(); + } + + // Run the BFS while the next queued node sits at depth <= `targetDepth`. + // Nodes at depth `targetDepth + 1` may be added to the table by the final + // iteration but are not themselves processed (their `_tChildren[idx]` keeps + // the placeholder). + // + // The body that processes a single node (Pass 1 — bucket-count entries by + // their next ancestor's func; Pass 2 — scatter entries into scratch and + // copy back; Pass 3 — emit one child row per distinct func) is inlined here + // so all the `this.*` column references and scratch buffers are hoisted to + // locals once per BFS run rather than once per processed node. The local + // references stay valid across the loop because each column is a JS array + // grown in place via `push()` — the backing array identity doesn't change. + _extendToDepth(targetDepth: number): void { + const tPrefix = this._tPrefix; + const tFunc = this._tFunc; + const tCategory = this._tCategory; + const tSubcategory = this._tSubcategory; + const tInnerWindowID = this._tInnerWindowID; + const tInlinedInto = this._tInlinedInto; + const tDepth = this._tDepth; + const tSoStart = this._tSoStart; + const tSoEnd = this._tSoEnd; + const tChildren = this._tChildren; + + const selfNodes = this._selfNodes; + const deepNodes = this._deepNodes; + const suffixOrderIndexes = this._suffixOrderIndexes; + const callNodeTable = this._callNodeTable; + const funcCol = callNodeTable.func; + const prefixCol = callNodeTable.prefix; + const categoryCol = callNodeTable.category; + const subcategoryCol = callNodeTable.subcategory; + const innerWindowIDCol = callNodeTable.innerWindowID; + const inlinedCol = callNodeTable.sourceFramesInlinedIntoSymbol; + const countPerFunc = this._countPerFunc; + const funcToChildIdx = this._funcToChildIdx; + const startPerChild = this._startPerChild; + const writePerChild = this._writePerChild; + const scratchSelf = this._scratchSelf; + const scratchDeep = this._scratchDeep; + const defaultCategory = this._defaultCategory; + + // Scratch list of distinct funcs seen in Pass 1 of the current node. + // Cleared at the top of each iteration. + const childFuncs: number[] = []; + + let k = this._nextK; + while (k < tFunc.length) { + if (tDepth[k] > targetDepth) { + break; + } + + const parentSuffixStart = tSoStart[k]; + const parentSuffixEnd = tSoEnd[k]; + const parentDepth = tDepth[k]; + // Public handle === table index. + const parentHandle = k; + + // Pass 1: walk each entry's deepNode one step up, bucket-counting by + // the new deepNode's func. Entries whose chain bottoms out are tallied + // as leaves and stay at the parent. + childFuncs.length = 0; + let leafCount = 0; + for (let i = parentSuffixStart; i < parentSuffixEnd; i++) { + const newDeep = prefixCol[deepNodes[i]]; + if (newDeep === -1) { + leafCount++; + continue; + } + const f = funcCol[newDeep]; + if (countPerFunc[f] === 0) { + childFuncs.push(f); + } + countPerFunc[f]++; + } + + const childCount = childFuncs.length; + if (childCount === 0) { + // No children — leave the placeholder we seeded at child-emit time + // (or for the root in the constructor) in place. + k++; + continue; + } + + childFuncs.sort((a, b) => a - b); + + // Compute each child's absolute [start, end) range, leaves first then + // per-func partitions in sorted order. + let cursor = parentSuffixStart + leafCount; + for (let c = 0; c < childCount; c++) { + const f = childFuncs[c]; + funcToChildIdx[f] = c + 1; // 1-based; 0 means "unset" + startPerChild[c] = cursor; + writePerChild[c] = cursor; + cursor += countPerFunc[f]; + countPerFunc[f] = 0; + } + + // Pass 2: scatter (self, deep) pairs into scratch, then copy back — + // updating `_suffixOrderIndexes` so the inverse mapping stays in sync. + let leafWrite = parentSuffixStart; + for (let i = parentSuffixStart; i < parentSuffixEnd; i++) { + const self = selfNodes[i]; + const newDeep = prefixCol[deepNodes[i]]; + let writeAt; + let storeDeep; + if (newDeep === -1) { + writeAt = leafWrite++; + storeDeep = -1; + } else { + const c = funcToChildIdx[funcCol[newDeep]] - 1; + writeAt = writePerChild[c]++; + storeDeep = newDeep; + } + const local = writeAt - parentSuffixStart; + scratchSelf[local] = self; + scratchDeep[local] = storeDeep; + } + const len = parentSuffixEnd - parentSuffixStart; + for (let i = 0; i < len; i++) { + const pos = parentSuffixStart + i; + const ns = scratchSelf[i]; + selfNodes[pos] = ns; + deepNodes[pos] = scratchDeep[i]; + suffixOrderIndexes[ns] = pos; + } + + for (let i = 0; i < childCount; i++) { + funcToChildIdx[childFuncs[i]] = 0; + } + + // Pass 3: emit one child row per distinct func. Each child gets a + // placeholder `_tChildren` slot so column lengths stay aligned; the + // placeholder is overwritten when the child is itself processed. + const childHandles: InvertedCallNodeHandle[] = new Array(childCount); + const childDepth = parentDepth + 1; + for (let c = 0; c < childCount; c++) { + const f = childFuncs[c]; + const start = startPerChild[c]; + const end = writePerChild[c]; + const first = deepNodes[start]; + let category = categoryCol[first]; + let subcategory = subcategoryCol[first]; + const innerWindowID = innerWindowIDCol[first]; + let inlined = inlinedCol[first]; + for (let j = start + 1; j < end; j++) { + const dn = deepNodes[j]; + if (categoryCol[dn] !== category) { + category = defaultCategory; + subcategory = 0; + } else if (subcategoryCol[dn] !== subcategory) { + subcategory = 0; + } + if (inlinedCol[dn] !== inlined) { + inlined = -1; + } + } + + // Public handle === table index, which is the slot the child row is + // about to be pushed into. + const newHandle = tPrefix.length; + tPrefix.push(parentHandle); + tFunc.push(f); + tCategory.push(category); + tSubcategory.push(subcategory); + tInnerWindowID.push(innerWindowID); + tInlinedInto.push(inlined); + tDepth.push(childDepth); + tSoStart.push(start); + tSoEnd.push(end); + tChildren.push(EMPTY_LOWER_WING_CHILDREN); + + childHandles[c] = newHandle; + } + tChildren[k] = childHandles; + + k++; + } + this._nextK = k; + } + + // Return a snapshot view of the current table. The column fields alias the + // CNI's live backing arrays directly — no copy, no typed-array conversion. + // The arrays only grow by `push()` and `_tChildren[k]` is overwritten in + // place from the placeholder to the node's real children when the node is + // processed; in both cases the prefix `[0, length)` observed at snapshot + // time stays stable, so consumers iterating that prefix see consistent + // data even if the BFS extends further. + // + // The wrapper object itself is cached and reused as long as `length` hasn't + // grown since the last call — this keeps `===` identity stable for selector + // memoization downstream. + _getSnapshotTable(): LowerWingTable { + const length = this._tFunc.length; + if (this._cachedTable !== null && this._cachedTableLength === length) { + return this._cachedTable; + } + const table: LowerWingTable = { + prefix: this._tPrefix, + func: this._tFunc, + category: this._tCategory, + subcategory: this._tSubcategory, + innerWindowID: this._tInnerWindowID, + sourceFramesInlinedIntoSymbol: this._tInlinedInto, + depth: this._tDepth, + suffixOrderIndexRangeStart: this._tSoStart, + suffixOrderIndexRangeEnd: this._tSoEnd, + children: this._tChildren, + length, + }; + this._cachedTable = table; + this._cachedTableLength = length; + return table; + } +} + +// Shared empty children array for unused roots. Returned by reference; callers +// must not mutate. +const EMPTY_LOWER_WING_CHILDREN: InvertedCallNodeHandle[] = []; diff --git a/src/profile-logic/call-tree.ts b/src/profile-logic/call-tree.ts index 3e2d7e38c6..7637c6507d 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -66,9 +66,15 @@ export type InvertedCallTreeRoot = { export type CallTreeTimingsInverted = { callNodeSelf: Float64Array; rootTotalSummary: number; - sortedRoots: IndexIntoFuncTable[]; - totalPerRootFunc: Float64Array; - hasChildrenPerRootFunc: Uint8Array; + // Inverted-tree root call node indices, sorted by their total descending. + // For LazyInvertedCallNodeInfo these happen to equal func indices; for other + // CNIs (e.g. lower-wing) they're whatever index the CNI uses for its root. + sortedRoots: IndexIntoCallNodeTable[]; + // Indexed by inverted-tree root call node index (i.e. 0..rootCount-1, where + // rootCount = callNodeInfo.getRootCount()). For the standard inverted tree, + // root index === func index; for a lower-wing CNI, the array has length 1. + totalPerRootNode: Float64Array; + hasChildrenPerRootNode: Uint8Array; }; export type CallTreeTimingsFunctionList = { @@ -248,8 +254,8 @@ class CallTreeInternalInverted implements CallTreeInternal { _callNodeTable: CallNodeTable; _callNodeSelf: Float64Array; _rootNodes: IndexIntoCallNodeTable[]; - _totalPerRootFunc: Float64Array; - _hasChildrenPerRootFunc: Uint8Array; + _totalPerRootNode: Float64Array; + _hasChildrenPerRootNode: Uint8Array; _totalAndHasChildrenPerNonRootNode: Map< IndexIntoCallNodeTable, TotalAndHasChildren @@ -262,10 +268,10 @@ class CallTreeInternalInverted implements CallTreeInternal { this._callNodeInfo = callNodeInfo; this._callNodeTable = callNodeInfo.getCallNodeTable(); this._callNodeSelf = callTreeTimingsInverted.callNodeSelf; - const { sortedRoots, totalPerRootFunc, hasChildrenPerRootFunc } = + const { sortedRoots, totalPerRootNode, hasChildrenPerRootNode } = callTreeTimingsInverted; - this._totalPerRootFunc = totalPerRootFunc; - this._hasChildrenPerRootFunc = hasChildrenPerRootFunc; + this._totalPerRootNode = totalPerRootNode; + this._hasChildrenPerRootNode = hasChildrenPerRootNode; this._rootNodes = sortedRoots; } @@ -275,7 +281,7 @@ class CallTreeInternalInverted implements CallTreeInternal { hasChildren(callNodeIndex: IndexIntoCallNodeTable): boolean { if (this._callNodeInfo.isRoot(callNodeIndex)) { - return this._hasChildrenPerRootFunc[callNodeIndex] !== 0; + return this._hasChildrenPerRootNode[callNodeIndex] !== 0; } return this._getTotalAndHasChildren(callNodeIndex).hasChildren; } @@ -301,14 +307,14 @@ class CallTreeInternalInverted implements CallTreeInternal { getSelf(callNodeIndex: IndexIntoCallNodeTable): number { if (this._callNodeInfo.isRoot(callNodeIndex)) { - return this._totalPerRootFunc[callNodeIndex]; + return this._totalPerRootNode[callNodeIndex]; } return 0; } getTotal(callNodeIndex: IndexIntoCallNodeTable): number { if (this._callNodeInfo.isRoot(callNodeIndex)) { - return this._totalPerRootFunc[callNodeIndex]; + return this._totalPerRootNode[callNodeIndex]; } const { total } = this._getTotalAndHasChildren(callNodeIndex); return total; @@ -777,9 +783,9 @@ export function getSelfAndTotalForCallNode( case 'INVERTED': { const callNodeInfoInverted = ensureExists(callNodeInfo.asInverted()); const { timings } = callTreeTimings; - const { callNodeSelf, totalPerRootFunc } = timings; + const { callNodeSelf, totalPerRootNode } = timings; if (callNodeInfoInverted.isRoot(callNodeIndex)) { - const total = totalPerRootFunc[callNodeIndex]; + const total = totalPerRootNode[callNodeIndex]; return { self: total, total }; } const { total } = _getInvertedTreeNodeTotalAndHasChildren( @@ -847,14 +853,14 @@ export function computeCallTreeTimingsInverted( callNodeInfo: CallNodeInfoInverted, { callNodeSelf, rootTotalSummary }: CallNodeSelfAndSummary ): CallTreeTimingsInverted { - const funcCount = callNodeInfo.getFuncCount(); + const rootCount = callNodeInfo.getRootCount(); const callNodeTable = callNodeInfo.getCallNodeTable(); const callNodeTableFuncCol = callNodeTable.func; const callNodeTableDepthCol = callNodeTable.depth; - const totalPerRootFunc = new Float64Array(funcCount); - const hasChildrenPerRootFunc = new Uint8Array(funcCount); - const seenPerRootFunc = new Uint8Array(funcCount); - const sortedRoots = []; + const totalPerRootNode = new Float64Array(rootCount); + const hasChildrenPerRootNode = new Uint8Array(rootCount); + const seenPerRootNode = new Uint8Array(rootCount); + const sortedRoots: IndexIntoCallNodeTable[] = []; for (let i = 0; i < callNodeSelf.length; i++) { const self = callNodeSelf[i]; if (self === 0) { @@ -862,28 +868,33 @@ export function computeCallTreeTimingsInverted( } // Map the non-inverted call node to its corresponding root in the inverted - // call tree. This is done by finding the inverted root which corresponds to - // the self function of the non-inverted call node. + // call tree, via the CNI. For the full inverted tree this is the identity + // on func index; for a lower-wing CNI only one func has a root, so other + // funcs return -1 and are skipped here. const func = callNodeTableFuncCol[i]; + const rootNode = callNodeInfo.getRootNodeForFunc(func); + if (rootNode === -1) { + continue; + } - totalPerRootFunc[func] += self; - if (seenPerRootFunc[func] === 0) { - seenPerRootFunc[func] = 1; - sortedRoots.push(func); + totalPerRootNode[rootNode] += self; + if (seenPerRootNode[rootNode] === 0) { + seenPerRootNode[rootNode] = 1; + sortedRoots.push(rootNode); } if (callNodeTableDepthCol[i] !== 0) { - hasChildrenPerRootFunc[func] = 1; + hasChildrenPerRootNode[rootNode] = 1; } } sortedRoots.sort( - (a, b) => Math.abs(totalPerRootFunc[b]) - Math.abs(totalPerRootFunc[a]) + (a, b) => Math.abs(totalPerRootNode[b]) - Math.abs(totalPerRootNode[a]) ); return { callNodeSelf, rootTotalSummary, sortedRoots, - totalPerRootFunc, - hasChildrenPerRootFunc, + totalPerRootNode, + hasChildrenPerRootNode, }; } diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index f05ad7755e..670591c6c3 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -14,7 +14,8 @@ import { } from './data-structures'; import { CallNodeInfoNonInverted, - CallNodeInfoInverted, + LazyInvertedCallNodeInfo, + LowerWingCallNodeInfo, } from './call-node-info'; import { computeThreadCPUPercent } from './cpu'; import { @@ -105,7 +106,11 @@ import type { SourceLocationTable, } from 'firefox-profiler/types'; import { SelectedState, ResourceType } from 'firefox-profiler/types'; -import type { CallNodeInfo, SuffixOrderIndex } from './call-node-info'; +import type { + CallNodeInfo, + CallNodeInfoInverted, + SuffixOrderIndex, +} from './call-node-info'; /** * Various helpers for dealing with the profile as a data structure. @@ -828,7 +833,7 @@ export function getInvertedCallNodeInfo( defaultCategory: IndexIntoCategoryList, funcCount: number ): CallNodeInfoInverted { - return new CallNodeInfoInverted( + return new LazyInvertedCallNodeInfo( callNodeInfo.getCallNodeTable(), callNodeInfo.getStackIndexToNonInvertedCallNodeIndex(), defaultCategory, @@ -836,6 +841,28 @@ export function getInvertedCallNodeInfo( ); } +/** + * Generate the lower wing CallNodeInfo for a thread and a selected function. + * + * Unlike getInvertedCallNodeInfo, this builds the entire inverted subtree + * upfront, restricted to the selected function's entry points. It's the + * dedicated structure for the "lower wing" view. + */ +export function getLowerWingCallNodeInfo( + callNodeInfo: CallNodeInfo, + defaultCategory: IndexIntoCategoryList, + funcCount: number, + selectedFuncIndex: IndexIntoFuncTable | null +): CallNodeInfoInverted { + return new LowerWingCallNodeInfo( + callNodeInfo.getCallNodeTable(), + callNodeInfo.getStackIndexToNonInvertedCallNodeIndex(), + defaultCategory, + funcCount, + selectedFuncIndex + ); +} + // Compare two non-inverted call nodes in "suffix order". // The suffix order is defined as the lexicographical order of the inverted call // path, or, in other words, the "backwards" lexicographical order of the diff --git a/src/test/fixtures/utils.ts b/src/test/fixtures/utils.ts index 1fa7032894..f2c866b2e1 100644 --- a/src/test/fixtures/utils.ts +++ b/src/test/fixtures/utils.ts @@ -15,6 +15,7 @@ import { computeCallNodeFuncIsDuplicate, getCallNodeInfo, getInvertedCallNodeInfo, + getLowerWingCallNodeInfo, getSampleIndexToCallNodeIndex, getOriginAnnotationForFunc, createThreadFromDerivedTables, @@ -320,10 +321,13 @@ export function upperWingTreeFromProfile( * This function creates the "lower wing" CallTree for a profile and a selected * function. The lower wing is an inverted call tree where each root's total * counts only samples where the selected function appears in the call stack. + * + * Pass `null` (or a name that does not exist in the thread) to exercise the + * "no function selected" path. */ export function lowerWingTreeFromProfile( profile: Profile, - selectedFuncName: string, + selectedFuncName: string | null, threadIndex: number = 0 ): CallTree { const { derivedThreads, defaultCategory } = getProfileWithDicts(profile); @@ -333,15 +337,19 @@ export function lowerWingTreeFromProfile( thread.frameTable, defaultCategory ); - const invertedCallNodeInfo = getInvertedCallNodeInfo( + const selectedFunc = + selectedFuncName === null + ? -1 + : (thread.funcTable.name.findIndex( + (i) => thread.stringTable.getString(i) === selectedFuncName + ) ?? -1); + const selectedFuncIndex = selectedFunc === -1 ? null : selectedFunc; + const lowerWingCallNodeInfo = getLowerWingCallNodeInfo( callNodeInfo, defaultCategory, - thread.funcTable.length + thread.funcTable.length, + selectedFuncIndex ); - const selectedFunc = - thread.funcTable.name.findIndex( - (i) => thread.stringTable.getString(i) === selectedFuncName - ) ?? null; const selfAndSummary = computeCallNodeSelfAndSummary( thread.samples, getSampleIndexToCallNodeIndex( @@ -351,13 +359,13 @@ export function lowerWingTreeFromProfile( callNodeInfo.getCallNodeTable().length ); const timings = computeLowerWingTimings( - invertedCallNodeInfo, + lowerWingCallNodeInfo, selfAndSummary, - selectedFunc === -1 ? null : selectedFunc + selectedFuncIndex ); return getCallTree( thread, - invertedCallNodeInfo, + lowerWingCallNodeInfo, ensureExists(profile.meta.categories), thread.samples, timings, diff --git a/src/test/unit/profile-tree.test.ts b/src/test/unit/profile-tree.test.ts index 466996c7d1..e9181bd6fe 100644 --- a/src/test/unit/profile-tree.test.ts +++ b/src/test/unit/profile-tree.test.ts @@ -655,6 +655,101 @@ describe('lower wing', function () { const callTree = lowerWingTreeFromProfile(profile, 'NONEXISTENT'); expect(formatTree(callTree)).toEqual([]); }); + + it('returns an empty tree when null is passed', function () { + // Exercises the `selectedFuncIndex === null` early return in + // _buildLowerWingTree. + const { profile } = getProfileFromTextSamples(textSamples); + const callTree = lowerWingTreeFromProfile(profile, null); + expect(formatTree(callTree)).toEqual([]); + }); + + it('does not double-count nested re-entries of the selected function', function () { + // C calls itself: A->C->B->C. The inner C must not contribute a second + // entry point — the outer C's entry already covers the whole stack. This + // exercises the subtree skip-ahead at the `entries` collection loop. + const { profile } = getProfileFromTextSamples(` + A + C + B + C + `); + const callTree = lowerWingTreeFromProfile(profile, 'C'); + expect(formatTree(callTree)).toEqual([ + '- C (total: 1, self: 1)', + ' - A (total: 1, self: —)', + ]); + }); + + it('sorts caller children by func index', function () { + // Three distinct callers of X. Func indices follow column-major discovery + // order: Z=0, X=1, A=2, M=3. The lower wing must list the children sorted + // by func index (Z, then A, then M), not by stack iteration order. + const { profile } = getProfileFromTextSamples(` + Z A M + X X X + `); + const callTree = lowerWingTreeFromProfile(profile, 'X'); + expect(formatTree(callTree)).toEqual([ + '- X (total: 3, self: 3)', + ' - Z (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - M (total: 1, self: —)', + ]); + }); + + it('partitions callers across multiple depths', function () { + // Two stacks share a depth-4 ancestor chain through D. Exercising the + // partition loop past depth 1 ensures the suffix-ordered ranges and + // per-iteration scratch buffers behave correctly across iterations. + const { profile } = getProfileFromTextSamples(` + A A + B E + C C + D D + `); + const callTree = lowerWingTreeFromProfile(profile, 'D'); + expect(formatTree(callTree)).toEqual([ + '- D (total: 2, self: 2)', + ' - C (total: 2, self: —)', + ' - B (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - E (total: 1, self: —)', + ' - A (total: 1, self: —)', + ]); + }); + + it('attributes self time to a parent when an entry runs out of ancestors mid-walk', function () { + // Two entry points for X: one is at the top of its stack (no ancestor), + // the other has B above it. At root X the first entry is a "leaf" in the + // ancestor walk (newDeep === -1) and contributes to X's self only; the + // second contributes to child B. + const { profile } = getProfileFromTextSamples(` + X B + _ X + `); + const callTree = lowerWingTreeFromProfile(profile, 'X'); + expect(formatTree(callTree)).toEqual([ + '- X (total: 2, self: 2)', + ' - B (total: 1, self: —)', + ]); + }); + + it('falls back to the default category when entry points disagree', function () { + // Two entry points for C with conflicting categories. The non-inverted + // call nodes have different categories (Graphics vs DOM), and the lower + // wing root for C must resolve to the default category ('Other'). + const { profile } = getProfileFromTextSamples(` + A B + C[cat:Graphics] C[cat:DOM] + `); + const callTree = lowerWingTreeFromProfile(profile, 'C'); + expect(formatTreeIncludeCategories(callTree)).toEqual([ + '- C [Other] (total: 2, self: 2)', + ' - A [Other] (total: 1, self: —)', + ' - B [Other] (total: 1, self: —)', + ]); + }); }); describe('diffing trees', function () { From ed25302f7877f2d6cb2fbb3fdeb68ff28e5d5628 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Wed, 24 Jun 2026 16:24:30 -0400 Subject: [PATCH 15/19] Move flame graph tooltip percentage computation into a FlameGraphTimings method. --- src/components/flame-graph/Canvas.tsx | 8 ++++---- src/profile-logic/flame-graph.ts | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/components/flame-graph/Canvas.tsx b/src/components/flame-graph/Canvas.tsx index 7d40744d9b..29606ce3b5 100644 --- a/src/components/flame-graph/Canvas.tsx +++ b/src/components/flame-graph/Canvas.tsx @@ -393,10 +393,10 @@ class FlameGraphCanvasImpl extends React.PureComponent { return null; } - const ratio = - stackTiming.end[flameGraphTimingIndex] - - stackTiming.start[flameGraphTimingIndex]; - + const ratio = flameGraphTiming.getRatioOfRootTotalSummary( + depth, + flameGraphTimingIndex + ); let percentage = formatPercent(ratio); if (tracedTiming) { const time = formatCallNodeNumberWithUnit( diff --git a/src/profile-logic/flame-graph.ts b/src/profile-logic/flame-graph.ts index b6810c9833..6ed0a8598a 100644 --- a/src/profile-logic/flame-graph.ts +++ b/src/profile-logic/flame-graph.ts @@ -94,6 +94,20 @@ export class FlameGraphTiming { return rows; } + // For a given node / "box", get the sample percentage (as a fraction of 1) that + // should be displayed in the tooltip for this node. + getRatioOfRootTotalSummary(depth: number, indexInRow: number): number { + const row = this.getRow(depth); + if (indexInRow < 0 || indexInRow >= row.length) { + throw new Error( + `Out-of-bounds call to getRatioOfRootTotalSummary: For depth ${depth}, ${indexInRow} is outside 0..${row.length}` + ); + } + + const ratioOfFullWidth = row.end[indexInRow] - row.start[indexInRow]; + return ratioOfFullWidth; + } + _buildNextTimingRow(): void { const { total, self, rootTotalSummary } = this._callTreeTimings; const { prefix } = this._callNodeTable; From 50f60094752f6c44800f1056a292513ecca9f307 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:56:11 -0400 Subject: [PATCH 16/19] Restructure FlameGraphTiming and CallTreeTimings to support subtree-scoped percentages. --- src/profile-logic/call-tree.ts | 18 +++++++++++++++--- src/profile-logic/flame-graph.ts | 9 +++++---- .../__snapshots__/profile-view.test.ts.snap | 2 ++ src/test/unit/profile-tree.test.ts | 1 + src/types/profile-derived.ts | 6 ++++++ 5 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/profile-logic/call-tree.ts b/src/profile-logic/call-tree.ts index 7637c6507d..40b99d7e24 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -54,6 +54,7 @@ export type CallTreeTimingsNonInverted = { self: Float64Array; total: Float64Array; rootTotalSummary: number; // sum of absolute values, this is used for computing percentages + flameGraphWidthTotal: number; // used as 100% reference for flame graph box widths }; type TotalAndHasChildren = { total: number; hasChildren: boolean }; @@ -754,7 +755,11 @@ export function computeCallNodeSelfAndSummary( rootTotalSummary += abs(callNodeSelf[callNodeIndex]); } - return { callNodeSelf, rootTotalSummary }; + return { + callNodeSelf, + rootTotalSummary, + flameGraphWidthTotal: rootTotalSummary, + }; } export function getSelfAndTotalForCallNode( @@ -951,6 +956,7 @@ export function computeLowerWingTimings( timings: computeCallTreeTimingsInverted(callNodeInfo, { callNodeSelf: mappedSelf, rootTotalSummary, + flameGraphWidthTotal: rootTotalSummary, }), }; } @@ -987,7 +993,8 @@ export function computeCallTreeTimingsNonInverted( callNodeSelfAndSummary: CallNodeSelfAndSummary ): CallTreeTimingsNonInverted { const callNodeTable = callNodeInfo.getCallNodeTable(); - const { callNodeSelf, rootTotalSummary } = callNodeSelfAndSummary; + const { callNodeSelf, rootTotalSummary, flameGraphWidthTotal } = + callNodeSelfAndSummary; // Compute the following variables: const callNodeTotal = new Float64Array(callNodeTable.length); @@ -1025,6 +1032,7 @@ export function computeCallTreeTimingsNonInverted( total: callNodeTotal, callNodeHasChildren, rootTotalSummary, + flameGraphWidthTotal, }; } @@ -1427,5 +1435,9 @@ export function computeCallNodeTracedSelfAndSummary( } } - return { callNodeSelf, rootTotalSummary }; + return { + callNodeSelf, + rootTotalSummary, + flameGraphWidthTotal: rootTotalSummary, + }; } diff --git a/src/profile-logic/flame-graph.ts b/src/profile-logic/flame-graph.ts index 6ed0a8598a..4636f10d6d 100644 --- a/src/profile-logic/flame-graph.ts +++ b/src/profile-logic/flame-graph.ts @@ -105,11 +105,12 @@ export class FlameGraphTiming { } const ratioOfFullWidth = row.end[indexInRow] - row.start[indexInRow]; - return ratioOfFullWidth; + const total = ratioOfFullWidth * this._callTreeTimings.flameGraphWidthTotal; + return total / this._callTreeTimings.rootTotalSummary; } _buildNextTimingRow(): void { - const { total, self, rootTotalSummary } = this._callTreeTimings; + const { total, self, flameGraphWidthTotal } = this._callTreeTimings; const { prefix } = this._callNodeTable; const depth = this._timingRows.length; @@ -146,8 +147,8 @@ export class FlameGraphTiming { } startPerCallNode[nodeIndex] = currentStart; - const totalRelative = abs(totalVal / rootTotalSummary); - const selfRelativeVal = abs(self[nodeIndex] / rootTotalSummary); + const totalRelative = abs(totalVal / flameGraphWidthTotal); + const selfRelativeVal = abs(self[nodeIndex] / flameGraphWidthTotal); const currentEnd = currentStart + totalRelative; start.push(currentStart); diff --git a/src/test/store/__snapshots__/profile-view.test.ts.snap b/src/test/store/__snapshots__/profile-view.test.ts.snap index 771dd6d473..5327d34acf 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -2346,6 +2346,7 @@ CallTree { 0, 0, ], + "flameGraphWidthTotal": 2, "rootTotalSummary": 2, "self": Float64Array [ 0, @@ -3244,6 +3245,7 @@ Object { ], }, ], + "tooltipRatioMultiplier": 1, } `; diff --git a/src/test/unit/profile-tree.test.ts b/src/test/unit/profile-tree.test.ts index e9181bd6fe..7fa5b568e7 100644 --- a/src/test/unit/profile-tree.test.ts +++ b/src/test/unit/profile-tree.test.ts @@ -86,6 +86,7 @@ describe('unfiltered call tree', function () { type: 'NON_INVERTED', timings: { rootTotalSummary: 3, + flameGraphWidthTotal: 3, callNodeHasChildren: new Uint8Array([1, 1, 1, 1, 0, 1, 0, 1, 0]), self: new Float64Array([0, 0, 0, 0, 1, 0, 1, 0, 1]), total: new Float64Array([3, 3, 2, 1, 1, 1, 1, 1, 1]), diff --git a/src/types/profile-derived.ts b/src/types/profile-derived.ts index a3c6eaaffb..12a0794743 100644 --- a/src/types/profile-derived.ts +++ b/src/types/profile-derived.ts @@ -729,6 +729,12 @@ export type CallNodeSelfAndSummary = { // The sum of absolute values in callNodeSelf. // This is used for computing the percentages displayed in the call tree. rootTotalSummary: number; + // The total used as the 100% reference for flame graph box widths. + // Usually equals rootTotalSummary, but for the upper wing it is set to the + // total of samples that contain the selected function, so that the root node + // fills the full flame graph width while percentages remain relative to all + // filtered samples. + flameGraphWidthTotal: number; }; /** From 919a8c74a8054af01f680be1fa6e9b4c36039c1c Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:56:12 -0400 Subject: [PATCH 17/19] Add lower wing flame graph computation code. --- src/profile-logic/call-node-info.ts | 53 ++ src/profile-logic/call-tree.ts | 4 +- src/profile-logic/flame-graph.ts | 453 +++++++++++++++++- src/profile-logic/profile-data.ts | 5 +- .../__snapshots__/profile-view.test.ts.snap | 1 - src/test/unit/profile-tree.test.ts | 248 +++++++++- 6 files changed, 735 insertions(+), 29 deletions(-) diff --git a/src/profile-logic/call-node-info.ts b/src/profile-logic/call-node-info.ts index c524db0969..ef0a056292 100644 --- a/src/profile-logic/call-node-info.ts +++ b/src/profile-logic/call-node-info.ts @@ -2167,6 +2167,13 @@ export class LowerWingCallNodeInfo implements CallNodeInfoInverted { | -2; } + // Returns a snapshot of the lower-wing inverted call node table, fully + // expanded. Kept for callers (and tests) that want the whole tree at once; + // new code should prefer `getLowerWingTableUpToDepth(k)`. + getLowerWingTable(): LowerWingTable { + return this.getLowerWingTableUpToDepth(Infinity); + } + // Returns a snapshot of the lower-wing inverted call node table containing // at least every node with inverted depth <= `targetDepth`. The snapshot may // also include nodes at `targetDepth + 1` (children emitted when the deepest @@ -2182,6 +2189,10 @@ export class LowerWingCallNodeInfo implements CallNodeInfoInverted { return this._getSnapshotTable(); } + getSelectedFuncIndex(): IndexIntoFuncTable | null { + return this._selectedFuncIndex; + } + // Run the BFS while the next queued node sits at depth <= `targetDepth`. // Nodes at depth `targetDepth + 1` may be added to the table by the final // iteration but are not themselves processed (their `_tChildren[idx]` keeps @@ -2398,6 +2409,48 @@ export class LowerWingCallNodeInfo implements CallNodeInfoInverted { } } +/** + * Compute the number of rows in the lower-wing flame graph for `selectedFuncIndex` + * without building the lower-wing tree. + * + * The lower-wing max inverted depth equals the maximum non-inverted depth across + * all entry points (root-most non-inverted call nodes whose func is the selected + * one): an entry at non-inverted depth D contributes D ancestor steps above the + * inverted root, giving a final inverted depth of D. We add 1 to match the + * "depth-plus-one" row-count convention. + * + * Entry-point collection mirrors the skip-ahead in `_buildLowerWingTree` (Pass 1) + * so nested re-entries aren't double-counted. Returns 1 when there is no + * selection or no entry points — matching the empty-tree fallback in + * `_emptyLowerWingTree`, which has a length-1 root row at depth 0. + */ +export function computeLowerWingMaxDepthPlusOne( + callNodeTable: CallNodeTable, + selectedFuncIndex: IndexIntoFuncTable | null +): number { + if (selectedFuncIndex === null) { + return 1; + } + const funcCol = callNodeTable.func; + const subtreeEndCol = callNodeTable.subtreeRangeEnd; + const depthCol = callNodeTable.depth; + const callNodeCount = callNodeTable.length; + let maxDepth = 0; + let found = false; + for (let i = 0; i < callNodeCount; i++) { + if (funcCol[i] !== selectedFuncIndex) { + continue; + } + found = true; + const d = depthCol[i]; + if (d > maxDepth) { + maxDepth = d; + } + i = subtreeEndCol[i] - 1; + } + return found ? maxDepth + 1 : 1; +} + // Shared empty children array for unused roots. Returned by reference; callers // must not mutate. const EMPTY_LOWER_WING_CHILDREN: InvertedCallNodeHandle[] = []; diff --git a/src/profile-logic/call-tree.ts b/src/profile-logic/call-tree.ts index 40b99d7e24..fcc7ce5793 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -908,7 +908,7 @@ export function computeCallTreeTimingsInverted( // *inclusive* time of that invocation (self + descendants). Feeding this // through computeCallTreeTimingsInverted then attributes each invocation's // inclusive time to the lower-wing node whose path is its caller chain. -function _computeLowerWingCallNodeSelf( +export function computeLowerWingCallNodeSelf( callNodeSelf: Float64Array, callNodeTable: CallNodeTable, selectedFuncIndex: IndexIntoFuncTable @@ -944,7 +944,7 @@ export function computeLowerWingTimings( const callNodeTable = callNodeInfo.getCallNodeTable(); const mappedSelf = selectedFuncIndex !== null - ? _computeLowerWingCallNodeSelf( + ? computeLowerWingCallNodeSelf( callNodeSelf, callNodeTable, selectedFuncIndex diff --git a/src/profile-logic/flame-graph.ts b/src/profile-logic/flame-graph.ts index 4636f10d6d..03aaa7f0c0 100644 --- a/src/profile-logic/flame-graph.ts +++ b/src/profile-logic/flame-graph.ts @@ -6,9 +6,14 @@ import type { CallNodeTable, FuncTable, IndexIntoCallNodeTable, + IndexIntoFuncTable, + CallNodeSelfAndSummary, } from 'firefox-profiler/types'; import type { StringTable } from 'firefox-profiler/utils/string-table'; import type { CallTreeTimingsNonInverted } from './call-tree'; +import { computeLowerWingCallNodeSelf } from './call-tree'; +import type { LowerWingCallNodeInfo } from './call-node-info'; +import { computeLowerWingMaxDepthPlusOne } from './call-node-info'; import { bisectionRightByStrKey } from 'firefox-profiler/utils/bisect'; @@ -44,33 +49,17 @@ export type FlameGraphTimingRow = { * the implementation to generate rows lazily as the user scrolls towards * deeper calls. */ -export class FlameGraphTiming { - _flameGraphRows: FlameGraphRows; - _callNodeTable: CallNodeTable; - _callTreeTimings: CallTreeTimingsNonInverted; - - // Populated lazily by _buildNextTimingRow(). - _timingRows: FlameGraphTimingRow[]; - - // Used to position the children call node boxes: For a given parent box, its - // first (left-most) child box starts at the same x position as the parent. - _startPerCallNode: Float32Array; - - constructor( - flameGraphRows: FlameGraphRows, - callNodeTable: CallNodeTable, - callTreeTimings: CallTreeTimingsNonInverted - ) { - this._flameGraphRows = flameGraphRows; - this._callNodeTable = callNodeTable; - this._callTreeTimings = callTreeTimings; +export abstract class FlameGraphTiming { + _rowCount: number; + _timingRows: FlameGraphTimingRow[]; // populated lazily by _buildNextTimingRow() + constructor(rowCount: number) { this._timingRows = []; - this._startPerCallNode = new Float32Array(callNodeTable.length); + this._rowCount = rowCount; } get rowCount(): number { - return this._flameGraphRows.length; + return this._rowCount; } getRow(depth: number): FlameGraphTimingRow { @@ -96,6 +85,49 @@ export class FlameGraphTiming { // For a given node / "box", get the sample percentage (as a fraction of 1) that // should be displayed in the tooltip for this node. + abstract getRatioOfRootTotalSummary( + depth: number, + indexInRow: number + ): number; + + // Build and push `_timingRows[_timingRows.length]`. Called by `getRow` until + // enough rows have been built. Subclasses may extend their underlying tables + // here too. + abstract _buildNextTimingRow(): void; +} + +/** + * Non-inverted lazy timing: drives the regular flame graph, upper wing, and + * self wing. Reads per-node totals and selfs straight out of + * `CallTreeTimingsNonInverted` (which is already computed for the call tree + * view), and uses `callNodeTable.prefix` to align children under parents. + * + * The inner build loop is the structural twin of `LowerWingFlameGraphTiming`'s, + * minus the handle decoding and the `self = total − Σ child totals` step (here + * `self` is read directly). + */ +export class RegularFlameGraphTiming extends FlameGraphTiming { + _flameGraphRows: FlameGraphRows; + _callNodeTable: CallNodeTable; + _callTreeTimings: CallTreeTimingsNonInverted; + + // Used to position the children call node boxes: For a given parent box, its + // first (left-most) child box starts at the same x position as the parent. + _startPerCallNode: Float32Array; + + constructor( + flameGraphRows: FlameGraphRows, + callNodeTable: CallNodeTable, + callTreeTimings: CallTreeTimingsNonInverted + ) { + super(flameGraphRows.length); + this._flameGraphRows = flameGraphRows; + this._callNodeTable = callNodeTable; + this._callTreeTimings = callTreeTimings; + + this._startPerCallNode = new Float32Array(callNodeTable.length); + } + getRatioOfRootTotalSummary(depth: number, indexInRow: number): number { const row = this.getRow(depth); if (indexInRow < 0 || indexInRow >= row.length) { @@ -362,6 +394,379 @@ export function getFlameGraphTiming( flameGraphRows: FlameGraphRows, callNodeTable: CallNodeTable, callTreeTimings: CallTreeTimingsNonInverted -): FlameGraphTiming { - return new FlameGraphTiming(flameGraphRows, callNodeTable, callTreeTimings); +): RegularFlameGraphTiming { + return new RegularFlameGraphTiming( + flameGraphRows, + callNodeTable, + callTreeTimings + ); +} + +/** + * Per-LowerWingTable-index total and self values used to drive the lower wing + * flame graph. Both arrays have length equal to the lower-wing table length. + * Index 0 is the root (the selected function). + */ +export type LowerWingFlameGraphTotals = { + totalPerTableIdx: Float64Array; + selfPerTableIdx: Float64Array; + rootTotalSummary: number; + // Equal to rootTotalSummary — the lower wing's flame graph root fills the + // entire width. + flameGraphTotalForScaling: number; +}; + +/** + * Lazy, depth-incremental FlameGraphTiming for the lower wing. + * + * Wraps a `LowerWingCallNodeInfo` and produces a `FlameGraphTimingRow` for any + * depth on demand. Internal state: + * + * - `_rowsCallNodes[d]` is the row of call-node handles for depth `d`, sorted + * in flame-graph display order. Lazily built one depth at a time from the + * previous row's children — same algorithm as the old + * `computeLowerWingFlameGraphRows`, just sliced. + * - `_timingRows[d]` is the per-cell start/end/self/etc. for depth `d`. + * - `_prefixSums` is the running prefix sum over `lowerWing.getSuffixOrderedCallNodes()` + * in its *current* (partition-refined) order. We recompute whenever the + * underlying table has grown since the last recompute — extending the BFS + * permutes the suffix order within nodes' fixed `[soStart, soEnd)` ranges, + * which invalidates per-position prefix sums but leaves per-range sums + * unchanged. Detection is just `lowerWing.getLowerWingTableUpToDepth(-1).length`. + * - `_startPerTableIdx` carries cells' left edges across rows so children can + * be aligned under their parent. Grown as the table grows. + * + * `numRows` is the cheap upper bound from `computeLowerWingMaxDepthPlusOne`, + * so the Canvas can size the scroll area without forcing any tree build. + * + * Self-time correctness: row D's self values depend on the totals of nodes at + * depth D+1. `getRow(D)` extends the lower-wing CNI to depth D, which processes + * nodes at depth D and emits their depth-D+1 children — so children's + * `[soStart, soEnd)` ranges are set and their totals are computable. (For the + * deepest row, nodes have no children and self = total, which is also correct.) + */ +export class LowerWingFlameGraphTiming extends FlameGraphTiming { + _lowerWing: LowerWingCallNodeInfo; + _callNodeSelfAndSummary: CallNodeSelfAndSummary; + _callNodeTable: CallNodeTable; + _selectedFuncIndex: IndexIntoFuncTable | null; + _funcTable: FuncTable; + _stringTable: StringTable; + + // Lazy timing state. + _mappedSelf: Float64Array | null; + _prefixSums: Float64Array | null; + _prefixSumsForTableLength: number; + _rootTotalSummary: number; + _tooltipRatioMultiplier: number; + + // Lazy row state. `_rowsCallNodes[d]` is dense for `d < _rowsCallNodes.length`. + _rowsCallNodes: IndexIntoCallNodeTable[][]; + + // Persistent left-edge cache (table-index keyed) used by row alignment. + _startPerTableIdx: Float32Array; + + // Persistent total cache (table-index keyed). Populated for each node while + // iterating its parent's children to compute `childSum`, then read back when + // the node itself is emitted in the next row — avoids recomputing the same + // prefix-sums subtraction twice. Row-0 roots are the only nodes never seen + // as a child, so they compute their total directly. + _totalPerTableIdx: Float64Array; + + constructor( + lowerWing: LowerWingCallNodeInfo, + callNodeSelfAndSummary: CallNodeSelfAndSummary, + callNodeTable: CallNodeTable, + selectedFuncIndex: IndexIntoFuncTable | null, + funcTable: FuncTable, + stringTable: StringTable + ) { + super(computeLowerWingMaxDepthPlusOne(callNodeTable, selectedFuncIndex)); + this._lowerWing = lowerWing; + this._callNodeSelfAndSummary = callNodeSelfAndSummary; + this._callNodeTable = callNodeTable; + this._selectedFuncIndex = selectedFuncIndex; + this._funcTable = funcTable; + this._stringTable = stringTable; + + this._mappedSelf = null; + this._prefixSums = null; + this._prefixSumsForTableLength = -1; + this._rootTotalSummary = 0; + // tooltipRatioMultiplier for the lower wing is flameGraphTotalForScaling / + // rootTotalSummary, both equal to total[0]. So 1 when there's a selection + // with sample weight, 0 otherwise. We resolve this lazily inside + // `_ensurePrefixSums` since both values come from the prefix-sums pass. + this._tooltipRatioMultiplier = 0; + + this._rowsCallNodes = []; + this._startPerTableIdx = new Float32Array(16); + this._totalPerTableIdx = new Float64Array(16); + } + + getRatioOfRootTotalSummary(depth: number, indexInRow: number): number { + const row = this.getRow(depth); + if (indexInRow < 0 || indexInRow >= row.length) { + throw new Error( + `Out-of-bounds call to getRatioOfRootTotalSummary: For depth ${depth}, ${indexInRow} is outside 0..${row.length}` + ); + } + + const ratioOfFullWidth = row.end[indexInRow] - row.start[indexInRow]; + return ratioOfFullWidth; + } + + _ensureRowCallNodes(depth: number): void { + if (this._selectedFuncIndex === null) { + while (this._rowsCallNodes.length <= depth) { + this._rowsCallNodes.push([]); + } + return; + } + if (this._rowsCallNodes.length === 0) { + // Row 0: only the inverted root, whose handle is 0 in the lower wing. + this._rowsCallNodes.push([0]); + } + while (this._rowsCallNodes.length <= depth) { + this._buildNextRowCallNodes(); + } + } + + // Build `_rowsCallNodes[next]` from the previous row's children. The + // lower-wing table stores children sorted by func index, so we re-sort by + // name via bisect-insert here (mirrors the old `computeLowerWingFlameGraphRows`). + _buildNextRowCallNodes(): void { + const nextDepth = this._rowsCallNodes.length; + // Need parents (at nextDepth - 1) processed, i.e. CNI extended to + // nextDepth - 1 so their children at nextDepth are populated. + const table = this._lowerWing.getLowerWingTableUpToDepth(nextDepth - 1); + const funcNameCol = this._funcTable.name; + const stringTable = this._stringTable; + const tableFunc = table.func; + const tableChildren = table.children; + + const parentRow = this._rowsCallNodes[nextDepth - 1]; + const childRow: IndexIntoCallNodeTable[] = []; + + // Handle === table index for the lower-wing CNI, so no translation. + for (let p = 0; p < parentRow.length; p++) { + const parentHandle = parentRow[p]; + const children = tableChildren[parentHandle]; + if (children.length === 0) { + continue; + } + const groupStart = childRow.length; + for (let c = 0; c < children.length; c++) { + const childHandle = children[c]; + const childFunc = tableFunc[childHandle]; + const childName = stringTable.getString(funcNameCol[childFunc]); + const groupEnd = childRow.length; + if (groupStart === groupEnd) { + childRow.push(childHandle); + } else { + const insertionIndex = bisectionRightByStrKey( + childRow, + childName, + (handle) => stringTable.getString(funcNameCol[tableFunc[handle]]), + groupStart, + groupEnd + ); + childRow.splice(insertionIndex, 0, childHandle); + } + } + } + + this._rowsCallNodes.push(childRow); + } + + // Compute (or refresh) `_prefixSums` if the underlying CNI table has grown + // since the last computation. Also resolves `_rootTotalSummary` and + // `_tooltipRatioMultiplier` on the first run — those don't change as the + // tree grows because the root's `[soStart, soEnd)` covers the full entry + // set and that set is invariant. + _ensurePrefixSums(): void { + if (this._selectedFuncIndex === null) { + if (this._prefixSums === null) { + this._prefixSums = new Float64Array(1); + // _rootTotalSummary and _tooltipRatioMultiplier stay at their default 0. + this._prefixSumsForTableLength = + this._lowerWing.getLowerWingTableUpToDepth(-1).length; + } + return; + } + + const tableLength = this._lowerWing.getLowerWingTableUpToDepth(-1).length; + if ( + this._prefixSums !== null && + this._prefixSumsForTableLength === tableLength + ) { + return; + } + + if (this._mappedSelf === null) { + this._mappedSelf = computeLowerWingCallNodeSelf( + this._callNodeSelfAndSummary.callNodeSelf, + this._callNodeTable, + this._selectedFuncIndex + ); + } + const mappedSelf = this._mappedSelf; + const suffixOrdered = this._lowerWing.getSuffixOrderedCallNodes(); + const N = suffixOrdered.length; + const prefixSums = new Float64Array(N + 1); + for (let k = 0; k < N; k++) { + prefixSums[k + 1] = prefixSums[k] + mappedSelf[suffixOrdered[k]]; + } + this._prefixSums = prefixSums; + this._prefixSumsForTableLength = tableLength; + // total[0] = root's range = all entries. Compute once; invariant across + // further extensions. + if (this._rootTotalSummary === 0 && N > 0) { + this._rootTotalSummary = prefixSums[N]; + this._tooltipRatioMultiplier = this._rootTotalSummary === 0 ? 0 : 1; + } + } + + _growPerTableIdxArraysTo(minLength: number): void { + if (this._startPerTableIdx.length >= minLength) { + return; + } + let newCap = this._startPerTableIdx.length; + while (newCap < minLength) { + newCap *= 2; + } + const nextStart = new Float32Array(newCap); + nextStart.set(this._startPerTableIdx); + this._startPerTableIdx = nextStart; + const nextTotal = new Float64Array(newCap); + nextTotal.set(this._totalPerTableIdx); + this._totalPerTableIdx = nextTotal; + } + + _buildNextTimingRow(): void { + const nextDepth = this._timingRows.length; + + // Extend the CNI to nextDepth so depth-nextDepth nodes are processed — + // this populates their `_tChildren` and adds the depth-(nextDepth+1) + // children whose totals feed into self-time at nextDepth. + const table = this._lowerWing.getLowerWingTableUpToDepth(nextDepth); + + this._ensureRowCallNodes(nextDepth); + this._ensurePrefixSums(); + this._growPerTableIdxArraysTo(table.length); + + const rowNodes = this._rowsCallNodes[nextDepth]; + const flameGraphTotalForScaling = this._rootTotalSummary; + if (flameGraphTotalForScaling === 0) { + this._timingRows.push({ + start: [], + end: [], + selfRelative: [], + callNode: [], + length: 0, + }); + return; + } + + const tablePrefix = table.prefix; + const tableSoStart = table.suffixOrderIndexRangeStart; + const tableSoEnd = table.suffixOrderIndexRangeEnd; + const tableChildren = table.children; + const prefixSums = this._prefixSums as Float64Array; + const startPerTableIdx = this._startPerTableIdx; + const totalPerTableIdx = this._totalPerTableIdx; + + // Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1858310 + const abs = Math.abs; + + const start: UnitIntervalOfProfileRange[] = []; + const end: UnitIntervalOfProfileRange[] = []; + const selfRelative: number[] = []; + const timingCallNodes: IndexIntoCallNodeTable[] = []; + + let currentStart = 0; + let previousPrefixIdx = -1; + // Handle === table index for the lower-wing CNI. + for (let i = 0; i < rowNodes.length; i++) { + const tableIdx = rowNodes[i]; + // Row-0 roots are never seen as a child, so their total isn't cached; + // compute directly. Deeper rows read the total cached by their parent's + // childSum loop on the previous row. + const totalVal = + nextDepth === 0 + ? prefixSums[tableSoEnd[tableIdx]] - + prefixSums[tableSoStart[tableIdx]] + : totalPerTableIdx[tableIdx]; + if (totalVal === 0) { + continue; + } + + // self = total − Σ children's totals. Children at depth nextDepth+1 + // are guaranteed to exist (CNI was extended to nextDepth), so each + // child's [soStart, soEnd) is final and its total is well-defined. + // Cache each child's total so the next row can read it directly. + const children = tableChildren[tableIdx]; + let childSum = 0; + for (let c = 0; c < children.length; c++) { + const childIdx = children[c]; + const childTotal = + prefixSums[tableSoEnd[childIdx]] - prefixSums[tableSoStart[childIdx]]; + totalPerTableIdx[childIdx] = childTotal; + childSum += childTotal; + } + const selfVal = totalVal - childSum; + + const parentIdx = tablePrefix[tableIdx]; + + if (parentIdx !== previousPrefixIdx) { + currentStart = parentIdx === -1 ? 0 : startPerTableIdx[parentIdx]; + previousPrefixIdx = parentIdx; + } + startPerTableIdx[tableIdx] = currentStart; + + const totalRelative = abs(totalVal / flameGraphTotalForScaling); + const selfRelativeVal = abs(selfVal / flameGraphTotalForScaling); + + const currentEnd = currentStart + totalRelative; + start.push(currentStart); + end.push(currentEnd); + selfRelative.push(selfRelativeVal); + timingCallNodes.push(tableIdx); + + currentStart = currentEnd; + } + + this._timingRows.push({ + start, + end, + selfRelative, + callNode: timingCallNodes, + length: timingCallNodes.length, + }); + } +} + +/** + * Construct the (lazy) flame-graph timing for the lower wing. + * + * The returned object is the unit of work the Canvas talks to: it asks for a + * row by depth and the timing object extends the lower-wing CNI, refreshes + * prefix sums, and computes only the rows that have been asked for. + */ +export function createLowerWingFlameGraphTiming( + lowerWing: LowerWingCallNodeInfo, + callNodeSelfAndSummary: CallNodeSelfAndSummary, + callNodeTable: CallNodeTable, + selectedFuncIndex: IndexIntoFuncTable | null, + funcTable: FuncTable, + stringTable: StringTable +): LowerWingFlameGraphTiming { + return new LowerWingFlameGraphTiming( + lowerWing, + callNodeSelfAndSummary, + callNodeTable, + selectedFuncIndex, + funcTable, + stringTable + ); } diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index 670591c6c3..7d885dc4cc 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -16,7 +16,10 @@ import { CallNodeInfoNonInverted, LazyInvertedCallNodeInfo, LowerWingCallNodeInfo, + computeLowerWingMaxDepthPlusOne, } from './call-node-info'; + +export { computeLowerWingMaxDepthPlusOne }; import { computeThreadCPUPercent } from './cpu'; import { INSTANT, @@ -853,7 +856,7 @@ export function getLowerWingCallNodeInfo( defaultCategory: IndexIntoCategoryList, funcCount: number, selectedFuncIndex: IndexIntoFuncTable | null -): CallNodeInfoInverted { +): LowerWingCallNodeInfo { return new LowerWingCallNodeInfo( callNodeInfo.getCallNodeTable(), callNodeInfo.getStackIndexToNonInvertedCallNodeIndex(), diff --git a/src/test/store/__snapshots__/profile-view.test.ts.snap b/src/test/store/__snapshots__/profile-view.test.ts.snap index 5327d34acf..91af6cf8b9 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -3245,7 +3245,6 @@ Object { ], }, ], - "tooltipRatioMultiplier": 1, } `; diff --git a/src/test/unit/profile-tree.test.ts b/src/test/unit/profile-tree.test.ts index 7fa5b568e7..65a9e34d1a 100644 --- a/src/test/unit/profile-tree.test.ts +++ b/src/test/unit/profile-tree.test.ts @@ -11,10 +11,19 @@ import { computeCallNodeSelfAndSummary, computeCallTreeTimings, } from '../../profile-logic/call-tree'; -import { computeFlameGraphRows } from '../../profile-logic/flame-graph'; import { + computeFlameGraphRows, + createLowerWingFlameGraphTiming, +} from '../../profile-logic/flame-graph'; +import type { + FlameGraphTiming, + FlameGraphTimingRow, +} from '../../profile-logic/flame-graph'; +import { + computeLowerWingMaxDepthPlusOne, getCallNodeInfo, getInvertedCallNodeInfo, + getLowerWingCallNodeInfo, getOriginAnnotationForFunc, getOriginalPositionForFrame, filterRawThreadSamplesToRange, @@ -140,6 +149,243 @@ describe('unfiltered call tree', function () { }); }); + describe('lower wing flame graph', function () { + // The shared fixture profile (see `getProfile` above) has every sample + // rooted at A, so A has no callers in the inverted (lower wing) view. + // This used to render an empty flame graph; the regression test below + // exercises the fix that emits a single root cell with the full total. + function setup(selectedFuncIndex: number | null) { + const { derivedThreads, defaultCategory } = getProfile(); + const [thread] = derivedThreads; + const callNodeInfo = getCallNodeInfo( + thread.stackTable, + thread.frameTable, + defaultCategory + ); + const lowerWing = getLowerWingCallNodeInfo( + callNodeInfo, + defaultCategory, + thread.funcTable.length, + selectedFuncIndex + ); + const selfAndSummary = computeCallNodeSelfAndSummary( + thread.samples, + getSampleIndexToCallNodeIndex( + thread.samples.stack, + callNodeInfo.getStackIndexToNonInvertedCallNodeIndex() + ), + callNodeInfo.getCallNodeTable().length + ); + const timing = createLowerWingFlameGraphTiming( + lowerWing, + selfAndSummary, + callNodeInfo.getCallNodeTable(), + selectedFuncIndex, + thread.funcTable, + thread.stringTable + ); + return { thread, callNodeInfo, lowerWing, selfAndSummary, timing }; + } + + // Materialize every row of a lazy timing into a flat array for compact + // assertions. Equivalent to the old `flameGraphTiming.rows` field. + function materializeRows(timing: FlameGraphTiming): FlameGraphTimingRow[] { + const rows: FlameGraphTimingRow[] = []; + for (let depth = 0; depth < timing.rowCount; depth++) { + rows.push(timing.getRow(depth)); + } + return rows; + } + + it('renders a single full-width root cell when the selected function has no callers', function () { + const { timing } = setup(A); + + // Every one of the 3 samples passes through A at the bottom of the + // stack, so A's lower-wing row is a single full-width cell with full + // self time. + expect(timing.rowCount).toBe(1); + expect(timing.getRow(0)).toEqual({ + start: [0], + end: [1], + selfRelative: [1], + callNode: [A], + length: 1, + }); + }); + + it('builds a multi-row flame graph for a function with callers', function () { + // C appears in two samples and is called by B in both, so the lower + // wing tree has C as root with one caller chain (B -> A). + const { lowerWing, timing } = setup(C); + + // Three rows: [C], [B], [A]. Each cell occupies the full width since + // it's the only sibling at its depth. + expect(timing.rowCount).toBe(3); + const rows = materializeRows(timing); + // Cell values are InvertedCallNodeHandle values (root === funcIndex, + // non-roots come from the lower-wing handle scheme); translate via + // funcForNode for a clear func-based assertion. + expect( + rows.map((r) => r.callNode.map((h) => lowerWing.funcForNode(h))) + ).toEqual([[C], [B], [A]]); + expect(rows.map((r) => ({ start: r.start, end: r.end }))).toEqual([ + { start: [0], end: [1] }, + { start: [0], end: [1] }, + { start: [0], end: [1] }, + ]); + // Only the deepest inverted node (A, the topmost expanded caller) + // accumulates self; intermediate nodes' self is zero. + expect(rows.map((r) => r.selfRelative)).toEqual([[0], [0], [1]]); + }); + + it('extends the lower-wing tree lazily as rows are requested', function () { + // E sits at the deepest leaf of the fixture (A -> B -> C -> D -> E), + // so its lower-wing tree has rows E, D, C, B, A (max depth 4). + const { lowerWing, timing } = setup(E); + + // The cheap max-depth selector knows the row count up front, without + // building any of the tree. + expect(timing.rowCount).toBe(5); + + // Before any rows are fetched: only the root row of the lower-wing + // tree exists; no BFS work has happened. + expect(lowerWing.getLowerWingTableUpToDepth(-1).length).toBe(1); + + // Fetching row 0 extends the BFS just enough to process the root and + // emit its depth-1 child. The internal table is now length 2. + timing.getRow(0); + expect(lowerWing.getLowerWingTableUpToDepth(-1).length).toBe(2); + + // Fetching row 2 extends through depth 2, growing the table further. + timing.getRow(2); + expect(lowerWing.getLowerWingTableUpToDepth(-1).length).toBe(4); + + // Materializing every row matches an independently-built eager + // baseline (one row per depth, each cell full-width). + const rows = materializeRows(timing); + expect( + rows.map((r) => r.callNode.map((h) => lowerWing.funcForNode(h))) + ).toEqual([[E], [D], [C], [B], [A]]); + expect(rows.map((r) => ({ start: r.start, end: r.end }))).toEqual([ + { start: [0], end: [1] }, + { start: [0], end: [1] }, + { start: [0], end: [1] }, + { start: [0], end: [1] }, + { start: [0], end: [1] }, + ]); + // Only the deepest caller (A, the topmost expanded ancestor) carries + // self time. Self values stay correct as the tree is grown one row at + // a time because each `getRow(D)` extends the CNI to depth D, which + // emits depth D+1 children needed to compute self = total − Σchildren. + expect(rows.map((r) => r.selfRelative)).toEqual([ + [0], + [0], + [0], + [0], + [1], + ]); + }); + + it('builds the lower wing tree incrementally via getLowerWingTableUpToDepth', function () { + // E sits at the deepest leaf of the fixture (A -> B -> C -> D -> E), + // so its lower-wing tree has rows E, D, C, B, A (max depth 4). + const { derivedThreads, defaultCategory } = getProfile(); + const [thread] = derivedThreads; + const callNodeInfo = getCallNodeInfo( + thread.stackTable, + thread.frameTable, + defaultCategory + ); + + const lowerWing = getLowerWingCallNodeInfo( + callNodeInfo, + defaultCategory, + thread.funcTable.length, + E + ); + + // Before any extension: only the root row (placeholder children) exists. + // The cached snapshot is invalidated as the BFS grows the table, so each + // partial call returns a snapshot whose `length` reflects how far the + // tree has been expanded. + const tableAtRoot = lowerWing.getLowerWingTableUpToDepth(0); + // Processing depth 0 emits the single depth-1 child (D), so the table + // length is 2. + expect(tableAtRoot.length).toBe(2); + expect(tableAtRoot.depth).toEqual([0, 1]); + + // Extending by one more level reveals depth-2 children of D (only C). + const tableAtDepth1 = lowerWing.getLowerWingTableUpToDepth(1); + expect(tableAtDepth1.length).toBe(3); + expect(tableAtDepth1.depth).toEqual([0, 1, 2]); + + // Going past the tree's real depth caps at the actual max (4 + 1 = 5). + const tableFull = lowerWing.getLowerWingTableUpToDepth(99); + expect(tableFull.length).toBe(5); + expect(tableFull.depth).toEqual([0, 1, 2, 3, 4]); + + // The lazy build must match the eager build that getLowerWingTable + // returns (which is just getLowerWingTableUpToDepth(Infinity)). + const eager = getLowerWingCallNodeInfo( + callNodeInfo, + defaultCategory, + thread.funcTable.length, + E + ).getLowerWingTable(); + expect(tableFull.length).toBe(eager.length); + expect(tableFull.prefix).toEqual(eager.prefix); + expect(tableFull.func).toEqual(eager.func); + expect(tableFull.depth).toEqual(eager.depth); + expect(tableFull.suffixOrderIndexRangeStart).toEqual( + eager.suffixOrderIndexRangeStart + ); + expect(tableFull.suffixOrderIndexRangeEnd).toEqual( + eager.suffixOrderIndexRangeEnd + ); + }); + + it('computes max depth without building the lower wing tree, matching the eager build', function () { + const { derivedThreads, defaultCategory } = getProfile(); + const [thread] = derivedThreads; + const callNodeInfo = getCallNodeInfo( + thread.stackTable, + thread.frameTable, + defaultCategory + ); + const callNodeTable = callNodeInfo.getCallNodeTable(); + + // No selection → single empty root row. + expect(computeLowerWingMaxDepthPlusOne(callNodeTable, null)).toBe(1); + + // For every func in the fixture, the cheap helper should agree with the + // depth read off the fully-built lower-wing table. + const funcCount = thread.funcTable.length; + for (let f = 0; f < funcCount; f++) { + const lowerWing = getLowerWingCallNodeInfo( + callNodeInfo, + defaultCategory, + funcCount, + f + ); + const table = lowerWing.getLowerWingTable(); + const eagerMaxDepthPlusOne = table.depth[table.length - 1] + 1; + expect(computeLowerWingMaxDepthPlusOne(callNodeTable, f)).toBe( + eagerMaxDepthPlusOne + ); + } + + // Spot-check the absolute values for a few funcs in the fixture: + // A (root, no callers) -> 1 + // B (called by A) -> 2 + // C (called by B, A) -> 3 + // E (deepest leaf chain) -> 5 + expect(computeLowerWingMaxDepthPlusOne(callNodeTable, A)).toBe(1); + expect(computeLowerWingMaxDepthPlusOne(callNodeTable, B)).toBe(2); + expect(computeLowerWingMaxDepthPlusOne(callNodeTable, C)).toBe(3); + expect(computeLowerWingMaxDepthPlusOne(callNodeTable, E)).toBe(5); + }); + }); + /** * Explicitly test the structure of the unfiltered call tree. */ From 609e499dff8cf3457076429f23cff8e9c34a95c1 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:55:52 -0400 Subject: [PATCH 18/19] Add a DisclosureBox component. --- src/components/shared/DisclosureBox.css | 72 +++++++++++++++++++++++++ src/components/shared/DisclosureBox.tsx | 48 +++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 src/components/shared/DisclosureBox.css create mode 100644 src/components/shared/DisclosureBox.tsx diff --git a/src/components/shared/DisclosureBox.css b/src/components/shared/DisclosureBox.css new file mode 100644 index 0000000000..2b91918d85 --- /dev/null +++ b/src/components/shared/DisclosureBox.css @@ -0,0 +1,72 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +.disclosureBox { + display: flex; + min-height: 0; + flex-direction: column; +} + +.disclosureBox.open { + flex: 1; +} + +.disclosureBoxHeader { + display: flex; + flex-shrink: 0; + align-items: stretch; + border-top: 1px solid var(--base-border-color); + background: var(--panel-background-color); +} + +.disclosureBoxButton { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + padding: 2px 6px; + border: none; + background: transparent; + color: var(--panel-foreground-color); + cursor: pointer; + font-size: 11px; + font-weight: bold; + gap: 4px; + text-align: start; +} + +.disclosureBoxButton:hover { + background: var(--clickable-ghost-hover-background-color); +} + +.disclosureBoxButton:active { + background: var(--clickable-ghost-active-background-color); +} + +.disclosureBoxHeaderActions { + display: flex; + flex-shrink: 0; + align-items: center; + padding-right: 4px; + gap: 2px; +} + +.disclosureBoxArrow { + display: inline-block; + width: 0; + height: 0; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 6px solid currentcolor; +} + +.disclosureBox.open .disclosureBoxArrow { + transform: rotate(90deg); +} + +.disclosureBoxContents { + display: flex; + min-height: 0; + flex: 1; +} diff --git a/src/components/shared/DisclosureBox.tsx b/src/components/shared/DisclosureBox.tsx new file mode 100644 index 0000000000..22e5d0d610 --- /dev/null +++ b/src/components/shared/DisclosureBox.tsx @@ -0,0 +1,48 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import * as React from 'react'; +import { useCallback } from 'react'; + +import './DisclosureBox.css'; + +type Props = { + readonly label: string; + readonly isOpen: boolean; + readonly onToggle: (isOpen: boolean) => void; + readonly headerActions?: React.ReactNode; + readonly children: React.ReactNode; +}; + +export function DisclosureBox({ + label, + isOpen, + onToggle, + headerActions, + children, +}: Props) { + const handleToggle = useCallback(() => { + onToggle(!isOpen); + }, [isOpen, onToggle]); + + return ( +
+
+ + {headerActions ? ( +
{headerActions}
+ ) : null} +
+ {isOpen ?
{children}
: null} +
+ ); +} From 5059e16d49d2e66c22e10779198aa2183b1a5749 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:55:52 -0400 Subject: [PATCH 19/19] Add function list wings. --- src/actions/profile-view.ts | 114 ++++- src/app-logic/url-handling.ts | 145 ++++++ src/components/app/Details.tsx | 6 +- src/components/calltree/Butterfly.css | 55 ++ .../calltree/LowerWingFlameGraph.tsx | 262 ++++++++++ .../calltree/ProfileFunctionListView.tsx | 112 ++++- src/components/calltree/SelfWing.tsx | 333 ++++++++++++ .../calltree/UpperWingFlameGraph.tsx | 274 ++++++++++ src/components/calltree/WingTreeView.tsx | 426 ++++++++++++++++ src/components/calltree/WingViewToggle.css | 44 ++ src/components/calltree/WingViewToggle.tsx | 123 +++++ ...istContextMenu.tsx => WingContextMenu.tsx} | 372 ++++++++------ src/profile-logic/flame-graph.ts | 12 +- src/reducers/profile-view.ts | 302 +++++++---- src/reducers/url-state.ts | 62 +++ src/selectors/per-thread/stack-sample.ts | 473 +++++++++++++++++- src/selectors/right-clicked-call-node.tsx | 2 + src/selectors/url-state.ts | 21 + .../FunctionListContextMenu.test.tsx | 2 +- .../components/LowerWingContextMenu.test.tsx | 119 +++++ .../__snapshots__/profile-view.test.ts.snap | 47 +- src/test/store/profile-view.test.ts | 2 + src/types/actions.ts | 16 +- src/types/state.ts | 41 +- 24 files changed, 3016 insertions(+), 349 deletions(-) create mode 100644 src/components/calltree/Butterfly.css create mode 100644 src/components/calltree/LowerWingFlameGraph.tsx create mode 100644 src/components/calltree/SelfWing.tsx create mode 100644 src/components/calltree/UpperWingFlameGraph.tsx create mode 100644 src/components/calltree/WingTreeView.tsx create mode 100644 src/components/calltree/WingViewToggle.css create mode 100644 src/components/calltree/WingViewToggle.tsx rename src/components/shared/{FunctionListContextMenu.tsx => WingContextMenu.tsx} (53%) create mode 100644 src/test/components/LowerWingContextMenu.test.tsx diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 4f4f05f4a6..0bde320988 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -34,6 +34,7 @@ import { getHiddenLocalTracks, getInvertCallstack, getHash, + getUrlState, } from 'firefox-profiler/selectors/url-state'; import { assertExhaustiveCheck, @@ -75,12 +76,15 @@ import type { SelectionContext, BottomBoxInfo, IndexIntoFuncTable, + CallNodeArea, + WingName, } from 'firefox-profiler/types'; import { funcHasDirectRecursiveCall, funcHasRecursiveCall, } from '../profile-logic/transforms'; import { changeStoredProfileNameInDb } from 'firefox-profiler/app-logic/uploaded-profiles-db'; +import { replaceHistoryWithUrlState } from 'firefox-profiler/app-logic/url-handling'; import type { TabSlug } from '../app-logic/tabs-handling'; import type { CallNodeInfo } from '../profile-logic/call-node-info'; import type { SingleColumnSortState } from '../components/shared/TreeView'; @@ -122,7 +126,7 @@ export function changeSelectedCallNode( const isInverted = getInvertCallstack(getState()); dispatch({ type: 'CHANGE_SELECTED_CALL_NODE', - isInverted, + area: isInverted ? 'INVERTED_TREE' : 'NON_INVERTED_TREE', selectedCallNodePath, optionalExpandedToCallNodePath, threadsKey, @@ -131,19 +135,50 @@ export function changeSelectedCallNode( }; } +const WING_AREAS: Record = { + upper: 'UPPER_WING', + lower: 'LOWER_WING', +}; + +export function changeWingSelectedCallNode( + wing: WingName, + threadsKey: ThreadsKey, + selectedCallNodePath: CallNodePath, + context: SelectionContext = { source: 'auto' } +): Action { + return { + type: 'CHANGE_SELECTED_CALL_NODE', + area: WING_AREAS[wing], + selectedCallNodePath, + optionalExpandedToCallNodePath: [], + threadsKey, + context, + }; +} + /** * Select a function for a given thread in the function list. + * + * Replaces the current history entry rather than pushing a new one, so that + * holding e.g. the down arrow key in the function list doesn't get rate-limited + * by the browser and doesn't flood the back/forward history. */ export function changeSelectedFunctionIndex( threadsKey: ThreadsKey, selectedFunctionIndex: IndexIntoFuncTable | null, context: SelectionContext = { source: 'auto' } -): Action { - return { - type: 'CHANGE_SELECTED_FUNCTION', - selectedFunctionIndex, - threadsKey, - context, +): ThunkAction { + return (dispatch, getState) => { + dispatch({ + type: 'CHANGE_SELECTED_FUNCTION', + selectedFunctionIndex, + threadsKey, + context, + }); + // Update window.history synchronously instead of waiting for the + // UrlManager's componentDidUpdate, which is deferred by React's render + // scheduling and would otherwise pushState a new entry. + replaceHistoryWithUrlState(getUrlState(getState())); }; } @@ -155,11 +190,15 @@ export function changeSelectedFunctionIndex( export function changeRightClickedCallNode( threadsKey: ThreadsKey, callNodePath: CallNodePath | null -): Action { - return { - type: 'CHANGE_RIGHT_CLICKED_CALL_NODE', - threadsKey, - callNodePath, +): ThunkAction { + return (dispatch, getState) => { + const isInverted = getInvertCallstack(getState()); + dispatch({ + type: 'CHANGE_RIGHT_CLICKED_CALL_NODE', + threadsKey, + area: isInverted ? 'INVERTED_TREE' : 'NON_INVERTED_TREE', + callNodePath, + }); }; } @@ -174,6 +213,19 @@ export function changeRightClickedFunctionIndex( }; } +export function changeWingRightClickedCallNode( + wing: WingName, + threadsKey: ThreadsKey, + callNodePath: CallNodePath | null +): Action { + return { + type: 'CHANGE_RIGHT_CLICKED_CALL_NODE', + threadsKey, + area: WING_AREAS[wing], + callNodePath, + }; +} + /** * Given a threadIndex and a sampleIndex, select the call node which carries the * sample's self time. In the inverted tree, this will be a root node. @@ -1608,12 +1660,26 @@ export function changeExpandedCallNodes( const isInverted = getInvertCallstack(getState()); dispatch({ type: 'CHANGE_EXPANDED_CALL_NODES', - isInverted, + area: isInverted ? 'INVERTED_TREE' : 'NON_INVERTED_TREE', threadsKey, expandedCallNodePaths, }); }; } + +export function changeWingExpandedCallNodes( + wing: WingName, + threadsKey: ThreadsKey, + expandedCallNodePaths: Array +): Action { + return { + type: 'CHANGE_EXPANDED_CALL_NODES', + area: WING_AREAS[wing], + threadsKey, + expandedCallNodePaths, + }; +} + export function changeSelectedMarker( threadsKey: ThreadsKey, selectedMarker: MarkerIndex | null, @@ -1692,6 +1758,28 @@ export function changeFunctionListSort(sort: SingleColumnSortState[]): Action { }; } +export function changeFunctionListSectionOpen( + section: 'descendants' | 'ancestors' | 'self', + isOpen: boolean +): Action { + return { + type: 'CHANGE_FUNCTION_LIST_SECTION_OPEN', + section, + isOpen, + }; +} + +export function changeWingView( + wing: 'upper' | 'lower' | 'self', + view: 'flame-graph' | 'call-tree' +): Action { + return { + type: 'CHANGE_WING_VIEW', + wing, + view, + }; +} + export function changeNetworkSearchString(searchString: string): Action { return { type: 'CHANGE_NETWORK_SEARCH_STRING', diff --git a/src/app-logic/url-handling.ts b/src/app-logic/url-handling.ts index 01adaa14a7..862fcffe4d 100644 --- a/src/app-logic/url-handling.ts +++ b/src/app-logic/url-handling.ts @@ -42,6 +42,9 @@ import type { IndexIntoFrameTable, MarkerIndex, SelectedMarkersPerThread, + SelectedFunctionsPerThread, + FunctionListSectionsOpenState, + WingViewsState, } from 'firefox-profiler/types'; import { decodeUintArrayFromUrlComponent, @@ -120,6 +123,19 @@ export function getIsHistoryReplaceState(): boolean { return _isReplaceState; } +/** + * Synchronously replace the current history entry to match the given UrlState. + * + * Use this from action thunks for high-frequency actions (e.g. arrow-key + * navigation) where deferring the URL update to UrlManager.componentDidUpdate + * would result in unwanted pushState calls and history-flooding. By updating + * window.history synchronously here, the URL already matches the new state by + * the time UrlManager's componentDidUpdate runs, so it becomes a no-op. + */ +export function replaceHistoryWithUrlState(urlState: UrlState): void { + window.history.replaceState(urlState, document.title, urlFromState(urlState)); +} + function getPathParts(urlState: UrlState): string[] { const { dataSource } = urlState; switch (dataSource) { @@ -187,6 +203,9 @@ type CallTreeQuery = BaseQuery & { hideIdleSamples: null | undefined; ctSummary: string; functionListSort?: string; // "total-desc~self-asc" — primary first + funcListSections?: string; // "descendants,self" — comma-separated open sections + wingViews?: string; // "upper:call-tree,lower:call-tree" — non-default wing views + selectedFunc?: number; // Selected function index for the current thread, e.g. 42 }; type MarkersQuery = BaseQuery & { @@ -235,6 +254,9 @@ type Query = BaseQuery & { // Function list specific functionListSort?: string; + funcListSections?: string; + wingViews?: string; + selectedFunc?: number; // Network specific networkSearch?: string; @@ -394,6 +416,20 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { query.functionListSort = convertFunctionListSortToString( urlState.profileSpecific.functionListSort ); + query.funcListSections = convertFunctionListSectionsOpenToString( + urlState.profileSpecific.functionListSectionsOpen + ); + query.wingViews = convertWingViewsToString( + urlState.profileSpecific.wingViews + ); + query.selectedFunc = + selectedThreadsKey !== null && + urlState.profileSpecific.selectedFunctions[selectedThreadsKey] !== + null && + urlState.profileSpecific.selectedFunctions[selectedThreadsKey] !== + undefined + ? urlState.profileSpecific.selectedFunctions[selectedThreadsKey] + : undefined; } break; } @@ -557,6 +593,19 @@ export function stateFromLocation( } } + // Parse the selected function for the current thread + const selectedFunctions: SelectedFunctionsPerThread = {}; + if ( + selectedThreadsKey !== null && + query.selectedFunc !== undefined && + query.selectedFunc !== null + ) { + const funcIndex = Number(query.selectedFunc); + if (Number.isInteger(funcIndex) && funcIndex >= 0) { + selectedFunctions[selectedThreadsKey] = funcIndex; + } + } + // tabID is used for the tab selector that we have in our full view. let tabID = null; if (query.tabID && Number.isInteger(Number(query.tabID))) { @@ -648,10 +697,15 @@ export function stateFromLocation( ? query.hiddenThreads.split('-').map((index) => Number(index)) : null, selectedMarkers, + selectedFunctions, markerTableSort: convertMarkerTableSortFromString(query.markerSort), functionListSort: convertFunctionListSortFromString( query.functionListSort ), + functionListSectionsOpen: convertFunctionListSectionsOpenFromString( + query.funcListSections + ), + wingViews: convertWingViewsFromString(query.wingViews), }, }; } @@ -751,6 +805,97 @@ function convertFunctionListSortFromString( return parsed.reverse(); } +// FunctionList section disclosure-box open/closed state. The URL stores a +// comma-separated list of the open sections; the param is omitted when the +// state matches the default (only "descendants" open). The value "none" is +// used as a sentinel for the all-closed case so the param is non-empty. +const FUNCTION_LIST_SECTION_NAMES: ReadonlyArray< + keyof FunctionListSectionsOpenState +> = ['descendants', 'ancestors', 'self']; +const FUNCTION_LIST_SECTIONS_OPEN_DEFAULT: FunctionListSectionsOpenState = { + descendants: true, + ancestors: false, + self: false, +}; + +function convertFunctionListSectionsOpenToString( + state: FunctionListSectionsOpenState +): string | undefined { + const matchesDefault = FUNCTION_LIST_SECTION_NAMES.every( + (name) => state[name] === FUNCTION_LIST_SECTIONS_OPEN_DEFAULT[name] + ); + if (matchesDefault) { + return undefined; + } + const open = FUNCTION_LIST_SECTION_NAMES.filter((name) => state[name]); + return open.length === 0 ? 'none' : open.join(','); +} + +function convertFunctionListSectionsOpenFromString( + raw: string | null | void +): FunctionListSectionsOpenState { + if (raw === undefined || raw === null) { + return { ...FUNCTION_LIST_SECTIONS_OPEN_DEFAULT }; + } + const result: FunctionListSectionsOpenState = { + descendants: false, + ancestors: false, + self: false, + }; + if (raw === 'none' || raw === '') { + return result; + } + for (const part of raw.split(',')) { + if (part === 'descendants' || part === 'ancestors' || part === 'self') { + result[part] = true; + } + } + return result; +} + +// WingView visualization choice for the Descendants ("upper"), Ancestors +// ("lower"), and Self wings of the FunctionList tab. The URL stores a +// comma-separated list of "wing:view" pairs for wings whose view differs from +// the default (flame-graph), e.g. "upper:call-tree". The param is omitted +// when all wings use the default. +const WING_VIEWS_DEFAULT: WingViewsState = { + upper: 'flame-graph', + lower: 'flame-graph', + self: 'flame-graph', +}; +const WING_VIEW_NAMES: ReadonlyArray = [ + 'upper', + 'lower', + 'self', +]; + +function convertWingViewsToString(state: WingViewsState): string | undefined { + const parts: string[] = []; + for (const wing of WING_VIEW_NAMES) { + if (state[wing] !== WING_VIEWS_DEFAULT[wing]) { + parts.push(`${wing}:${state[wing]}`); + } + } + return parts.length === 0 ? undefined : parts.join(','); +} + +function convertWingViewsFromString(raw: string | null | void): WingViewsState { + const result: WingViewsState = { ...WING_VIEWS_DEFAULT }; + if (raw === undefined || raw === null || raw === '') { + return result; + } + for (const part of raw.split(',')) { + const [wing, view] = part.split(':'); + if ( + (wing === 'upper' || wing === 'lower' || wing === 'self') && + (view === 'flame-graph' || view === 'call-tree') + ) { + result[wing] = view; + } + } + return result; +} + function convertGlobalTrackOrderFromString( rawString: string | null | void ): TrackIndex[] { diff --git a/src/components/app/Details.tsx b/src/components/app/Details.tsx index 85d92cc99a..0ac75ddc70 100644 --- a/src/components/app/Details.tsx +++ b/src/components/app/Details.tsx @@ -27,7 +27,10 @@ import { getSelectedTab } from 'firefox-profiler/selectors/url-state'; import { getIsSidebarOpen } from 'firefox-profiler/selectors/app'; import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; import { CallNodeContextMenu } from 'firefox-profiler/components/shared/CallNodeContextMenu'; -import { FunctionListContextMenu } from 'firefox-profiler/components/shared/FunctionListContextMenu'; +import { + FunctionListContextMenu, + LowerWingContextMenu, +} from 'firefox-profiler/components/shared/WingContextMenu'; import { MaybeMarkerContextMenu } from 'firefox-profiler/components/shared/MarkerContextMenu'; import { toValidTabSlug } from 'firefox-profiler/utils/types'; @@ -137,6 +140,7 @@ class ProfileViewerImpl extends PureComponent { +
); diff --git a/src/components/calltree/Butterfly.css b/src/components/calltree/Butterfly.css new file mode 100644 index 0000000000..62f236e0cb --- /dev/null +++ b/src/components/calltree/Butterfly.css @@ -0,0 +1,55 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +.butterflyWrapper { + position: relative; + display: flex; + min-height: 0; + flex: 1; +} + +.butterflyWings > .resizableWithSplitterInner { + display: flex; + flex-flow: column nowrap; +} + +/* Provide 3px extra grabbable surface on each side of the splitter */ +.butterflyWrapper .resizableWithSplitterSplitter { + position: relative; /* containing block for absolute ::before */ + border: none; + background-color: var(--base-border-color) !important; +} + +.butterflyWrapper .resizableWithSplitterSplitter::before { + position: absolute; + z-index: var(--z-bottom-box); + display: block; + content: ''; +} + +.butterflyWrapper .resizableWithSplitterSplitter.resizesWidth { + width: 1px; +} + +.butterflyWrapper .resizableWithSplitterSplitter.resizesWidth::before { + inset: 0 -3px; +} + +.butterflyWrapper .resizableWithSplitterSplitter.resizesHeight { + height: 1px; + margin-bottom: -1px; +} + +.butterflyWrapper .resizableWithSplitterSplitter.resizesHeight::before { + inset: -3px 0; +} + +.functionListTreeWrapper { + display: flex; + flex-flow: column nowrap; +} + +.functionListTreeWrapper .treeRowToggleButton { + display: none; +} diff --git a/src/components/calltree/LowerWingFlameGraph.tsx b/src/components/calltree/LowerWingFlameGraph.tsx new file mode 100644 index 0000000000..fc27795c8d --- /dev/null +++ b/src/components/calltree/LowerWingFlameGraph.tsx @@ -0,0 +1,262 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import * as React from 'react'; + +import { explicitConnectWithForwardRef } from 'firefox-profiler/utils/connect'; +import { FlameGraph } from 'firefox-profiler/components/flame-graph/FlameGraph'; + +import { + getCategories, + getCommittedRange, + getPreviewSelection, + getScrollToSelectionGeneration, + getProfileInterval, + getInnerWindowIDToPageMap, + getProfileUsesMultipleStackTypes, +} from 'firefox-profiler/selectors/profile'; +import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { getSelectedThreadsKey } from 'firefox-profiler/selectors/url-state'; +import { + changeWingSelectedCallNode, + changeWingRightClickedCallNode, + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, +} from 'firefox-profiler/actions/profile-view'; + +import type { + Thread, + CategoryList, + Milliseconds, + StartEndRange, + WeightType, + SamplesLikeTable, + PreviewSelection, + CallTreeSummaryStrategy, + IndexIntoCallNodeTable, + ThreadsKey, + InnerWindowID, + Page, + SampleCategoriesAndSubcategories, + SelectionContext, + CallNodePath, +} from 'firefox-profiler/types'; + +import type { FlameGraphTiming } from 'firefox-profiler/profile-logic/flame-graph'; +import type { CallNodeInfo } from 'firefox-profiler/profile-logic/call-node-info'; + +import type { + CallTree, + CallTreeTimings, +} from 'firefox-profiler/profile-logic/call-tree'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +type StateProps = { + readonly thread: Thread; + readonly weightType: WeightType; + readonly innerWindowIDToPageMap: Map | null; + readonly maxStackDepthPlusOne: number; + readonly timeRange: StartEndRange; + readonly previewSelection: PreviewSelection | null; + readonly flameGraphTiming: FlameGraphTiming; + readonly callTree: CallTree; + readonly callNodeInfo: CallNodeInfo; + readonly threadsKey: ThreadsKey; + readonly selectedCallNodeIndex: IndexIntoCallNodeTable | null; + readonly rightClickedCallNodeIndex: IndexIntoCallNodeTable | null; + readonly scrollToSelectionGeneration: number; + readonly categories: CategoryList; + readonly interval: Milliseconds; + readonly callTreeSummaryStrategy: CallTreeSummaryStrategy; + readonly ctssSamples: SamplesLikeTable; + readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; + readonly tracedTiming: CallTreeTimings | null; + readonly displayStackType: boolean; +}; + +type DispatchProps = { + readonly changeSelectedCallNode: ( + threadsKey: ThreadsKey, + path: CallNodePath, + context?: SelectionContext + ) => any; + readonly changeRightClickedCallNode: ( + threadsKey: ThreadsKey, + path: CallNodePath | null + ) => any; + readonly handleCallNodeTransformShortcut: typeof handleCallNodeTransformShortcut; + readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +export interface LowerWingFlameGraphHandle { + focus(): void; +} + +class LowerWingFlameGraphImpl + extends React.PureComponent + implements LowerWingFlameGraphHandle +{ + _flameGraph: React.RefObject = React.createRef(); + + // eslint-disable-next-line react/no-unused-class-component-methods -- called via LowerWingFlameGraphHandle ref from LowerWingImpl + focus() { + this._flameGraph.current?.focus(); + } + + _onSelectedCallNodeChange = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + const { callNodeInfo, threadsKey, changeSelectedCallNode } = this.props; + const context: SelectionContext = { source: 'pointer' }; + changeSelectedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(callNodeIndex), + context + ); + }; + + _onRightClickedCallNodeChange = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + const { callNodeInfo, threadsKey, changeRightClickedCallNode } = this.props; + changeRightClickedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(callNodeIndex) + ); + }; + + _onCallNodeEnterOrDoubleClick = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + if (callNodeIndex === null) { + return; + } + const { callTree, updateBottomBoxContentsAndMaybeOpen } = this.props; + const bottomBoxInfo = callTree.getBottomBoxInfoForCallNode(callNodeIndex); + updateBottomBoxContentsAndMaybeOpen('function-list', bottomBoxInfo); + }; + + _onKeyboardTransformShortcut = ( + event: React.KeyboardEvent, + nodeIndex: IndexIntoCallNodeTable + ) => { + const { threadsKey, callNodeInfo, handleCallNodeTransformShortcut } = + this.props; + handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); + }; + + override render() { + const { + thread, + threadsKey, + maxStackDepthPlusOne, + flameGraphTiming, + callTree, + callNodeInfo, + timeRange, + previewSelection, + rightClickedCallNodeIndex, + selectedCallNodeIndex, + scrollToSelectionGeneration, + callTreeSummaryStrategy, + categories, + interval, + innerWindowIDToPageMap, + weightType, + ctssSamples, + ctssSampleCategoriesAndSubcategories, + tracedTiming, + displayStackType, + } = this.props; + + return ( + + ); + } +} + +export const LowerWingFlameGraph = explicitConnectWithForwardRef< + {}, + StateProps, + DispatchProps, + LowerWingFlameGraphHandle +>({ + mapStateToProps: (state) => ({ + thread: selectedThreadSelectors.getPreviewFilteredThread(state), + weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), + maxStackDepthPlusOne: + selectedThreadSelectors.getLowerWingFlameGraphMaxDepthPlusOne(state), + flameGraphTiming: + selectedThreadSelectors.getLowerWingFlameGraphTiming(state), + callTree: selectedThreadSelectors.getLowerWingCallTree(state), + timeRange: getCommittedRange(state), + previewSelection: getPreviewSelection(state), + callNodeInfo: selectedThreadSelectors.getLowerWingCallNodeInfo(state), + categories: getCategories(state), + threadsKey: getSelectedThreadsKey(state), + selectedCallNodeIndex: + selectedThreadSelectors.getLowerWingSelectedCallNodeIndex(state), + rightClickedCallNodeIndex: + selectedThreadSelectors.getLowerWingRightClickedCallNodeIndex(state), + scrollToSelectionGeneration: getScrollToSelectionGeneration(state), + interval: getProfileInterval(state), + callTreeSummaryStrategy: + selectedThreadSelectors.getCallTreeSummaryStrategy(state), + innerWindowIDToPageMap: getInnerWindowIDToPageMap(state), + ctssSamples: selectedThreadSelectors.getPreviewFilteredCtssSamples(state), + ctssSampleCategoriesAndSubcategories: + selectedThreadSelectors.getPreviewFilteredCtssSampleCategoriesAndSubcategories( + state + ), + tracedTiming: selectedThreadSelectors.getTracedTiming(state), + displayStackType: getProfileUsesMultipleStackTypes(state), + }), + mapDispatchToProps: { + changeSelectedCallNode: ( + threadsKey: ThreadsKey, + path: CallNodePath, + context?: SelectionContext + ) => changeWingSelectedCallNode('lower', threadsKey, path, context), + changeRightClickedCallNode: ( + threadsKey: ThreadsKey, + path: CallNodePath | null + ) => changeWingRightClickedCallNode('lower', threadsKey, path), + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, + }, + component: LowerWingFlameGraphImpl, +}); diff --git a/src/components/calltree/ProfileFunctionListView.tsx b/src/components/calltree/ProfileFunctionListView.tsx index 7695a6c4d2..a3c2f0900b 100644 --- a/src/components/calltree/ProfileFunctionListView.tsx +++ b/src/components/calltree/ProfileFunctionListView.tsx @@ -2,19 +2,107 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import * as React from 'react'; + +import explicitConnect from 'firefox-profiler/utils/connect'; import { FunctionList } from './FunctionList'; +import { SelfWing } from './SelfWing'; +import { UpperWing, LowerWing } from './WingTreeView'; +import { DisclosureBox } from 'firefox-profiler/components/shared/DisclosureBox'; +import { WingViewToggle } from './WingViewToggle'; import { StackSettings } from 'firefox-profiler/components/shared/StackSettings'; import { TransformNavigator } from 'firefox-profiler/components/shared/TransformNavigator'; +import { ResizableWithSplitter } from '../shared/ResizableWithSplitter'; +import { getFunctionListSectionsOpen } from 'firefox-profiler/selectors/url-state'; +import { changeFunctionListSectionOpen } from 'firefox-profiler/actions/profile-view'; + +import type { FunctionListSectionsOpenState } from 'firefox-profiler/types'; +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +import './Butterfly.css'; + +type StateProps = { + readonly sectionsOpen: FunctionListSectionsOpenState; +}; + +type DispatchProps = { + readonly changeFunctionListSectionOpen: typeof changeFunctionListSectionOpen; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +class ProfileFunctionListViewImpl extends React.PureComponent { + _onDescendantsToggle = (isOpen: boolean) => { + this.props.changeFunctionListSectionOpen('descendants', isOpen); + }; + _onAncestorsToggle = (isOpen: boolean) => { + this.props.changeFunctionListSectionOpen('ancestors', isOpen); + }; + _onSelfToggle = (isOpen: boolean) => { + this.props.changeFunctionListSectionOpen('self', isOpen); + }; + + override render() { + const { sectionsOpen } = this.props; + return ( +
+ + +
+ + + } + > + + + } + > + + + } + > + + + +
+
+ ); + } +} -export const ProfileFunctionListView = () => ( -
- - - -
-); +export const ProfileFunctionListView = explicitConnect< + {}, + StateProps, + DispatchProps +>({ + mapStateToProps: (state) => ({ + sectionsOpen: getFunctionListSectionsOpen(state), + }), + mapDispatchToProps: { + changeFunctionListSectionOpen, + }, + component: ProfileFunctionListViewImpl, +}); diff --git a/src/components/calltree/SelfWing.tsx b/src/components/calltree/SelfWing.tsx new file mode 100644 index 0000000000..07de24874f --- /dev/null +++ b/src/components/calltree/SelfWing.tsx @@ -0,0 +1,333 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @flow + +import * as React from 'react'; + +import explicitConnect from 'firefox-profiler/utils/connect'; +import { FlameGraph } from 'firefox-profiler/components/flame-graph/FlameGraph'; +import { TreeView } from 'firefox-profiler/components/shared/TreeView'; +import { CallTreeEmptyReasons } from './CallTreeEmptyReasons'; +import { nameColumn, libColumn, treeColumnsForWeightType } from './columns'; + +import { + getCategories, + getCommittedRange, + getPreviewSelection, + getScrollToSelectionGeneration, + getProfileInterval, + getInnerWindowIDToPageMap, + getProfileUsesMultipleStackTypes, + getCurrentTableViewOptions, + getPreviewSelectionIsBeingModified, +} from 'firefox-profiler/selectors/profile'; +import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { + getSelectedThreadsKey, + getSearchStringsAsRegExp, + getSelfWingView, +} from 'firefox-profiler/selectors/url-state'; +import { + updateBottomBoxContentsAndMaybeOpen, + changeRightClickedFunctionIndex, + changeTableViewOptions, +} from 'firefox-profiler/actions/profile-view'; + +import type { + Thread, + CategoryList, + Milliseconds, + StartEndRange, + WeightType, + SamplesLikeTable, + PreviewSelection, + CallTreeSummaryStrategy, + IndexIntoCallNodeTable, + ThreadsKey, + InnerWindowID, + Page, + SampleCategoriesAndSubcategories, + CallNodeDisplayData, + TableViewOptions, + WingViewType, +} from 'firefox-profiler/types'; + +import type { FlameGraphTiming } from 'firefox-profiler/profile-logic/flame-graph'; +import type { CallNodeInfo } from 'firefox-profiler/profile-logic/call-node-info'; +import type { CallTree } from 'firefox-profiler/profile-logic/call-tree'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +type StateProps = { + readonly thread: Thread; + readonly weightType: WeightType; + readonly innerWindowIDToPageMap: Map | null; + readonly maxStackDepthPlusOne: number; + readonly timeRange: StartEndRange; + readonly previewSelection: PreviewSelection | null; + readonly flameGraphTiming: FlameGraphTiming; + readonly callTree: CallTree; + readonly callNodeInfo: CallNodeInfo; + readonly threadsKey: ThreadsKey; + readonly scrollToSelectionGeneration: number; + readonly categories: CategoryList; + readonly interval: Milliseconds; + readonly callTreeSummaryStrategy: CallTreeSummaryStrategy; + readonly ctssSamples: SamplesLikeTable; + readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; + readonly displayStackType: boolean; + readonly searchStringsRegExp: RegExp | null; + readonly disableOverscan: boolean; + readonly tableViewOptions: TableViewOptions; + readonly view: WingViewType; +}; + +type DispatchProps = { + readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; + readonly changeRightClickedFunctionIndex: typeof changeRightClickedFunctionIndex; + readonly onTableViewOptionsChange: (options: TableViewOptions) => any; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +type LocalState = { + selectedCallNodeIndex: IndexIntoCallNodeTable | null; + rightClickedCallNodeIndex: IndexIntoCallNodeTable | null; + expandedCallNodeIndexes: Array; +}; + +class SelfWingImpl extends React.PureComponent { + override state: LocalState = { + selectedCallNodeIndex: null, + rightClickedCallNodeIndex: null, + expandedCallNodeIndexes: [], + }; + + _treeView: TreeView | null = null; + _takeTreeViewRef = (treeView: TreeView | null) => { + this._treeView = treeView; + }; + + override componentDidUpdate(prevProps: Props, _prevState: LocalState) { + // Reset local selection when the call node info changes (e.g. different + // function selected) since old call node indices are no longer valid. + if ( + prevProps.callNodeInfo !== this.props.callNodeInfo || + prevProps.threadsKey !== this.props.threadsKey + ) { + this.setState({ + selectedCallNodeIndex: null, + rightClickedCallNodeIndex: null, + expandedCallNodeIndexes: [], + }); + } + } + + _onSelectedCallNodeChange = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + this.setState({ selectedCallNodeIndex: callNodeIndex }); + }; + + _onRightClickedCallNodeChange = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + this.setState({ rightClickedCallNodeIndex: callNodeIndex }); + const { callNodeInfo, threadsKey, changeRightClickedFunctionIndex } = + this.props; + const funcIndex = + callNodeIndex !== null ? callNodeInfo.funcForNode(callNodeIndex) : null; + changeRightClickedFunctionIndex(threadsKey, funcIndex); + }; + + _onTreeViewSelectionChange = ( + callNodeIndex: IndexIntoCallNodeTable, + context: { source: 'keyboard' | 'pointer' } + ) => { + this.setState({ selectedCallNodeIndex: callNodeIndex }, () => { + // Selection in this wing is local state, so the Redux-driven + // scrollToSelectionGeneration mechanism used by the other wings does not + // apply. Scroll directly when keyboard navigation moves the selection. + if (context.source === 'keyboard' && this._treeView) { + this._treeView.scrollSelectionIntoView(); + } + }); + }; + + _onTreeViewRightClickSelection = (callNodeIndex: IndexIntoCallNodeTable) => { + this._onRightClickedCallNodeChange(callNodeIndex); + }; + + _onTreeViewExpandedNodesChange = ( + expandedCallNodeIndexes: Array + ) => { + this.setState({ expandedCallNodeIndexes }); + }; + + _onCallNodeEnterOrDoubleClick = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + if (callNodeIndex === null) { + return; + } + const { callTree, updateBottomBoxContentsAndMaybeOpen } = this.props; + const bottomBoxInfo = callTree.getBottomBoxInfoForCallNode(callNodeIndex); + updateBottomBoxContentsAndMaybeOpen('function-list', bottomBoxInfo); + }; + + // Transforms are disabled in the SelfWing because it operates on an ephemeral + // thread that is not part of the Redux transform stack. + _onKeyboardTransformShortcut = ( + _event: React.KeyboardEvent, + _nodeIndex: IndexIntoCallNodeTable + ) => {}; + + _renderFlameGraph() { + const { + thread, + threadsKey, + maxStackDepthPlusOne, + flameGraphTiming, + callTree, + callNodeInfo, + timeRange, + previewSelection, + scrollToSelectionGeneration, + callTreeSummaryStrategy, + categories, + interval, + innerWindowIDToPageMap, + weightType, + ctssSamples, + ctssSampleCategoriesAndSubcategories, + displayStackType, + } = this.props; + + const { selectedCallNodeIndex, rightClickedCallNodeIndex } = this.state; + + return ( + + ); + } + + _renderCallTree() { + const { + callTree, + searchStringsRegExp, + disableOverscan, + maxStackDepthPlusOne, + weightType, + tableViewOptions, + onTableViewOptionsChange, + } = this.props; + const { + selectedCallNodeIndex, + rightClickedCallNodeIndex, + expandedCallNodeIndexes, + } = this.state; + if (callTree.getRoots().length === 0) { + return ; + } + return ( + + ); + } + + override render() { + if (this.props.view === 'call-tree') { + return this._renderCallTree(); + } + return this._renderFlameGraph(); + } +} + +export const SelfWing = explicitConnect<{}, StateProps, DispatchProps>({ + mapStateToProps: (state) => ({ + thread: selectedThreadSelectors.getSelfWingThread(state), + weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), + maxStackDepthPlusOne: + selectedThreadSelectors.getSelfWingCallNodeMaxDepthPlusOne(state), + flameGraphTiming: + selectedThreadSelectors.getSelfWingFlameGraphTiming(state), + callTree: selectedThreadSelectors.getSelfWingCallTree(state), + timeRange: getCommittedRange(state), + previewSelection: getPreviewSelection(state), + callNodeInfo: selectedThreadSelectors.getSelfWingCallNodeInfo(state), + categories: getCategories(state), + threadsKey: getSelectedThreadsKey(state), + scrollToSelectionGeneration: getScrollToSelectionGeneration(state), + interval: getProfileInterval(state), + callTreeSummaryStrategy: + selectedThreadSelectors.getCallTreeSummaryStrategy(state), + innerWindowIDToPageMap: getInnerWindowIDToPageMap(state), + ctssSamples: selectedThreadSelectors.getSelfWingCtssSamples(state), + ctssSampleCategoriesAndSubcategories: + selectedThreadSelectors.getSelfWingCtssSampleCategoriesAndSubcategories( + state + ), + displayStackType: getProfileUsesMultipleStackTypes(state), + searchStringsRegExp: getSearchStringsAsRegExp(state), + disableOverscan: getPreviewSelectionIsBeingModified(state), + tableViewOptions: getCurrentTableViewOptions(state), + view: getSelfWingView(state), + }), + mapDispatchToProps: { + updateBottomBoxContentsAndMaybeOpen, + changeRightClickedFunctionIndex, + onTableViewOptionsChange: (options: TableViewOptions) => + changeTableViewOptions('calltree', options), + }, + component: SelfWingImpl, +}); diff --git a/src/components/calltree/UpperWingFlameGraph.tsx b/src/components/calltree/UpperWingFlameGraph.tsx new file mode 100644 index 0000000000..01c5f3e8b5 --- /dev/null +++ b/src/components/calltree/UpperWingFlameGraph.tsx @@ -0,0 +1,274 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @flow + +import * as React from 'react'; + +import { explicitConnectWithForwardRef } from 'firefox-profiler/utils/connect'; +import { FlameGraph } from 'firefox-profiler/components/flame-graph/FlameGraph'; + +import { + getCategories, + getCommittedRange, + getPreviewSelection, + getScrollToSelectionGeneration, + getProfileInterval, + getInnerWindowIDToPageMap, + getProfileUsesMultipleStackTypes, +} from 'firefox-profiler/selectors/profile'; +import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { getSelectedThreadsKey } from 'firefox-profiler/selectors/url-state'; +import { + changeWingSelectedCallNode, + changeWingRightClickedCallNode, + changeRightClickedFunctionIndex, + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, +} from 'firefox-profiler/actions/profile-view'; + +import type { + Thread, + CategoryList, + Milliseconds, + StartEndRange, + WeightType, + SamplesLikeTable, + PreviewSelection, + CallTreeSummaryStrategy, + IndexIntoCallNodeTable, + ThreadsKey, + InnerWindowID, + Page, + SampleCategoriesAndSubcategories, + SelectionContext, + CallNodePath, +} from 'firefox-profiler/types'; + +import type { FlameGraphTiming } from 'firefox-profiler/profile-logic/flame-graph'; +import type { CallNodeInfo } from 'firefox-profiler/profile-logic/call-node-info'; + +import type { + CallTree, + CallTreeTimings, +} from 'firefox-profiler/profile-logic/call-tree'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +type StateProps = { + readonly thread: Thread; + readonly weightType: WeightType; + readonly innerWindowIDToPageMap: Map | null; + readonly maxStackDepthPlusOne: number; + readonly timeRange: StartEndRange; + readonly previewSelection: PreviewSelection | null; + readonly flameGraphTiming: FlameGraphTiming; + readonly callTree: CallTree; + readonly callNodeInfo: CallNodeInfo; + readonly threadsKey: ThreadsKey; + readonly selectedCallNodeIndex: IndexIntoCallNodeTable | null; + readonly rightClickedCallNodeIndex: IndexIntoCallNodeTable | null; + readonly scrollToSelectionGeneration: number; + readonly categories: CategoryList; + readonly interval: Milliseconds; + readonly callTreeSummaryStrategy: CallTreeSummaryStrategy; + readonly ctssSamples: SamplesLikeTable; + readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; + readonly tracedTiming: CallTreeTimings | null; + readonly displayStackType: boolean; +}; + +type DispatchProps = { + readonly changeSelectedCallNode: ( + threadsKey: ThreadsKey, + path: CallNodePath, + context?: SelectionContext + ) => any; + readonly changeRightClickedCallNode: ( + threadsKey: ThreadsKey, + path: CallNodePath | null + ) => any; + readonly changeRightClickedFunctionIndex: typeof changeRightClickedFunctionIndex; + readonly handleCallNodeTransformShortcut: typeof handleCallNodeTransformShortcut; + readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +export interface UpperWingFlameGraphHandle { + focus(): void; +} + +class UpperWingFlameGraphImpl + extends React.PureComponent + implements UpperWingFlameGraphHandle +{ + _flameGraph: React.RefObject = React.createRef(); + + // eslint-disable-next-line react/no-unused-class-component-methods -- called via UpperWingFlameGraphHandle ref from UpperWingImpl + focus() { + this._flameGraph.current?.focus(); + } + + _onSelectedCallNodeChange = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + const { callNodeInfo, threadsKey, changeSelectedCallNode } = this.props; + const context: SelectionContext = { source: 'pointer' }; + changeSelectedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(callNodeIndex), + context + ); + }; + + _onRightClickedCallNodeChange = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + const { + callNodeInfo, + threadsKey, + changeRightClickedCallNode, + changeRightClickedFunctionIndex, + } = this.props; + changeRightClickedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(callNodeIndex) + ); + const funcIndex = + callNodeIndex !== null ? callNodeInfo.funcForNode(callNodeIndex) : null; + changeRightClickedFunctionIndex(threadsKey, funcIndex); + }; + + _onCallNodeEnterOrDoubleClick = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + if (callNodeIndex === null) { + return; + } + const { callTree, updateBottomBoxContentsAndMaybeOpen } = this.props; + const bottomBoxInfo = callTree.getBottomBoxInfoForCallNode(callNodeIndex); + updateBottomBoxContentsAndMaybeOpen('function-list', bottomBoxInfo); + }; + + _onKeyboardTransformShortcut = ( + event: React.KeyboardEvent, + nodeIndex: IndexIntoCallNodeTable + ) => { + const { threadsKey, callNodeInfo, handleCallNodeTransformShortcut } = + this.props; + handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); + }; + + override render() { + const { + thread, + threadsKey, + maxStackDepthPlusOne, + flameGraphTiming, + callTree, + callNodeInfo, + timeRange, + previewSelection, + rightClickedCallNodeIndex, + selectedCallNodeIndex, + scrollToSelectionGeneration, + callTreeSummaryStrategy, + categories, + interval, + innerWindowIDToPageMap, + weightType, + ctssSamples, + ctssSampleCategoriesAndSubcategories, + tracedTiming, + displayStackType, + } = this.props; + + return ( + + ); + } +} + +export const UpperWingFlameGraph = explicitConnectWithForwardRef< + {}, + StateProps, + DispatchProps, + UpperWingFlameGraphHandle +>({ + mapStateToProps: (state) => ({ + thread: selectedThreadSelectors.getPreviewFilteredThread(state), + weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), + maxStackDepthPlusOne: + selectedThreadSelectors.getFilteredCallNodeMaxDepthPlusOne(state), + flameGraphTiming: + selectedThreadSelectors.getUpperWingFlameGraphTiming(state), + callTree: selectedThreadSelectors.getUpperWingCallTree(state), + timeRange: getCommittedRange(state), + previewSelection: getPreviewSelection(state), + callNodeInfo: selectedThreadSelectors.getUpperWingCallNodeInfo(state), + categories: getCategories(state), + threadsKey: getSelectedThreadsKey(state), + selectedCallNodeIndex: + selectedThreadSelectors.getUpperWingSelectedCallNodeIndex(state), + rightClickedCallNodeIndex: + selectedThreadSelectors.getUpperWingRightClickedCallNodeIndex(state), + scrollToSelectionGeneration: getScrollToSelectionGeneration(state), + interval: getProfileInterval(state), + callTreeSummaryStrategy: + selectedThreadSelectors.getCallTreeSummaryStrategy(state), + innerWindowIDToPageMap: getInnerWindowIDToPageMap(state), + ctssSamples: selectedThreadSelectors.getPreviewFilteredCtssSamples(state), + ctssSampleCategoriesAndSubcategories: + selectedThreadSelectors.getPreviewFilteredCtssSampleCategoriesAndSubcategories( + state + ), + tracedTiming: selectedThreadSelectors.getTracedTiming(state), + displayStackType: getProfileUsesMultipleStackTypes(state), + }), + mapDispatchToProps: { + changeSelectedCallNode: ( + threadsKey: ThreadsKey, + path: CallNodePath, + context?: SelectionContext + ) => changeWingSelectedCallNode('upper', threadsKey, path, context), + changeRightClickedCallNode: ( + threadsKey: ThreadsKey, + path: CallNodePath | null + ) => changeWingRightClickedCallNode('upper', threadsKey, path), + changeRightClickedFunctionIndex, + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, + }, + component: UpperWingFlameGraphImpl, +}); diff --git a/src/components/calltree/WingTreeView.tsx b/src/components/calltree/WingTreeView.tsx new file mode 100644 index 0000000000..e6141e5bad --- /dev/null +++ b/src/components/calltree/WingTreeView.tsx @@ -0,0 +1,426 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import * as React from 'react'; +import { PureComponent } from 'react'; +import explicitConnect from 'firefox-profiler/utils/connect'; +import { TreeView } from 'firefox-profiler/components/shared/TreeView'; +import { CallTreeEmptyReasons } from './CallTreeEmptyReasons'; +import { + UpperWingFlameGraph, + type UpperWingFlameGraphHandle, +} from './UpperWingFlameGraph'; +import { + LowerWingFlameGraph, + type LowerWingFlameGraphHandle, +} from './LowerWingFlameGraph'; +import { nameColumn, libColumn, treeColumnsForWeightType } from './columns'; +import { + getSearchStringsAsRegExp, + getSelectedThreadsKey, + getUpperWingView, + getLowerWingView, +} from 'firefox-profiler/selectors/url-state'; +import { + getScrollToSelectionGeneration, + getCategories, + getCurrentTableViewOptions, + getPreviewSelectionIsBeingModified, +} from 'firefox-profiler/selectors/profile'; +import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { + changeWingSelectedCallNode, + changeWingRightClickedCallNode, + changeWingExpandedCallNodes, + addTransformToStack, + handleCallNodeTransformShortcut, + changeTableViewOptions, + updateBottomBoxContentsAndMaybeOpen, +} from 'firefox-profiler/actions/profile-view'; +import type { + State, + ThreadsKey, + CategoryList, + IndexIntoCallNodeTable, + CallNodeDisplayData, + WeightType, + TableViewOptions, + SelectionContext, + WingViewType, + CallNodePath, + WingName, +} from 'firefox-profiler/types'; +import type { CallTree as CallTreeType } from 'firefox-profiler/profile-logic/call-tree'; +import type { CallNodeInfo } from 'firefox-profiler/profile-logic/call-node-info'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +import './CallTree.css'; + +// Structural interface satisfied by both wing flame graph components' ref +// handles. Used so the shared impl can call focus() on either. +type WingFlameGraphHandle = { focus(): void }; + +type StateProps = { + readonly threadsKey: ThreadsKey; + readonly scrollToSelectionGeneration: number; + readonly tree: CallTreeType; + readonly callNodeInfo: CallNodeInfo; + readonly categories: CategoryList; + readonly selectedCallNodeIndex: IndexIntoCallNodeTable | null; + readonly rightClickedCallNodeIndex: IndexIntoCallNodeTable | null; + readonly expandedCallNodeIndexes: Array; + readonly searchStringsRegExp: RegExp | null; + readonly disableOverscan: boolean; + readonly callNodeMaxDepthPlusOne: number; + readonly weightType: WeightType; + readonly tableViewOptions: TableViewOptions; + readonly view: WingViewType; +}; + +type DispatchProps = { + readonly changeSelectedCallNode: ( + threadsKey: ThreadsKey, + path: CallNodePath, + context?: SelectionContext + ) => any; + readonly changeRightClickedCallNode: ( + threadsKey: ThreadsKey, + path: CallNodePath | null + ) => any; + readonly changeExpandedCallNodes: ( + threadsKey: ThreadsKey, + paths: Array + ) => any; + readonly addTransformToStack: typeof addTransformToStack; + readonly handleCallNodeTransformShortcut: typeof handleCallNodeTransformShortcut; + readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; + readonly onTableViewOptionsChange: (options: TableViewOptions) => any; +}; + +// Wing-specific bits that vary between Upper and Lower wing. Injected by +// each wrapper below. +type WingConfigProps = { + readonly contextMenuId: string; + readonly renderFlameGraph: ( + ref: React.RefObject + ) => React.ReactNode; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps> & WingConfigProps; + +class WingTreeViewImpl extends PureComponent { + _treeView: TreeView | null = null; + _takeTreeViewRef = (treeView: TreeView | null) => { + this._treeView = treeView; + }; + _flameGraphRef: React.RefObject = + React.createRef(); + + override componentDidMount() { + this.focus(); + this.maybeProcureInterestingInitialSelection(); + + if (this.props.selectedCallNodeIndex !== null && this._treeView) { + this._treeView.scrollSelectionIntoView(); + } + } + + override componentDidUpdate(prevProps: Props) { + this.maybeProcureInterestingInitialSelection(); + + if ( + this.props.selectedCallNodeIndex !== null && + this.props.scrollToSelectionGeneration > + prevProps.scrollToSelectionGeneration && + this._treeView + ) { + this._treeView.scrollSelectionIntoView(); + } + } + + focus() { + if (this.props.view === 'flame-graph') { + this._flameGraphRef.current?.focus(); + return; + } + if (this._treeView) { + this._treeView.focus(); + } + } + + _onSelectedCallNodeChange = ( + newSelectedCallNode: IndexIntoCallNodeTable, + context: SelectionContext + ) => { + const { callNodeInfo, threadsKey, changeSelectedCallNode } = this.props; + changeSelectedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(newSelectedCallNode), + context + ); + }; + + _onRightClickSelection = (newSelectedCallNode: IndexIntoCallNodeTable) => { + const { callNodeInfo, threadsKey, changeRightClickedCallNode } = this.props; + changeRightClickedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(newSelectedCallNode) + ); + }; + + _onExpandedCallNodesChange = ( + newExpandedCallNodeIndexes: Array + ) => { + const { callNodeInfo, threadsKey, changeExpandedCallNodes } = this.props; + changeExpandedCallNodes( + threadsKey, + newExpandedCallNodeIndexes.map((callNodeIndex) => + callNodeInfo.getCallNodePathFromIndex(callNodeIndex) + ) + ); + }; + + _onKeyDown = (event: React.KeyboardEvent) => { + const { + selectedCallNodeIndex, + rightClickedCallNodeIndex, + callNodeInfo, + handleCallNodeTransformShortcut, + threadsKey, + } = this.props; + const nodeIndex = + rightClickedCallNodeIndex !== null + ? rightClickedCallNodeIndex + : selectedCallNodeIndex; + if (nodeIndex === null) { + return; + } + handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); + }; + + _onEnterOrDoubleClick = (nodeId: IndexIntoCallNodeTable) => { + const { tree, updateBottomBoxContentsAndMaybeOpen } = this.props; + const bottomBoxInfo = tree.getBottomBoxInfoForCallNode(nodeId); + updateBottomBoxContentsAndMaybeOpen('function-list', bottomBoxInfo); + }; + + maybeProcureInterestingInitialSelection() { + // Expand the heaviest callstack up to a certain depth and select the frame + // at that depth. + const { + tree, + expandedCallNodeIndexes, + selectedCallNodeIndex, + callNodeInfo, + categories, + } = this.props; + + if (selectedCallNodeIndex !== null || expandedCallNodeIndexes.length > 0) { + // Let's not change some existing state. + return; + } + + const idleCategoryIndex = categories.findIndex( + (category) => category.name === 'Idle' + ); + + const newExpandedCallNodeIndexes = expandedCallNodeIndexes.slice(); + const maxInterestingDepth = 17; // scientifically determined + let currentCallNodeIndex = tree.getRoots()[0]; + if (currentCallNodeIndex === undefined) { + // This tree is empty. + return; + } + newExpandedCallNodeIndexes.push(currentCallNodeIndex); + for (let i = 0; i < maxInterestingDepth; i++) { + const children = tree.getChildren(currentCallNodeIndex); + if (children.length === 0) { + break; + } + + // Let's find if there's a non idle children. + const firstNonIdleNode = children.find( + (nodeIndex) => + callNodeInfo.categoryForNode(nodeIndex) !== idleCategoryIndex + ); + + // If there's a non idle children, use it; otherwise use the first + // children (that will be idle). + currentCallNodeIndex = + firstNonIdleNode !== undefined ? firstNonIdleNode : children[0]; + newExpandedCallNodeIndexes.push(currentCallNodeIndex); + } + this._onExpandedCallNodesChange(newExpandedCallNodeIndexes); + + const categoryIndex = callNodeInfo.categoryForNode(currentCallNodeIndex); + if (categoryIndex !== idleCategoryIndex) { + // If we selected the call node with a "idle" category, we'd have a + // completely dimmed activity graph because idle stacks are not drawn in + // this graph. Because this isn't probably what the average user wants we + // do it only when the category is something different. + this._onSelectedCallNodeChange(currentCallNodeIndex, { source: 'auto' }); + } + } + + _renderCallTree() { + const { + tree, + selectedCallNodeIndex, + rightClickedCallNodeIndex, + expandedCallNodeIndexes, + searchStringsRegExp, + disableOverscan, + callNodeMaxDepthPlusOne, + weightType, + tableViewOptions, + onTableViewOptionsChange, + contextMenuId, + } = this.props; + if (tree.getRoots().length === 0) { + return ; + } + return ( + + ); + } + + override render() { + if (this.props.view === 'call-tree') { + return this._renderCallTree(); + } + return this.props.renderFlameGraph(this._flameGraphRef); + } +} + +function makeMapDispatchToProps(wing: WingName) { + return { + changeSelectedCallNode: ( + threadsKey: ThreadsKey, + path: CallNodePath, + context?: SelectionContext + ) => changeWingSelectedCallNode(wing, threadsKey, path, context), + changeRightClickedCallNode: ( + threadsKey: ThreadsKey, + path: CallNodePath | null + ) => changeWingRightClickedCallNode(wing, threadsKey, path), + changeExpandedCallNodes: ( + threadsKey: ThreadsKey, + paths: Array + ) => changeWingExpandedCallNodes(wing, threadsKey, paths), + addTransformToStack, + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, + onTableViewOptionsChange: (options: TableViewOptions) => + changeTableViewOptions('calltree', options), + }; +} + +const renderUpperWingFlameGraph = ( + ref: React.RefObject +) => } />; + +const renderLowerWingFlameGraph = ( + ref: React.RefObject +) => } />; + +function UpperWingComponent( + props: ConnectedProps<{}, StateProps, DispatchProps> +) { + return ( + + ); +} + +function LowerWingComponent( + props: ConnectedProps<{}, StateProps, DispatchProps> +) { + return ( + + ); +} + +export const UpperWing = explicitConnect<{}, StateProps, DispatchProps>({ + mapStateToProps: (state: State): StateProps => ({ + threadsKey: getSelectedThreadsKey(state), + scrollToSelectionGeneration: getScrollToSelectionGeneration(state), + tree: selectedThreadSelectors.getUpperWingCallTree(state), + callNodeInfo: selectedThreadSelectors.getUpperWingCallNodeInfo(state), + categories: getCategories(state), + selectedCallNodeIndex: + selectedThreadSelectors.getUpperWingSelectedCallNodeIndex(state), + rightClickedCallNodeIndex: + selectedThreadSelectors.getUpperWingRightClickedCallNodeIndex(state), + expandedCallNodeIndexes: + selectedThreadSelectors.getUpperWingExpandedCallNodeIndexes(state), + searchStringsRegExp: getSearchStringsAsRegExp(state), + disableOverscan: getPreviewSelectionIsBeingModified(state), + // Use the filtered call node max depth, rather than the preview filtered + // call node max depth so that the width of the TreeView component is stable + // across preview selections. + callNodeMaxDepthPlusOne: + selectedThreadSelectors.getFilteredCallNodeMaxDepthPlusOne(state), + weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), + tableViewOptions: getCurrentTableViewOptions(state), + view: getUpperWingView(state), + }), + mapDispatchToProps: makeMapDispatchToProps('upper'), + component: UpperWingComponent, +}); + +export const LowerWing = explicitConnect<{}, StateProps, DispatchProps>({ + mapStateToProps: (state: State): StateProps => ({ + threadsKey: getSelectedThreadsKey(state), + scrollToSelectionGeneration: getScrollToSelectionGeneration(state), + tree: selectedThreadSelectors.getLowerWingCallTree(state), + callNodeInfo: selectedThreadSelectors.getLowerWingCallNodeInfo(state), + categories: getCategories(state), + selectedCallNodeIndex: + selectedThreadSelectors.getLowerWingSelectedCallNodeIndex(state), + rightClickedCallNodeIndex: + selectedThreadSelectors.getLowerWingRightClickedCallNodeIndex(state), + expandedCallNodeIndexes: + selectedThreadSelectors.getLowerWingExpandedCallNodeIndexes(state), + searchStringsRegExp: getSearchStringsAsRegExp(state), + disableOverscan: getPreviewSelectionIsBeingModified(state), + callNodeMaxDepthPlusOne: + selectedThreadSelectors.getFilteredCallNodeMaxDepthPlusOne(state), + weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), + tableViewOptions: getCurrentTableViewOptions(state), + view: getLowerWingView(state), + }), + mapDispatchToProps: makeMapDispatchToProps('lower'), + component: LowerWingComponent, +}); diff --git a/src/components/calltree/WingViewToggle.css b/src/components/calltree/WingViewToggle.css new file mode 100644 index 0000000000..0743c3bfec --- /dev/null +++ b/src/components/calltree/WingViewToggle.css @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +.wingViewToggle { + display: flex; + align-items: center; + gap: 1px; +} + +.wingViewToggleButton { + display: flex; + width: 18px; + height: 18px; + align-items: center; + justify-content: center; + padding: 0; + border: none; + border-radius: 2px; + background: transparent; + color: var(--panel-foreground-color); + cursor: pointer; +} + +.wingViewToggleButton:hover { + background: var(--clickable-ghost-hover-background-color); +} + +.wingViewToggleButton:active { + background: var(--clickable-ghost-active-background-color); +} + +.wingViewToggleButton.selected { + background: var(--clickable-ghost-active-background-color); +} + +.wingViewToggleButton svg { + fill: currentcolor; + opacity: 0.55; +} + +.wingViewToggleButton.selected svg { + opacity: 1; +} diff --git a/src/components/calltree/WingViewToggle.tsx b/src/components/calltree/WingViewToggle.tsx new file mode 100644 index 0000000000..44ab072baa --- /dev/null +++ b/src/components/calltree/WingViewToggle.tsx @@ -0,0 +1,123 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import * as React from 'react'; + +import explicitConnect from 'firefox-profiler/utils/connect'; +import { + getUpperWingView, + getLowerWingView, + getSelfWingView, +} from 'firefox-profiler/selectors/url-state'; +import { changeWingView } from 'firefox-profiler/actions/profile-view'; +import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; + +import type { State, WingViewType } from 'firefox-profiler/types'; +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +import './WingViewToggle.css'; + +type OwnProps = { + readonly wing: 'upper' | 'lower' | 'self'; +}; + +type StateProps = { + readonly view: WingViewType; +}; + +type DispatchProps = { + readonly changeWingView: typeof changeWingView; +}; + +type Props = ConnectedProps; + +class WingViewToggleImpl extends React.PureComponent { + _onFlameGraphClick = (event: React.MouseEvent) => { + event.stopPropagation(); + this.props.changeWingView(this.props.wing, 'flame-graph'); + }; + _onCallTreeClick = (event: React.MouseEvent) => { + event.stopPropagation(); + this.props.changeWingView(this.props.wing, 'call-tree'); + }; + + override render() { + const { view } = this.props; + return ( +
+ + +
+ ); + } +} + +export const WingViewToggle = explicitConnect< + OwnProps, + StateProps, + DispatchProps +>({ + mapStateToProps: (state: State, ownProps: OwnProps) => { + let view; + switch (ownProps.wing) { + case 'upper': + view = getUpperWingView(state); + break; + case 'lower': + view = getLowerWingView(state); + break; + case 'self': + view = getSelfWingView(state); + break; + default: + throw assertExhaustiveCheck(ownProps.wing, 'Unhandled wing.'); + } + return { view }; + }, + mapDispatchToProps: { + changeWingView, + }, + component: WingViewToggleImpl, +}); diff --git a/src/components/shared/FunctionListContextMenu.tsx b/src/components/shared/WingContextMenu.tsx similarity index 53% rename from src/components/shared/FunctionListContextMenu.tsx rename to src/components/shared/WingContextMenu.tsx index ccbdeb54a8..5b8075b07c 100644 --- a/src/components/shared/FunctionListContextMenu.tsx +++ b/src/components/shared/WingContextMenu.tsx @@ -26,6 +26,7 @@ import { getProfileViewOptions, getShouldDisplaySearchfox, } from 'firefox-profiler/selectors/profile'; +import { getRightClickedCallNodeInfo } from 'firefox-profiler/selectors/right-clicked-call-node'; import { oneLine } from 'common-tags'; import { @@ -47,11 +48,103 @@ import type { ConnectedProps } from 'firefox-profiler/utils/connect'; import './CallNodeContextMenu.css'; +// Context provided to data-table menu items so they can compute visibility, +// label, and l10n vars from the current right-clicked function. +type MenuItemContext = { + readonly funcIndex: IndexIntoFuncTable; + readonly callNodeTables: ReadonlyArray; + readonly nameForResource: string | null; +}; + +// A descriptor for a transform menu item. The data table below drives the +// rendering of the menu, instead of repeating identical JSX three times. +type TransformMenuItem = { + readonly transform: TransformType; + readonly shortcut: string; + readonly icon: string; + readonly l10nId: string; + readonly content: (ctx: MenuItemContext) => string; + readonly visible?: (ctx: MenuItemContext) => boolean; + readonly l10nVars?: (ctx: MenuItemContext) => Record; + readonly l10nElems?: Record; +}; + +const MENU_ITEMS: ReadonlyArray = [ + { + transform: 'merge-function', + shortcut: 'm', + icon: 'Merge', + l10nId: 'CallNodeContextMenu--transform-merge-function', + content: () => 'Merge function', + }, + { + transform: 'focus-function', + shortcut: 'f', + icon: 'Focus', + l10nId: 'CallNodeContextMenu--transform-focus-function', + content: () => 'Focus on function', + }, + { + transform: 'focus-self', + shortcut: 'S', + icon: 'FocusSelf', + l10nId: 'CallNodeContextMenu--transform-focus-self', + content: () => 'Focus on self only', + }, + { + transform: 'collapse-function-subtree', + shortcut: 'c', + icon: 'Collapse', + l10nId: 'CallNodeContextMenu--transform-collapse-function-subtree', + content: () => 'Collapse function', + }, + { + transform: 'collapse-resource', + shortcut: 'C', + icon: 'Collapse', + l10nId: 'CallNodeContextMenu--transform-collapse-resource', + content: ({ nameForResource }) => `Collapse ${nameForResource}`, + visible: ({ nameForResource }) => nameForResource !== null, + l10nVars: ({ nameForResource }) => ({ + nameForResource: nameForResource ?? '', + }), + l10nElems: { strong: }, + }, + { + transform: 'collapse-recursion', + shortcut: 'r', + icon: 'Collapse', + l10nId: 'CallNodeContextMenu--transform-collapse-recursion', + content: () => 'Collapse recursion', + visible: ({ funcIndex, callNodeTables }) => + callNodeTables.some((t) => funcHasRecursiveCall(t, funcIndex)), + }, + { + transform: 'collapse-direct-recursion', + shortcut: 'R', + icon: 'Collapse', + l10nId: 'CallNodeContextMenu--transform-collapse-direct-recursion-only', + content: () => 'Collapse direct recursion only', + visible: ({ funcIndex, callNodeTables }) => + callNodeTables.some((t) => funcHasDirectRecursiveCall(t, funcIndex)), + }, + { + transform: 'drop-function', + shortcut: 'd', + icon: 'Drop', + l10nId: 'CallNodeContextMenu--transform-drop-function', + content: () => 'Drop samples with this function', + }, +]; + type StateProps = { readonly thread: Thread | null; readonly threadsKey: ThreadsKey | null; - readonly rightClickedFunctionIndex: IndexIntoFuncTable | null; - readonly callNodeTable: CallNodeTable | null; + readonly funcIndex: IndexIntoFuncTable | null; + // Call node tables in which to check for recursion. Multiple tables are + // useful for the self wing menu, where the focusSelf-filtered table may + // reveal recursion that the regular table hides. + readonly callNodeTables: ReadonlyArray; readonly implementation: ImplementationFilter; readonly displaySearchfox: boolean; }; @@ -62,9 +155,13 @@ type DispatchProps = { readonly setContextMenuVisibility: typeof setContextMenuVisibility; }; -type Props = ConnectedProps<{}, StateProps, DispatchProps>; +// The DOM id is baked in by each connected variant below; the impl receives +// it as a regular prop. +type Props = ConnectedProps<{}, StateProps, DispatchProps> & { + readonly id: string; +}; -class FunctionListContextMenuImpl extends PureComponent { +class WingContextMenuImpl extends PureComponent { _hidingTimeout: NodeJS.Timeout | null = null; _onShow = () => { @@ -85,22 +182,10 @@ class FunctionListContextMenuImpl extends PureComponent { readonly thread: Thread; readonly threadsKey: ThreadsKey; readonly funcIndex: IndexIntoFuncTable; - readonly callNodeTable: CallNodeTable; } { - const { thread, threadsKey, rightClickedFunctionIndex, callNodeTable } = - this.props; - if ( - thread !== null && - threadsKey !== null && - rightClickedFunctionIndex !== null && - callNodeTable !== null - ) { - return { - thread, - threadsKey, - funcIndex: rightClickedFunctionIndex, - callNodeTable, - }; + const { thread, threadsKey, funcIndex } = this.props; + if (thread !== null && threadsKey !== null && funcIndex !== null) { + return { thread, threadsKey, funcIndex }; } return null; } @@ -233,7 +318,7 @@ class FunctionListContextMenuImpl extends PureComponent { case 'focus-category': case 'filter-samples': throw new Error( - `The transform "${type}" is not supported in the function list context menu.` + `The transform "${type}" is not supported in the wing context menu.` ); default: assertExhaustiveCheck(type); @@ -264,45 +349,33 @@ class FunctionListContextMenuImpl extends PureComponent { } }; - renderTransformMenuItem(props: { - readonly l10nId: string; - readonly content: React.ReactNode; - readonly onClick: ( - event: React.ChangeEvent, - data: { type: string } - ) => void; - readonly transform: string; - readonly shortcut: string; - readonly icon: string; - readonly title: string; - readonly l10nVars?: Record; - readonly l10nElems?: Record; - }) { + renderTransformMenuItem(item: TransformMenuItem, ctx: MenuItemContext) { return ( - + -
- {props.content} +
+ {item.content(ctx)}
- {props.shortcut} + {item.shortcut} ); } renderContextMenuContents() { - const { displaySearchfox } = this.props; + const { displaySearchfox, callNodeTables } = this.props; const info = this._getRightClickedInfo(); if (info === null) { @@ -312,99 +385,19 @@ class FunctionListContextMenuImpl extends PureComponent { return
; } - const { funcIndex, callNodeTable } = info; - const nameForResource = this.getNameForSelectedResource(); + const ctx: MenuItemContext = { + funcIndex: info.funcIndex, + callNodeTables, + nameForResource: this.getNameForSelectedResource(), + }; + + const renderedItems = MENU_ITEMS.filter( + (item) => !item.visible || item.visible(ctx) + ).map((item) => this.renderTransformMenuItem(item, ctx)); return ( <> - {this.renderTransformMenuItem({ - l10nId: 'CallNodeContextMenu--transform-merge-function', - shortcut: 'm', - icon: 'Merge', - onClick: this._handleClick, - transform: 'merge-function', - title: '', - content: 'Merge function', - })} - - {this.renderTransformMenuItem({ - l10nId: 'CallNodeContextMenu--transform-focus-function', - shortcut: 'f', - icon: 'Focus', - onClick: this._handleClick, - transform: 'focus-function', - title: '', - content: 'Focus on function', - })} - - {this.renderTransformMenuItem({ - l10nId: 'CallNodeContextMenu--transform-focus-self', - shortcut: 'S', - icon: 'FocusSelf', - onClick: this._handleClick, - transform: 'focus-self', - title: '', - content: 'Focus on self only', - })} - - {this.renderTransformMenuItem({ - l10nId: 'CallNodeContextMenu--transform-collapse-function-subtree', - shortcut: 'c', - icon: 'Collapse', - onClick: this._handleClick, - transform: 'collapse-function-subtree', - title: '', - content: 'Collapse function', - })} - - {nameForResource - ? this.renderTransformMenuItem({ - l10nId: 'CallNodeContextMenu--transform-collapse-resource', - l10nVars: { nameForResource }, - l10nElems: { strong: }, - shortcut: 'C', - icon: 'Collapse', - onClick: this._handleClick, - transform: 'collapse-resource', - title: '', - content: `Collapse ${nameForResource}`, - }) - : null} - - {funcHasRecursiveCall(callNodeTable, funcIndex) - ? this.renderTransformMenuItem({ - l10nId: 'CallNodeContextMenu--transform-collapse-recursion', - shortcut: 'r', - icon: 'Collapse', - onClick: this._handleClick, - transform: 'collapse-recursion', - title: '', - content: 'Collapse recursion', - }) - : null} - - {funcHasDirectRecursiveCall(callNodeTable, funcIndex) - ? this.renderTransformMenuItem({ - l10nId: - 'CallNodeContextMenu--transform-collapse-direct-recursion-only', - shortcut: 'R', - icon: 'Collapse', - onClick: this._handleClick, - transform: 'collapse-direct-recursion', - title: '', - content: 'Collapse direct recursion only', - }) - : null} - - {this.renderTransformMenuItem({ - l10nId: 'CallNodeContextMenu--transform-drop-function', - shortcut: 'd', - icon: 'Drop', - onClick: this._handleClick, - transform: 'drop-function', - title: '', - content: 'Drop samples with this function', - })} + {renderedItems}
@@ -434,7 +427,7 @@ class FunctionListContextMenuImpl extends PureComponent { return ( { } } +const dispatchToProps: DispatchProps = { + addTransformToStack, + addCollapseResourceTransformToStack, + setContextMenuVisibility, +}; + +// Connected variant used by the function list and self wing: the right-clicked +// function comes from the rightClickedFunction profile-view state. Recursion +// detection considers both the regular call node table and the self wing's +// call node table (where the focusSelf filter may surface recursion that the +// regular table hides). export const FunctionListContextMenu = explicitConnect< {}, StateProps, @@ -454,35 +458,83 @@ export const FunctionListContextMenu = explicitConnect< const rightClickedFunction = getProfileViewOptions(state).rightClickedFunction; - let thread = null; - let threadsKey = null; - let rightClickedFunctionIndex = null; - let callNodeTable = null; - - if (rightClickedFunction !== null) { - const selectors = getThreadSelectorsFromThreadsKey( - rightClickedFunction.threadsKey - ); - thread = selectors.getFilteredThread(state); - threadsKey = rightClickedFunction.threadsKey; - rightClickedFunctionIndex = rightClickedFunction.functionIndex; - // Use the non-inverted call node table for recursion detection. - callNodeTable = selectors.getCallNodeInfo(state).getCallNodeTable(); + if (rightClickedFunction === null) { + return { + thread: null, + threadsKey: null, + funcIndex: null, + callNodeTables: [], + implementation: getImplementationFilter(state), + displaySearchfox: getShouldDisplaySearchfox(state), + }; } + const selectors = getThreadSelectorsFromThreadsKey( + rightClickedFunction.threadsKey + ); + const callNodeTables: CallNodeTable[] = [ + selectors.getCallNodeInfo(state).getCallNodeTable(), + selectors.getSelfWingCallNodeInfo(state).getCallNodeTable(), + ]; + return { - thread, - threadsKey, - rightClickedFunctionIndex, - callNodeTable, + thread: selectors.getFilteredThread(state), + threadsKey: rightClickedFunction.threadsKey, + funcIndex: rightClickedFunction.functionIndex, + callNodeTables, implementation: getImplementationFilter(state), displaySearchfox: getShouldDisplaySearchfox(state), }; }, - mapDispatchToProps: { - addTransformToStack, - addCollapseResourceTransformToStack, - setContextMenuVisibility, + mapDispatchToProps: dispatchToProps, + component: (props) => ( + + ), +}); + +// Connected variant used by the lower wing: the right-clicked function comes +// from a right-clicked call node in the LOWER_WING area. Only the regular +// call node table is consulted for recursion. +export const LowerWingContextMenu = explicitConnect< + {}, + StateProps, + DispatchProps +>({ + mapStateToProps: (state: State) => { + const rightClickedCallNodeInfo = getRightClickedCallNodeInfo(state); + + if ( + rightClickedCallNodeInfo === null || + rightClickedCallNodeInfo.area !== 'LOWER_WING' + ) { + return { + thread: null, + threadsKey: null, + funcIndex: null, + callNodeTables: [], + implementation: getImplementationFilter(state), + displaySearchfox: getShouldDisplaySearchfox(state), + }; + } + + const selectors = getThreadSelectorsFromThreadsKey( + rightClickedCallNodeInfo.threadsKey + ); + const callNodeTables: CallNodeTable[] = [ + selectors.getCallNodeInfo(state).getCallNodeTable(), + ]; + + return { + thread: selectors.getFilteredThread(state), + threadsKey: rightClickedCallNodeInfo.threadsKey, + funcIndex: selectors.getLowerWingRightClickedFuncIndex(state), + callNodeTables, + implementation: getImplementationFilter(state), + displaySearchfox: getShouldDisplaySearchfox(state), + }; }, - component: FunctionListContextMenuImpl, + mapDispatchToProps: dispatchToProps, + component: (props) => ( + + ), }); diff --git a/src/profile-logic/flame-graph.ts b/src/profile-logic/flame-graph.ts index 03aaa7f0c0..e9b13c2693 100644 --- a/src/profile-logic/flame-graph.ts +++ b/src/profile-logic/flame-graph.ts @@ -413,7 +413,7 @@ export type LowerWingFlameGraphTotals = { rootTotalSummary: number; // Equal to rootTotalSummary — the lower wing's flame graph root fills the // entire width. - flameGraphTotalForScaling: number; + flameGraphWidthTotal: number; }; /** @@ -493,7 +493,7 @@ export class LowerWingFlameGraphTiming extends FlameGraphTiming { this._prefixSums = null; this._prefixSumsForTableLength = -1; this._rootTotalSummary = 0; - // tooltipRatioMultiplier for the lower wing is flameGraphTotalForScaling / + // tooltipRatioMultiplier for the lower wing is flameGraphWidthTotal / // rootTotalSummary, both equal to total[0]. So 1 when there's a selection // with sample weight, 0 otherwise. We resolve this lazily inside // `_ensurePrefixSums` since both values come from the prefix-sums pass. @@ -656,8 +656,8 @@ export class LowerWingFlameGraphTiming extends FlameGraphTiming { this._growPerTableIdxArraysTo(table.length); const rowNodes = this._rowsCallNodes[nextDepth]; - const flameGraphTotalForScaling = this._rootTotalSummary; - if (flameGraphTotalForScaling === 0) { + const flameGraphWidthTotal = this._rootTotalSummary; + if (flameGraphWidthTotal === 0) { this._timingRows.push({ start: [], end: [], @@ -724,8 +724,8 @@ export class LowerWingFlameGraphTiming extends FlameGraphTiming { } startPerTableIdx[tableIdx] = currentStart; - const totalRelative = abs(totalVal / flameGraphTotalForScaling); - const selfRelativeVal = abs(selfVal / flameGraphTotalForScaling); + const totalRelative = abs(totalVal / flameGraphWidthTotal); + const selfRelativeVal = abs(selfVal / flameGraphWidthTotal); const currentEnd = currentStart + totalRelative; start.push(currentStart); diff --git a/src/reducers/profile-view.ts b/src/reducers/profile-view.ts index fa2b3fd6c8..0f9bffb48e 100644 --- a/src/reducers/profile-view.ts +++ b/src/reducers/profile-view.ts @@ -31,6 +31,7 @@ import type { Milliseconds, TableViewOptions, RightClickedFunction, + CallNodeArea, } from 'firefox-profiler/types'; import { applyFuncSubstitutionToCallPath, @@ -184,12 +185,25 @@ const sourceMapSymbolicationStatus: Reducer = ( } }; +const _emptySelectedCallNodePaths: Record = { + NON_INVERTED_TREE: [], + INVERTED_TREE: [], + LOWER_WING: [], + UPPER_WING: [], +}; + +function _emptyExpandedCallNodePaths(): Record { + return { + NON_INVERTED_TREE: new PathSet(), + INVERTED_TREE: new PathSet(), + LOWER_WING: new PathSet(), + UPPER_WING: new PathSet(), + }; +} + export const defaultThreadViewOptions: ThreadViewOptions = { - selectedNonInvertedCallNodePath: [], - selectedInvertedCallNodePath: [], - expandedNonInvertedCallNodePaths: new PathSet(), - expandedInvertedCallNodePaths: new PathSet(), - selectedFunctionIndex: null, + selectedCallNodePaths: _emptySelectedCallNodePaths, + expandedCallNodePaths: _emptyExpandedCallNodePaths(), selectedNetworkMarker: null, lastSeenTransformCount: 0, }; @@ -231,24 +245,29 @@ const viewOptionsPerThread: Reducer = ( const newState = objectMap(state, (threadViewOptions) => { return { ...threadViewOptions, - selectedNonInvertedCallNodePath: applyFuncSubstitutionToCallPath( - oldFuncToNewFuncsMap, - threadViewOptions.selectedNonInvertedCallNodePath - ), - selectedInvertedCallNodePath: applyFuncSubstitutionToCallPath( - oldFuncToNewFuncsMap, - threadViewOptions.selectedInvertedCallNodePath - ), - expandedNonInvertedCallNodePaths: - applyFuncSubstitutionToPathSetAndIncludeNewAncestors( + selectedCallNodePaths: { + ...threadViewOptions.selectedCallNodePaths, + NON_INVERTED_TREE: applyFuncSubstitutionToCallPath( oldFuncToNewFuncsMap, - threadViewOptions.expandedNonInvertedCallNodePaths + threadViewOptions.selectedCallNodePaths.NON_INVERTED_TREE ), - expandedInvertedCallNodePaths: - applyFuncSubstitutionToPathSetAndIncludeNewAncestors( + INVERTED_TREE: applyFuncSubstitutionToCallPath( oldFuncToNewFuncsMap, - threadViewOptions.expandedInvertedCallNodePaths + threadViewOptions.selectedCallNodePaths.INVERTED_TREE ), + }, + expandedCallNodePaths: { + ...threadViewOptions.expandedCallNodePaths, + NON_INVERTED_TREE: + applyFuncSubstitutionToPathSetAndIncludeNewAncestors( + oldFuncToNewFuncsMap, + threadViewOptions.expandedCallNodePaths.NON_INVERTED_TREE + ), + INVERTED_TREE: applyFuncSubstitutionToPathSetAndIncludeNewAncestors( + oldFuncToNewFuncsMap, + threadViewOptions.expandedCallNodePaths.INVERTED_TREE + ), + }, }; }); @@ -256,17 +275,15 @@ const viewOptionsPerThread: Reducer = ( } case 'CHANGE_SELECTED_CALL_NODE': { const { - isInverted, + area, selectedCallNodePath, threadsKey, optionalExpandedToCallNodePath, } = action; const threadState = _getThreadViewOptions(state, threadsKey); - - const previousSelectedCallNodePath = isInverted - ? threadState.selectedInvertedCallNodePath - : threadState.selectedNonInvertedCallNodePath; + const previousSelectedCallNodePath = + threadState.selectedCallNodePaths[area]; // If the selected node doesn't actually change, let's return the previous // state to avoid rerenders. @@ -277,9 +294,7 @@ const viewOptionsPerThread: Reducer = ( return state; } - let expandedCallNodePaths = isInverted - ? threadState.expandedInvertedCallNodePaths - : threadState.expandedNonInvertedCallNodePaths; + let expandedCallNodePaths = threadState.expandedCallNodePaths[area]; const expandToNode = optionalExpandedToCallNodePath ? optionalExpandedToCallNodePath : selectedCallNodePath; @@ -303,35 +318,51 @@ const viewOptionsPerThread: Reducer = ( ); } - return _updateThreadViewOptions( - state, - threadsKey, - isInverted - ? { - selectedInvertedCallNodePath: selectedCallNodePath, - expandedInvertedCallNodePaths: expandedCallNodePaths, - } - : { - selectedNonInvertedCallNodePath: selectedCallNodePath, - expandedNonInvertedCallNodePaths: expandedCallNodePaths, - } - ); + return _updateThreadViewOptions(state, threadsKey, { + selectedCallNodePaths: { + ...threadState.selectedCallNodePaths, + [area]: selectedCallNodePath, + }, + expandedCallNodePaths: { + ...threadState.expandedCallNodePaths, + [area]: expandedCallNodePaths, + }, + }); } case 'CHANGE_SELECTED_FUNCTION': { const { selectedFunctionIndex, threadsKey } = action; const threadState = _getThreadViewOptions(state, threadsKey); - - const previousSelectedFunction = threadState.selectedFunctionIndex; - - // If the selected function doesn't actually change, let's return the previous - // state to avoid rerenders. - if (selectedFunctionIndex === previousSelectedFunction) { + const previousLowerWingPath = + threadState.selectedCallNodePaths.LOWER_WING; + const isSameSelection = + selectedFunctionIndex === null + ? previousLowerWingPath.length === 0 + : previousLowerWingPath.length === 1 && + previousLowerWingPath[0] === selectedFunctionIndex; + + if (isSameSelection) { return state; } + const wingPath: CallNodePath = + selectedFunctionIndex !== null ? [selectedFunctionIndex] : []; + const wingExpanded = + selectedFunctionIndex !== null + ? new PathSet([[selectedFunctionIndex]]) + : new PathSet(); + return _updateThreadViewOptions(state, threadsKey, { - selectedFunctionIndex, + selectedCallNodePaths: { + ...threadState.selectedCallNodePaths, + LOWER_WING: wingPath, + UPPER_WING: wingPath, + }, + expandedCallNodePaths: { + ...threadState.expandedCallNodePaths, + LOWER_WING: wingExpanded, + UPPER_WING: wingExpanded, + }, }); } case 'CHANGE_INVERT_CALLSTACK': { @@ -352,32 +383,34 @@ const viewOptionsPerThread: Reducer = ( expandedCallNodePaths.add(newSelectedCallNodePath.slice(0, i)); } - return invertCallstack - ? { - ...viewOptions, - selectedInvertedCallNodePath: newSelectedCallNodePath, - expandedInvertedCallNodePaths: expandedCallNodePaths, - } - : { - ...viewOptions, - selectedNonInvertedCallNodePath: newSelectedCallNodePath, - expandedNonInvertedCallNodePaths: expandedCallNodePaths, - }; + const area: CallNodeArea = invertCallstack + ? 'INVERTED_TREE' + : 'NON_INVERTED_TREE'; + return { + ...viewOptions, + selectedCallNodePaths: { + ...viewOptions.selectedCallNodePaths, + [area]: newSelectedCallNodePath, + }, + expandedCallNodePaths: { + ...viewOptions.expandedCallNodePaths, + [area]: expandedCallNodePaths, + }, + }; } return viewOptions; }); } case 'CHANGE_EXPANDED_CALL_NODES': { - const { threadsKey, isInverted } = action; + const { threadsKey, area } = action; const expandedCallNodePaths = new PathSet(action.expandedCallNodePaths); - - return _updateThreadViewOptions( - state, - threadsKey, - isInverted - ? { expandedInvertedCallNodePaths: expandedCallNodePaths } - : { expandedNonInvertedCallNodePaths: expandedCallNodePaths } - ); + const threadState = _getThreadViewOptions(state, threadsKey); + return _updateThreadViewOptions(state, threadsKey, { + expandedCallNodePaths: { + ...threadState.expandedCallNodePaths, + [area]: expandedCallNodePaths, + }, + }); } case 'CHANGE_SELECTED_NETWORK_MARKER': { const { threadsKey, selectedNetworkMarker } = action; @@ -413,27 +446,28 @@ const viewOptionsPerThread: Reducer = ( }; const threadViewOptions = _getThreadViewOptions(state, threadsKey); - const selectedNonInvertedCallNodePath = getFilteredPath( - threadViewOptions.selectedNonInvertedCallNodePath - ); - const selectedInvertedCallNodePath = getFilteredPath( - threadViewOptions.selectedInvertedCallNodePath - ); - const expandedNonInvertedCallNodePaths = getFilteredPathSet( - threadViewOptions.expandedNonInvertedCallNodePaths - ); - const expandedInvertedCallNodePaths = getFilteredPathSet( - threadViewOptions.expandedInvertedCallNodePaths - ); - const lastSeenTransformCount = threadViewOptions.lastSeenTransformCount + 1; return _updateThreadViewOptions(state, threadsKey, { - selectedNonInvertedCallNodePath, - selectedInvertedCallNodePath, - expandedNonInvertedCallNodePaths, - expandedInvertedCallNodePaths, + selectedCallNodePaths: { + ...threadViewOptions.selectedCallNodePaths, + NON_INVERTED_TREE: getFilteredPath( + threadViewOptions.selectedCallNodePaths.NON_INVERTED_TREE + ), + INVERTED_TREE: getFilteredPath( + threadViewOptions.selectedCallNodePaths.INVERTED_TREE + ), + }, + expandedCallNodePaths: { + ...threadViewOptions.expandedCallNodePaths, + NON_INVERTED_TREE: getFilteredPathSet( + threadViewOptions.expandedCallNodePaths.NON_INVERTED_TREE + ), + INVERTED_TREE: getFilteredPathSet( + threadViewOptions.expandedCallNodePaths.INVERTED_TREE + ), + }, lastSeenTransformCount, }); } @@ -441,11 +475,18 @@ const viewOptionsPerThread: Reducer = ( // Simply reset the stored paths until this bug is fixed: // https://github.com/firefox-devtools/profiler/issues/882 const { threadsKey } = action; + const threadState = _getThreadViewOptions(state, threadsKey); return _updateThreadViewOptions(state, threadsKey, { - selectedNonInvertedCallNodePath: [], - selectedInvertedCallNodePath: [], - expandedNonInvertedCallNodePaths: new PathSet(), - expandedInvertedCallNodePaths: new PathSet(), + selectedCallNodePaths: { + ...threadState.selectedCallNodePaths, + NON_INVERTED_TREE: [], + INVERTED_TREE: [], + }, + expandedCallNodePaths: { + ...threadState.expandedCallNodePaths, + NON_INVERTED_TREE: new PathSet(), + INVERTED_TREE: new PathSet(), + }, lastSeenTransformCount: 0, }); } @@ -458,8 +499,48 @@ const viewOptionsPerThread: Reducer = ( return state; } - const { transforms } = action.newUrlState.profileSpecific; - return objectMap(state, (viewOptions, threadsKey) => { + const { transforms, selectedFunctions } = + action.newUrlState.profileSpecific; + + // The selected function lives in URL state; mirror it into the per-thread + // wing paths so that initial loads and back/forward navigation restore the + // wings to the right function. + const newState: ThreadViewOptionsPerThreads = { ...state }; + for (const threadsKey of Object.keys(selectedFunctions)) { + const selectedFunctionIndex = selectedFunctions[threadsKey]; + const viewOptions = _getThreadViewOptions(newState, threadsKey); + const previousLowerWingPath = + viewOptions.selectedCallNodePaths.LOWER_WING; + const matchesExisting = + selectedFunctionIndex === null + ? previousLowerWingPath.length === 0 + : previousLowerWingPath.length === 1 && + previousLowerWingPath[0] === selectedFunctionIndex; + if (matchesExisting) { + continue; + } + const wingPath: CallNodePath = + selectedFunctionIndex !== null ? [selectedFunctionIndex] : []; + const wingExpanded = + selectedFunctionIndex !== null + ? new PathSet([[selectedFunctionIndex]]) + : new PathSet(); + newState[threadsKey] = { + ...viewOptions, + selectedCallNodePaths: { + ...viewOptions.selectedCallNodePaths, + LOWER_WING: wingPath, + UPPER_WING: wingPath, + }, + expandedCallNodePaths: { + ...viewOptions.expandedCallNodePaths, + LOWER_WING: wingExpanded, + UPPER_WING: wingExpanded, + }, + }; + } + + return objectMap(newState, (viewOptions, threadsKey) => { const transformStack = transforms[threadsKey] || []; const newTransformCount = transformStack.length; const oldTransformCount = viewOptions.lastSeenTransformCount; @@ -468,10 +549,16 @@ const viewOptionsPerThread: Reducer = ( if (newTransformCount < oldTransformCount) { return { ...viewOptions, - selectedNonInvertedCallNodePath: [], - selectedInvertedCallNodePath: [], - expandedNonInvertedCallNodePaths: new PathSet(), - expandedInvertedCallNodePaths: new PathSet(), + selectedCallNodePaths: { + ...viewOptions.selectedCallNodePaths, + NON_INVERTED_TREE: [], + INVERTED_TREE: [], + }, + expandedCallNodePaths: { + ...viewOptions.expandedCallNodePaths, + NON_INVERTED_TREE: new PathSet(), + INVERTED_TREE: new PathSet(), + }, lastSeenTransformCount: newTransformCount, }; } @@ -536,23 +623,25 @@ const viewOptionsPerThread: Reducer = ( }; const selectedNonInvertedCallNodePath = getUpdatedPath( - viewOptions.selectedNonInvertedCallNodePath + viewOptions.selectedCallNodePaths.NON_INVERTED_TREE ); const selectedInvertedCallNodePath = getUpdatedPath( - viewOptions.selectedInvertedCallNodePath - ); - const expandedNonInvertedCallNodePaths = getAncestorPathSet( - selectedNonInvertedCallNodePath - ); - const expandedInvertedCallNodePaths = getAncestorPathSet( - selectedInvertedCallNodePath + viewOptions.selectedCallNodePaths.INVERTED_TREE ); return _updateThreadViewOptions(state, threadsKey, { - selectedNonInvertedCallNodePath, - selectedInvertedCallNodePath, - expandedNonInvertedCallNodePaths, - expandedInvertedCallNodePaths, + selectedCallNodePaths: { + ...viewOptions.selectedCallNodePaths, + NON_INVERTED_TREE: selectedNonInvertedCallNodePath, + INVERTED_TREE: selectedInvertedCallNodePath, + }, + expandedCallNodePaths: { + ...viewOptions.expandedCallNodePaths, + NON_INVERTED_TREE: getAncestorPathSet( + selectedNonInvertedCallNodePath + ), + INVERTED_TREE: getAncestorPathSet(selectedInvertedCallNodePath), + }, }); } default: @@ -778,6 +867,7 @@ const rightClickedCallNode: Reducer = ( if (action.callNodePath !== null) { return { threadsKey: action.threadsKey, + area: action.area, callNodePath: action.callNodePath, }; } diff --git a/src/reducers/url-state.ts b/src/reducers/url-state.ts index f3902a4fff..8c4a3207f6 100644 --- a/src/reducers/url-state.ts +++ b/src/reducers/url-state.ts @@ -24,6 +24,9 @@ import type { IsOpenPerPanelState, TabID, SelectedMarkersPerThread, + SelectedFunctionsPerThread, + FunctionListSectionsOpenState, + WingViewsState, } from 'firefox-profiler/types'; import type { TabSlug } from '../app-logic/tabs-handling'; @@ -218,6 +221,42 @@ const functionListSort: Reducer = ( } }; +const FUNCTION_LIST_SECTIONS_OPEN_DEFAULT: FunctionListSectionsOpenState = { + descendants: true, + ancestors: false, + self: false, +}; + +const functionListSectionsOpen: Reducer = ( + state = FUNCTION_LIST_SECTIONS_OPEN_DEFAULT, + action +) => { + switch (action.type) { + case 'CHANGE_FUNCTION_LIST_SECTION_OPEN': + return { ...state, [action.section]: action.isOpen }; + default: + return state; + } +}; + +const WING_VIEWS_DEFAULT: WingViewsState = { + upper: 'flame-graph', + lower: 'flame-graph', + self: 'flame-graph', +}; + +const wingViews: Reducer = ( + state = WING_VIEWS_DEFAULT, + action +) => { + switch (action.type) { + case 'CHANGE_WING_VIEW': + return { ...state, [action.wing]: action.view }; + default: + return state; + } +}; + const networkSearchString: Reducer = (state = '', action) => { switch (action.type) { case 'CHANGE_NETWORK_SEARCH_STRING': @@ -789,6 +828,26 @@ const selectedMarkers: Reducer = ( } }; +const selectedFunctions: Reducer = ( + state = {}, + action +): SelectedFunctionsPerThread => { + switch (action.type) { + case 'CHANGE_SELECTED_FUNCTION': { + const { threadsKey, selectedFunctionIndex } = action; + if (state[threadsKey] === selectedFunctionIndex) { + return state; + } + return { + ...state, + [threadsKey]: selectedFunctionIndex, + }; + } + default: + return state; + } +}; + /** * These values are specific to an individual profile. */ @@ -818,8 +877,11 @@ const profileSpecific = combineReducers({ showJsTracerSummary, tabFilter, selectedMarkers, + selectedFunctions, markerTableSort, functionListSort, + functionListSectionsOpen, + wingViews, // The timeline tracks used to be hidden and sorted by thread indexes, rather than // track indexes. The only way to migrate this information to tracks-based data is to // first retrieve the profile, so they can't be upgraded by the normal url upgrading diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index 181868fac1..f6b2b88441 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -11,6 +11,7 @@ import * as ProfileData from '../../profile-logic/profile-data'; import * as StackTiming from '../../profile-logic/stack-timing'; import * as FlameGraph from '../../profile-logic/flame-graph'; import * as CallTree from '../../profile-logic/call-tree'; +import * as Transforms from '../../profile-logic/transforms'; import type { PathSet } from '../../utils/path'; import * as ProfileSelectors from '../profile'; import { getRightClickedCallNodeInfo } from '../right-clicked-call-node'; @@ -43,10 +44,15 @@ import type { State, CallNodeTableBitSet, IndexIntoFuncTable, + IndexIntoStackTable, + SamplesLikeTable, + SampleCategoriesAndSubcategories, + CallNodeArea, } from 'firefox-profiler/types'; import type { CallNodeInfo, CallNodeInfoInverted, + LowerWingCallNodeInfo, } from 'firefox-profiler/profile-logic/call-node-info'; import type { ThreadSelectorsPerThread } from './thread'; @@ -209,12 +215,66 @@ export function getStackAndSampleSelectorsPerThread( } ); - const getSelectedFunctionIndex: Selector = + const getSelectedFunctionIndex: Selector = ( + state + ) => UrlState.getSelectedFunction(state, threadsKey); + + const getUpperWingCallNodeInfo: Selector = createSelector( + _getNonInvertedCallNodeInfo, + getSelectedFunctionIndex, + (state: State) => threadSelectors.getFilteredThread(state).stackTable, + (state: State) => threadSelectors.getFilteredThread(state).frameTable, + (state: State) => threadSelectors.getFilteredThread(state).funcTable.length, + ProfileSelectors.getDefaultCategory, + ProfileData.createUpperWingCallNodeInfo + ); + + // The lower wing uses its own dedicated CallNodeInfo: a single inverted + // root for the selected function, with the entire subtree built eagerly + // from that function's entry points. See LowerWingCallNodeInfo in + // call-node-info.ts. + const getLowerWingCallNodeInfo: Selector = + createSelector( + _getNonInvertedCallNodeInfo, + ProfileSelectors.getDefaultCategory, + (state: State) => + threadSelectors.getFilteredThread(state).funcTable.length, + getSelectedFunctionIndex, + ProfileData.getLowerWingCallNodeInfo + ); + + // Combines a CallNodeInfo selector with a CallNodePath selector to produce + // the corresponding call node index. + const makeCallNodeIndexFromPathSelector = ( + callNodeInfoSel: Selector, + pathSel: Selector + ): Selector => + createSelector(callNodeInfoSel, pathSel, (callNodeInfo, callNodePath) => + callNodeInfo.getCallNodeIndexFromPath(callNodePath) + ); + + // Combines a CallNodeInfo selector with an expanded-paths selector to + // produce the corresponding array of call node indexes. + const makeExpandedIndexesSelector = ( + callNodeInfoSel: Selector, + pathsSel: Selector + ): Selector> => + createSelector(callNodeInfoSel, pathsSel, (callNodeInfo, callNodePaths) => + Array.from(callNodePaths).map((path) => + callNodeInfo.getCallNodeIndexFromPath(path) + ) + ); + + const getLowerWingSelectedCallNodePath: Selector = createSelector( threadSelectors.getViewOptions, - (threadViewOptions): IndexIntoFuncTable | null => { - return threadViewOptions.selectedFunctionIndex; - } + (threadViewOptions) => threadViewOptions.selectedCallNodePaths.LOWER_WING + ); + + const getUpperWingSelectedCallNodePath: Selector = + createSelector( + threadSelectors.getViewOptions, + (threadViewOptions) => threadViewOptions.selectedCallNodePaths.UPPER_WING ); const getSelectedCallNodePath: Selector = createSelector( @@ -222,37 +282,53 @@ export function getStackAndSampleSelectorsPerThread( UrlState.getInvertCallstack, (threadViewOptions, invertCallStack): CallNodePath => invertCallStack - ? threadViewOptions.selectedInvertedCallNodePath - : threadViewOptions.selectedNonInvertedCallNodePath + ? threadViewOptions.selectedCallNodePaths.INVERTED_TREE + : threadViewOptions.selectedCallNodePaths.NON_INVERTED_TREE ); - const getSelectedCallNodeIndex: Selector = - createSelector( - getCallNodeInfo, - getSelectedCallNodePath, - (callNodeInfo, callNodePath) => { - return callNodeInfo.getCallNodeIndexFromPath(callNodePath); - } - ); + const getSelectedCallNodeIndex = makeCallNodeIndexFromPathSelector( + getCallNodeInfo, + getSelectedCallNodePath + ); + const getLowerWingSelectedCallNodeIndex = makeCallNodeIndexFromPathSelector( + getLowerWingCallNodeInfo, + getLowerWingSelectedCallNodePath + ); + const getUpperWingSelectedCallNodeIndex = makeCallNodeIndexFromPathSelector( + getUpperWingCallNodeInfo, + getUpperWingSelectedCallNodePath + ); const getExpandedCallNodePaths: Selector = createSelector( threadSelectors.getViewOptions, UrlState.getInvertCallstack, (threadViewOptions, invertCallStack) => invertCallStack - ? threadViewOptions.expandedInvertedCallNodePaths - : threadViewOptions.expandedNonInvertedCallNodePaths + ? threadViewOptions.expandedCallNodePaths.INVERTED_TREE + : threadViewOptions.expandedCallNodePaths.NON_INVERTED_TREE ); - const getExpandedCallNodeIndexes: Selector< - Array - > = createSelector( + const getLowerWingExpandedCallNodePaths: Selector = createSelector( + threadSelectors.getViewOptions, + (threadViewOptions) => threadViewOptions.expandedCallNodePaths.LOWER_WING + ); + + const getUpperWingExpandedCallNodePaths: Selector = createSelector( + threadSelectors.getViewOptions, + (threadViewOptions) => threadViewOptions.expandedCallNodePaths.UPPER_WING + ); + + const getExpandedCallNodeIndexes = makeExpandedIndexesSelector( getCallNodeInfo, - getExpandedCallNodePaths, - (callNodeInfo, callNodePaths) => - Array.from(callNodePaths).map((path) => - callNodeInfo.getCallNodeIndexFromPath(path) - ) + getExpandedCallNodePaths + ); + const getLowerWingExpandedCallNodeIndexes = makeExpandedIndexesSelector( + getLowerWingCallNodeInfo, + getLowerWingExpandedCallNodePaths + ); + const getUpperWingExpandedCallNodeIndexes = makeExpandedIndexesSelector( + getUpperWingCallNodeInfo, + getUpperWingExpandedCallNodePaths ); const _getSampleIndexToNonInvertedCallNodeIndexForPreviewFilteredCtssThread: Selector< @@ -274,6 +350,27 @@ export function getStackAndSampleSelectorsPerThread( ProfileData.getSampleIndexToCallNodeIndex ); + const _getPreviewFilteredCtssSampleIndexToUpperWingCallNodeIndex: Selector< + Array + > = createSelector( + (state: State) => + threadSelectors.getPreviewFilteredCtssSamples(state).stack, + (state: State) => + getUpperWingCallNodeInfo(state).getStackIndexToNonInvertedCallNodeIndex(), + (sampleStacks, stackIndexToCallNodeIndex) => { + return sampleStacks.map((stackIndex: IndexIntoStackTable | null) => { + if (stackIndex === null) { + return null; + } + const callNodeIndex = stackIndexToCallNodeIndex[stackIndex]; + if (callNodeIndex === -1) { + return null; + } + return callNodeIndex; + }); + } + ); + const getSampleIndexToNonInvertedCallNodeIndexForFilteredThread: Selector< Array > = createSelector( @@ -357,12 +454,57 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getUpperWingCallNodeSelfAndSummary: Selector = + createSelector( + threadSelectors.getPreviewFilteredCtssSamples, + _getPreviewFilteredCtssSampleIndexToUpperWingCallNodeIndex, + getUpperWingCallNodeInfo, + getCallNodeSelfAndSummary, + ( + samples, + sampleIndexToCallNodeIndex, + callNodeInfo, + regularTreeSelfAndSummary + ) => { + const { rootTotalSummary } = regularTreeSelfAndSummary; + const upperWingSelfAndSummary = CallTree.computeCallNodeSelfAndSummary( + samples, + sampleIndexToCallNodeIndex, + callNodeInfo.getCallNodeTable().length + ); + const { callNodeSelf } = upperWingSelfAndSummary; + // Use the upper wing's own total as the flame graph scaling reference, + // so that the root node (the selected function) fills the full flame + // graph width. The rootTotalSummary from the regular tree is kept for + // percentage display, so tooltips show percentages relative to all + // filtered samples (e.g. "80%" if 800 of 1000 samples contain the + // selected function). + const flameGraphWidthTotal = upperWingSelfAndSummary.rootTotalSummary; + return { rootTotalSummary, callNodeSelf, flameGraphWidthTotal }; + } + ); + const getCallTreeTimings: Selector = createSelector( getCallNodeInfo, getCallNodeSelfAndSummary, CallTree.computeCallTreeTimings ); + const _getLowerWingCallTreeTimings: Selector = + createSelector( + getLowerWingCallNodeInfo, + getCallNodeSelfAndSummary, + getSelectedFunctionIndex, + CallTree.computeLowerWingTimings + ); + + const _getUpperWingCallTreeTimings: Selector = + createSelector( + getUpperWingCallNodeInfo, + getUpperWingCallNodeSelfAndSummary, + CallTree.computeCallTreeTimings + ); + const getCallTreeTimingsNonInverted: Selector = createSelector( getCallNodeInfo, @@ -416,6 +558,26 @@ export function getStackAndSampleSelectorsPerThread( ) ); + const getUpperWingCallTree: Selector = createSelector( + threadSelectors.getPreviewFilteredThread, + getUpperWingCallNodeInfo, + ProfileSelectors.getCategories, + threadSelectors.getPreviewFilteredCtssSamples, + _getUpperWingCallTreeTimings, + getWeightTypeForCallTree, + CallTree.getCallTree + ); + + const getLowerWingCallTree: Selector = createSelector( + threadSelectors.getPreviewFilteredThread, + getLowerWingCallNodeInfo, + ProfileSelectors.getCategories, + threadSelectors.getPreviewFilteredCtssSamples, + _getLowerWingCallTreeTimings, + getWeightTypeForCallTree, + CallTree.getCallTree + ); + const getSourceViewLineTimings: Selector = createSelector( getSourceViewStackLineInfo, threadSelectors.getPreviewFilteredCtssSamples, @@ -502,6 +664,190 @@ export function getStackAndSampleSelectorsPerThread( FlameGraph.getFlameGraphTiming ); + const _getUpperWingCallTreeTimingsNonInverted: Selector = + createSelector( + getUpperWingCallNodeInfo, + getUpperWingCallNodeSelfAndSummary, + CallTree.computeCallTreeTimingsNonInverted + ); + + const getUpperWingFlameGraphRows: Selector = + createSelector( + (state: State) => getUpperWingCallNodeInfo(state).getCallNodeTable(), + (state: State) => + threadSelectors.getPreviewFilteredThread(state).funcTable, + (state: State) => + threadSelectors.getPreviewFilteredThread(state).stringTable, + FlameGraph.computeFlameGraphRows + ); + + const getUpperWingFlameGraphTiming: Selector = + createSelector( + getUpperWingFlameGraphRows, + (state: State) => getUpperWingCallNodeInfo(state).getCallNodeTable(), + _getUpperWingCallTreeTimingsNonInverted, + FlameGraph.getFlameGraphTiming + ); + + // Lower wing flame graph: rendered top-down (icicle). The cells are + // InvertedCallNodeHandle values that map back to LowerWingCallNodeInfo. + // The returned object is stateful — it extends the lower-wing CNI and + // computes per-row timing on demand as the Canvas scrolls — so the selector + // is keyed only on inputs that change when the underlying tree must be + // rebuilt. Treat the object's mutations as invisible (no caller observes + // anything but `getRow(depth)` results, which are write-once / additive). + const getLowerWingFlameGraphTiming: Selector = + createSelector( + getLowerWingCallNodeInfo, + getCallNodeSelfAndSummary, + _getCallNodeTable, + getSelectedFunctionIndex, + (state: State) => + threadSelectors.getPreviewFilteredThread(state).funcTable, + (state: State) => + threadSelectors.getPreviewFilteredThread(state).stringTable, + FlameGraph.createLowerWingFlameGraphTiming + ); + + // The max depth equals the deepest entry point's non-inverted depth (an entry + // at non-inverted depth D contributes D ancestor steps above the inverted + // root). We can read this off the non-inverted call node table without + // building the lower-wing tree, so the scroll-area height is available before + // any rows are computed. + const getLowerWingFlameGraphMaxDepthPlusOne: Selector = + createSelector( + _getCallNodeTable, + getSelectedFunctionIndex, + ProfileData.computeLowerWingMaxDepthPlusOne + ); + + // Self wing: focusSelf(rangeAndTransformFilteredThread, selectedFunc, implFilter) + // This uses the thread BEFORE the implementation filter so that native frames + // that are "inside" the selected function's self time are visible even when + // the implementation filter is set to "JS only". + const getSelfWingThread: Selector = createSelector( + threadSelectors.getRangeAndTransformFilteredThread, + getSelectedFunctionIndex, + UrlState.getImplementationFilter, + (thread, funcIndex, implFilter) => { + if (funcIndex === null) { + return thread; + } + return Transforms.focusSelf(thread, funcIndex, implFilter); + } + ); + + const _getSelfWingCallNodeInfo: Selector = createSelector( + (state: State) => getSelfWingThread(state).stackTable, + (state: State) => getSelfWingThread(state).frameTable, + ProfileSelectors.getDefaultCategory, + ProfileData.getCallNodeInfo + ); + + const _getSelfWingCtssSamples: Selector = createSelector( + getSelfWingThread, + threadSelectors.getCallTreeSummaryStrategy, + CallTree.extractSamplesLikeTable + ); + + const _getSelfWingSampleIndexToCallNodeIndex: Selector< + Array + > = createSelector( + (state: State) => _getSelfWingCtssSamples(state).stack, + (state: State) => + _getSelfWingCallNodeInfo(state).getStackIndexToNonInvertedCallNodeIndex(), + ProfileData.getSampleIndexToCallNodeIndex + ); + + const _getSelfWingCallNodeSelfAndSummary: Selector = + createSelector( + _getSelfWingCtssSamples, + _getSelfWingSampleIndexToCallNodeIndex, + (state: State) => + _getSelfWingCallNodeInfo(state).getCallNodeTable().length, + getCallNodeSelfAndSummary, + ( + samples, + sampleIndexToCallNodeIndex, + callNodeCount, + regularTreeSelfAndSummary + ) => { + const selfWingSelfAndSummary = CallTree.computeCallNodeSelfAndSummary( + samples, + sampleIndexToCallNodeIndex, + callNodeCount + ); + // Keep flameGraphWidthTotal as the self wing's own total so the + // root fills the full flame graph width. Override rootTotalSummary with + // the regular tree's value so tooltips show percentages relative to all + // filtered samples. + return { + callNodeSelf: selfWingSelfAndSummary.callNodeSelf, + flameGraphWidthTotal: selfWingSelfAndSummary.rootTotalSummary, + rootTotalSummary: regularTreeSelfAndSummary.rootTotalSummary, + }; + } + ); + + const _getSelfWingCallTreeTimings: Selector = + createSelector( + _getSelfWingCallNodeInfo, + _getSelfWingCallNodeSelfAndSummary, + CallTree.computeCallTreeTimings + ); + + const _getSelfWingCallTreeTimingsNonInverted: Selector = + createSelector( + _getSelfWingCallNodeInfo, + _getSelfWingCallNodeSelfAndSummary, + CallTree.computeCallTreeTimingsNonInverted + ); + + const getSelfWingCallTree: Selector = createSelector( + getSelfWingThread, + _getSelfWingCallNodeInfo, + ProfileSelectors.getCategories, + _getSelfWingCtssSamples, + _getSelfWingCallTreeTimings, + getWeightTypeForCallTree, + CallTree.getCallTree + ); + + const _getSelfWingFlameGraphRows: Selector = + createSelector( + (state: State) => _getSelfWingCallNodeInfo(state).getCallNodeTable(), + (state: State) => getSelfWingThread(state).funcTable, + (state: State) => getSelfWingThread(state).stringTable, + FlameGraph.computeFlameGraphRows + ); + + const getSelfWingFlameGraphTiming: Selector = + createSelector( + _getSelfWingFlameGraphRows, + (state: State) => _getSelfWingCallNodeInfo(state).getCallNodeTable(), + _getSelfWingCallTreeTimingsNonInverted, + FlameGraph.getFlameGraphTiming + ); + + const getSelfWingCallNodeMaxDepthPlusOne: Selector = createSelector( + (state: State) => _getSelfWingCallNodeInfo(state).getCallNodeTable(), + (callNodeTable) => callNodeTable.maxDepth + 1 + ); + + const getSelfWingCallNodeInfo: Selector = + _getSelfWingCallNodeInfo; + + const getSelfWingCtssSamples: Selector = + _getSelfWingCtssSamples; + + const getSelfWingCtssSampleCategoriesAndSubcategories: Selector = + createSelector( + getSelfWingThread, + _getSelfWingCtssSamples, + ProfileSelectors.getDefaultCategory, + CallTree.computeUnfilteredCtssSampleCategoriesAndSubcategories + ); + const getRightClickedCallNodeIndex: Selector = createSelector( getRightClickedCallNodeInfo, @@ -510,16 +856,68 @@ export function getStackAndSampleSelectorsPerThread( if ( rightClickedCallNodeInfo !== null && threadsKey === rightClickedCallNodeInfo.threadsKey + ) { + const expectedArea = callNodeInfo.isInverted() + ? 'INVERTED_TREE' + : 'NON_INVERTED_TREE'; + if (rightClickedCallNodeInfo.area === expectedArea) { + return callNodeInfo.getCallNodeIndexFromPath( + rightClickedCallNodeInfo.callNodePath + ); + } + } + + return null; + } + ); + + // Returns the right-clicked call node index in `area` for this thread, or + // null if the right-clicked call node belongs to a different thread or area. + const makeAreaRightClickedCallNodeIndexSelector = ( + area: CallNodeArea, + callNodeInfoSel: Selector + ): Selector => + createSelector( + getRightClickedCallNodeInfo, + callNodeInfoSel, + (rightClickedCallNodeInfo, callNodeInfo) => { + if ( + rightClickedCallNodeInfo !== null && + rightClickedCallNodeInfo.threadsKey === threadsKey && + rightClickedCallNodeInfo.area === area ) { return callNodeInfo.getCallNodeIndexFromPath( rightClickedCallNodeInfo.callNodePath ); } - return null; } ); + const getLowerWingRightClickedCallNodeIndex = + makeAreaRightClickedCallNodeIndexSelector( + 'LOWER_WING', + getLowerWingCallNodeInfo + ); + + const getUpperWingRightClickedCallNodeIndex = + makeAreaRightClickedCallNodeIndexSelector( + 'UPPER_WING', + getUpperWingCallNodeInfo + ); + + const getLowerWingRightClickedFuncIndex: Selector = + createSelector( + getLowerWingRightClickedCallNodeIndex, + getLowerWingCallNodeInfo, + (callNodeIndex, callNodeInfo) => { + if (callNodeIndex === null) { + return null; + } + return callNodeInfo.funcForNode(callNodeIndex); + } + ); + const getRightClickedFunctionIndex: Selector = createSelector( ProfileSelectors.getProfileViewOptions, @@ -541,14 +939,24 @@ export function getStackAndSampleSelectorsPerThread( unfilteredSamplesRange, getWeightTypeForCallTree, getCallNodeInfo, + getLowerWingCallNodeInfo, + getUpperWingCallNodeInfo, getSourceViewStackLineInfo, getAssemblyViewNativeSymbolIndex, getAssemblyViewStackAddressInfo, getSelectedCallNodePath, getSelectedCallNodeIndex, + getLowerWingSelectedCallNodePath, + getLowerWingSelectedCallNodeIndex, + getUpperWingSelectedCallNodePath, + getUpperWingSelectedCallNodeIndex, getSelectedFunctionIndex, getExpandedCallNodePaths, getExpandedCallNodeIndexes, + getLowerWingExpandedCallNodePaths, + getLowerWingExpandedCallNodeIndexes, + getUpperWingExpandedCallNodePaths, + getUpperWingExpandedCallNodeIndexes, getSampleIndexToNonInvertedCallNodeIndexForFilteredThread, getSampleSelectedStatesInFilteredThread, getSampleSelectedStatesForFunctionListTab, @@ -556,6 +964,18 @@ export function getStackAndSampleSelectorsPerThread( getCallTree, getFunctionListTree, getFunctionListTimings, + getLowerWingCallTree, + getUpperWingCallTree, + getUpperWingFlameGraphTiming, + getLowerWingFlameGraphTiming, + getLowerWingFlameGraphMaxDepthPlusOne, + getSelfWingThread, + getSelfWingCallNodeInfo, + getSelfWingCallTree, + getSelfWingFlameGraphTiming, + getSelfWingCallNodeMaxDepthPlusOne, + getSelfWingCtssSamples, + getSelfWingCtssSampleCategoriesAndSubcategories, getSourceViewLineTimings, getAssemblyViewAddressTimings, getTracedTiming, @@ -566,5 +986,8 @@ export function getStackAndSampleSelectorsPerThread( getFlameGraphTiming, getRightClickedCallNodeIndex, getRightClickedFunctionIndex, + getLowerWingRightClickedCallNodeIndex, + getLowerWingRightClickedFuncIndex, + getUpperWingRightClickedCallNodeIndex, }; } diff --git a/src/selectors/right-clicked-call-node.tsx b/src/selectors/right-clicked-call-node.tsx index a72dc73d35..f793e4df86 100644 --- a/src/selectors/right-clicked-call-node.tsx +++ b/src/selectors/right-clicked-call-node.tsx @@ -9,10 +9,12 @@ import type { ThreadsKey, CallNodePath, Selector, + CallNodeArea, } from 'firefox-profiler/types'; export type RightClickedCallNodeInfo = { readonly threadsKey: ThreadsKey; + readonly area: CallNodeArea; readonly callNodePath: CallNodePath; }; diff --git a/src/selectors/url-state.ts b/src/selectors/url-state.ts index 5fa38566ce..d5765a52fd 100644 --- a/src/selectors/url-state.ts +++ b/src/selectors/url-state.ts @@ -32,6 +32,10 @@ import type { TabID, IndexIntoSourceTable, MarkerIndex, + IndexIntoFuncTable, + FunctionListSectionsOpenState, + WingViewsState, + WingViewType, } from 'firefox-profiler/types'; import type { TabSlug } from '../app-logic/tabs-handling'; @@ -122,6 +126,17 @@ export const getMarkerTableSort: Selector = (state) => getProfileSpecificState(state).markerTableSort; export const getFunctionListSort: Selector = (state) => getProfileSpecificState(state).functionListSort; +export const getFunctionListSectionsOpen: Selector< + FunctionListSectionsOpenState +> = (state) => getProfileSpecificState(state).functionListSectionsOpen; +export const getWingViews: Selector = (state) => + getProfileSpecificState(state).wingViews; +export const getUpperWingView: Selector = (state) => + getWingViews(state).upper; +export const getLowerWingView: Selector = (state) => + getWingViews(state).lower; +export const getSelfWingView: Selector = (state) => + getWingViews(state).self; export const getNetworkSearchString: Selector = (state) => getProfileSpecificState(state).networkSearchString; export const getSelectedTab: Selector = (state) => @@ -250,6 +265,12 @@ export const getSelectedMarker: DangerousSelectorWithArguments< > = (state, threadsKey) => getProfileSpecificState(state).selectedMarkers[threadsKey] ?? null; +export const getSelectedFunction: DangerousSelectorWithArguments< + IndexIntoFuncTable | null, + ThreadsKey +> = (state, threadsKey) => + getProfileSpecificState(state).selectedFunctions[threadsKey] ?? null; + export const getIsBottomBoxOpen: Selector = (state) => { const tab = getSelectedTab(state); return getProfileSpecificState(state).isBottomBoxOpenPerPanel[tab]; diff --git a/src/test/components/FunctionListContextMenu.test.tsx b/src/test/components/FunctionListContextMenu.test.tsx index 6f85957870..019b0a8936 100644 --- a/src/test/components/FunctionListContextMenu.test.tsx +++ b/src/test/components/FunctionListContextMenu.test.tsx @@ -6,7 +6,7 @@ import { Provider } from 'react-redux'; import copy from 'copy-to-clipboard'; import { render, screen } from 'firefox-profiler/test/fixtures/testing-library'; -import { FunctionListContextMenu } from '../../components/shared/FunctionListContextMenu'; +import { FunctionListContextMenu } from '../../components/shared/WingContextMenu'; import { storeWithProfile } from '../fixtures/stores'; import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; import { fireFullClick } from '../fixtures/utils'; diff --git a/src/test/components/LowerWingContextMenu.test.tsx b/src/test/components/LowerWingContextMenu.test.tsx new file mode 100644 index 0000000000..244db89b31 --- /dev/null +++ b/src/test/components/LowerWingContextMenu.test.tsx @@ -0,0 +1,119 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Provider } from 'react-redux'; + +import { render, screen } from 'firefox-profiler/test/fixtures/testing-library'; +import { LowerWingContextMenu } from '../../components/shared/WingContextMenu'; +import { storeWithProfile } from '../fixtures/stores'; +import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; +import { fireFullClick } from '../fixtures/utils'; +import { + changeWingRightClickedCallNode, + changeSelectedFunctionIndex, + setContextMenuVisibility, +} from '../../actions/profile-view'; +import { selectedThreadSelectors } from '../../selectors/per-thread'; +import { ensureExists } from '../../utils/types'; + +describe('LowerWingContextMenu', function () { + // Samples: A->B->C, A->E->C + // When C is selected, the lower wing (inverted) tree shows: + // C (root/self function) + // B (caller of C) + // A + // E (caller of C) + // A + // + // Right-clicking B (an inverted child = caller) should give a context menu + // for B, not C. + function createStore() { + const { + profile, + funcNamesDictPerThread: [{ B, C }], + } = getProfileFromTextSamples(` + A A + B E + C C + `); + const store = storeWithProfile(profile); + + // The lower wing only exists when a function is selected. Select C so the + // lower wing tree is built with C as the root. + const threadsKey = 0; + store.dispatch(changeSelectedFunctionIndex(threadsKey, C)); + // The inverted call node path for B-as-caller-of-C is [C, B]. + store.dispatch(changeWingRightClickedCallNode('lower', threadsKey, [C, B])); + return store; + } + + function setup(store = createStore()) { + store.dispatch(setContextMenuVisibility(true)); + const renderResult = render( + + + + ); + return { ...renderResult, getState: store.getState }; + } + + describe('basic rendering', function () { + it('does not render when no node is right-clicked', () => { + const store = storeWithProfile(getProfileFromTextSamples('A').profile); + store.dispatch(setContextMenuVisibility(true)); + const { container } = render( + + + + ); + expect(container.querySelector('.react-contextmenu')).toBeNull(); + }); + + it('renders a context menu when a node is right-clicked', () => { + const { container } = setup(); + expect( + ensureExists( + container.querySelector('.react-contextmenu'), + `Couldn't find the context menu root component .react-contextmenu` + ).children.length > 1 + ).toBeTruthy(); + }); + + it('does not include call-node-specific transforms', () => { + setup(); + expect(screen.queryByText(/Merge node only/)).not.toBeInTheDocument(); + expect( + screen.queryByText(/Focus on subtree only/) + ).not.toBeInTheDocument(); + expect(screen.queryByText(/Expand all/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Copy stack/)).not.toBeInTheDocument(); + }); + }); + + describe('clicking on transforms', function () { + it('applies transforms to function B, not to the selected function C', function () { + const { getState } = setup(); + fireFullClick(screen.getByText(/Merge function/)); + const transform = + selectedThreadSelectors.getTransformStack(getState())[0]; + const { + funcNamesDictPerThread: [{ B }], + } = getProfileFromTextSamples(` + A A + B E + C C + `); + // The transform should target B (the right-clicked caller), not C (the root). + expect(transform).toMatchObject({ type: 'merge-function', funcIndex: B }); + }); + + it('adds a focus-function transform for the right-clicked node', function () { + const { getState } = setup(); + fireFullClick(screen.getByText(/Focus on function/)); + expect( + selectedThreadSelectors.getTransformStack(getState())[0].type + ).toBe('focus-function'); + }); + }); +}); diff --git a/src/test/store/__snapshots__/profile-view.test.ts.snap b/src/test/store/__snapshots__/profile-view.test.ts.snap index 91af6cf8b9..49c5b2cb0d 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -4417,27 +4417,38 @@ Process: \\"default\\" (0)" exports[`snapshots of selectors/profile matches the last stored run of selectedThreadSelector.getViewOptions 1`] = ` Object { - "expandedInvertedCallNodePaths": PathSet { - "_table": Map {}, - }, - "expandedNonInvertedCallNodePaths": PathSet { - "_table": Map { - "0" => Array [ - 0, - ], - "0-1" => Array [ - 0, - 1, - ], + "expandedCallNodePaths": Object { + "INVERTED_TREE": PathSet { + "_table": Map {}, + }, + "LOWER_WING": PathSet { + "_table": Map {}, + }, + "NON_INVERTED_TREE": PathSet { + "_table": Map { + "0" => Array [ + 0, + ], + "0-1" => Array [ + 0, + 1, + ], + }, + }, + "UPPER_WING": PathSet { + "_table": Map {}, }, }, "lastSeenTransformCount": 1, - "selectedFunctionIndex": null, - "selectedInvertedCallNodePath": Array [], + "selectedCallNodePaths": Object { + "INVERTED_TREE": Array [], + "LOWER_WING": Array [], + "NON_INVERTED_TREE": Array [ + 0, + 1, + ], + "UPPER_WING": Array [], + }, "selectedNetworkMarker": null, - "selectedNonInvertedCallNodePath": Array [ - 0, - 1, - ], } `; diff --git a/src/test/store/profile-view.test.ts b/src/test/store/profile-view.test.ts index 9fda33ec65..40793ddd1b 100644 --- a/src/test/store/profile-view.test.ts +++ b/src/test/store/profile-view.test.ts @@ -3601,6 +3601,7 @@ describe('right clicked call node info', () => { expect(getRightClickedCallNodeInfo(getState())).toEqual({ threadsKey: 0, + area: 'NON_INVERTED_TREE', callNodePath: [0, 1], }); }); @@ -3612,6 +3613,7 @@ describe('right clicked call node info', () => { expect(getRightClickedCallNodeInfo(getState())).toEqual({ threadsKey: 0, + area: 'NON_INVERTED_TREE', callNodePath: [0, 1], }); diff --git a/src/types/actions.ts b/src/types/actions.ts index 9ceb650bc5..f4d4c5fa3c 100644 --- a/src/types/actions.ts +++ b/src/types/actions.ts @@ -47,6 +47,7 @@ import type { ApiQueryError, TableViewOptions, DecodedInstruction, + CallNodeArea, } from './state'; import type { CssPixels, StartEndRange, Milliseconds } from './units'; import type { BrowserConnectionStatus } from '../app-logic/browser-connection'; @@ -187,7 +188,7 @@ type ProfileAction = } | { readonly type: 'CHANGE_SELECTED_CALL_NODE'; - readonly isInverted: boolean; + readonly area: CallNodeArea; readonly threadsKey: ThreadsKey; readonly selectedCallNodePath: CallNodePath; readonly optionalExpandedToCallNodePath: CallNodePath | undefined; @@ -207,6 +208,7 @@ type ProfileAction = | { readonly type: 'CHANGE_RIGHT_CLICKED_CALL_NODE'; readonly threadsKey: ThreadsKey; + readonly area: CallNodeArea; readonly callNodePath: CallNodePath | null; } | { @@ -220,7 +222,7 @@ type ProfileAction = | { readonly type: 'CHANGE_EXPANDED_CALL_NODES'; readonly threadsKey: ThreadsKey; - readonly isInverted: boolean; + readonly area: CallNodeArea; readonly expandedCallNodePaths: Array; } | { @@ -579,6 +581,16 @@ type UrlStateAction = readonly type: 'CHANGE_FUNCTION_LIST_SORT'; readonly sort: SingleColumnSortState[]; } + | { + readonly type: 'CHANGE_FUNCTION_LIST_SECTION_OPEN'; + readonly section: 'descendants' | 'ancestors' | 'self'; + readonly isOpen: boolean; + } + | { + readonly type: 'CHANGE_WING_VIEW'; + readonly wing: 'upper' | 'lower' | 'self'; + readonly view: 'flame-graph' | 'call-tree'; + } | { readonly type: 'CHANGE_NETWORK_SEARCH_STRING'; readonly searchString: string; diff --git a/src/types/state.ts b/src/types/state.ts index a73b8b4772..436221146c 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -57,12 +57,10 @@ export type SourceMapSymbolicationStatus = | 'INACTIVE' | 'FETCHING' | 'SYMBOLICATING'; + export type ThreadViewOptions = { - readonly selectedNonInvertedCallNodePath: CallNodePath; - readonly selectedInvertedCallNodePath: CallNodePath; - readonly expandedNonInvertedCallNodePaths: PathSet; - readonly expandedInvertedCallNodePaths: PathSet; - readonly selectedFunctionIndex: IndexIntoFuncTable | null; + readonly selectedCallNodePaths: Record; + readonly expandedCallNodePaths: Record; readonly selectedNetworkMarker: MarkerIndex | null; // Track the number of transforms to detect when they change via browser // navigation. This helps us know when to reset paths that may be invalid @@ -80,8 +78,20 @@ export type TableViewOptions = { export type TableViewOptionsPerTab = { [K in TabSlug]: TableViewOptions }; +export type CallNodeArea = + | 'NON_INVERTED_TREE' + | 'INVERTED_TREE' + | 'LOWER_WING' + | 'UPPER_WING'; + +// State-bearing function-list wings: each has its own selected/expanded/ +// right-clicked call node path. The self wing has no such state of its own +// (it shares it with the main tree). +export type WingName = 'upper' | 'lower'; + export type RightClickedCallNode = { readonly threadsKey: ThreadsKey; + readonly area: CallNodeArea; readonly callNodePath: CallNodePath; }; @@ -99,6 +109,10 @@ export type SelectedMarkersPerThread = { [key: ThreadsKey]: MarkerIndex | null; }; +export type SelectedFunctionsPerThread = { + [key: ThreadsKey]: IndexIntoFuncTable | null; +}; + /** * Profile view state */ @@ -392,8 +406,25 @@ export type ProfileSpecificUrlState = { legacyThreadOrder: ThreadIndex[] | null; legacyHiddenThreads: ThreadIndex[] | null; selectedMarkers: SelectedMarkersPerThread; + selectedFunctions: SelectedFunctionsPerThread; markerTableSort: SingleColumnSortState[]; functionListSort: SingleColumnSortState[]; + functionListSectionsOpen: FunctionListSectionsOpenState; + wingViews: WingViewsState; +}; + +export type FunctionListSectionsOpenState = { + descendants: boolean; + ancestors: boolean; + self: boolean; +}; + +export type WingViewType = 'flame-graph' | 'call-tree'; + +export type WingViewsState = { + upper: WingViewType; + lower: WingViewType; + self: WingViewType; }; export type UrlState = {