From 56a49d6e924eeccaba3232b2b9381cbbaa7159cd Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Wed, 24 Jun 2026 16:24:30 -0400 Subject: [PATCH 01/19] Move the flame graph tooltip percentage computation into FlameGraphTiming. The Canvas computed the tooltip percentage itself, by subtracting the box's start from its end. That only works because the non-inverted flame graph happens to lay boxes out relative to rootTotalSummary. Move the computation behind a getRatioOfRootTotalSummary() method on FlameGraphTiming so that each implementation can decide how its box widths relate to the percentage it wants to display. --- src/components/flame-graph/Canvas.tsx | 8 +++---- src/profile-logic/flame-graph.ts | 33 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/components/flame-graph/Canvas.tsx b/src/components/flame-graph/Canvas.tsx index c7b042fa0f..caf03dd00a 100644 --- a/src/components/flame-graph/Canvas.tsx +++ b/src/components/flame-graph/Canvas.tsx @@ -419,10 +419,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 { total } = getSelfAndTotalForCallNode( diff --git a/src/profile-logic/flame-graph.ts b/src/profile-logic/flame-graph.ts index 24f8347c5a..46eb4d6aa6 100644 --- a/src/profile-logic/flame-graph.ts +++ b/src/profile-logic/flame-graph.ts @@ -52,6 +52,25 @@ export interface FlameGraphTiming { readonly rowCount: number; getRow(depth: number): FlameGraphTimingRow; getAllRowsForTesting(): FlameGraphTimingRow[]; + + // 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; +} + +// Returns the width of a box as a fraction of the full flame graph width. +function _getBoxWidth( + flameGraphTiming: FlameGraphTiming, + depth: number, + indexInRow: number +): number { + const row = flameGraphTiming.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}` + ); + } + return row.end[indexInRow] - row.start[indexInRow]; } class FlameGraphTimingNonInverted implements FlameGraphTiming { @@ -104,6 +123,12 @@ class FlameGraphTimingNonInverted implements 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 { + return _getBoxWidth(this, depth, indexInRow); + } + _buildNextTimingRow(): void { const { total, self, rootTotalSummary } = this._callTreeTimings; const { prefix } = this._callNodeTable; @@ -435,6 +460,14 @@ class FlameGraphTimingInverted implements 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. In the inverted flame + // graph, box widths are computed relative to rootTotalSummary, so the box + // width is already the ratio we want. + getRatioOfRootTotalSummary(depth: number, indexInRow: number): number { + return _getBoxWidth(this, depth, indexInRow); + } + _compareCallNodesByFuncName = ( a: IndexIntoCallNodeTable, b: IndexIntoCallNodeTable From 76c9da5b3fc12f40c85ca29ec099873d2edf89a2 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:56:11 -0400 Subject: [PATCH 02/19] Let the flame graph use a width reference other than rootTotalSummary. The flame graph currently derives box widths from rootTotalSummary, which also serves as the 100% reference for the displayed percentages. The upcoming function list wings need those to differ: a wing's root should fill the full width, while percentages should stay relative to all filtered samples. Add a separate flameGraphWidthTotal to CallTreeTimings for the width reference. It equals rootTotalSummary everywhere so far, so nothing changes yet. --- src/profile-logic/call-tree.ts | 17 ++++++++++++++--- src/profile-logic/flame-graph.ts | 10 ++++++---- .../__snapshots__/profile-view.test.ts.snap | 1 + src/test/unit/profile-tree.test.ts | 1 + src/types/profile-derived.ts | 6 ++++++ 5 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/profile-logic/call-tree.ts b/src/profile-logic/call-tree.ts index f870e0f703..0bd8f5ece6 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -51,6 +51,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 }; @@ -723,7 +724,11 @@ export function computeCallNodeSelfAndSummary( rootTotalSummary += abs(callNodeSelf[callNodeIndex]); } - return { callNodeSelf, rootTotalSummary }; + return { + callNodeSelf, + rootTotalSummary, + flameGraphWidthTotal: rootTotalSummary, + }; } export function getSelfAndTotalForCallNode( @@ -894,7 +899,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); @@ -932,6 +938,7 @@ export function computeCallTreeTimingsNonInverted( total: callNodeTotal, callNodeHasChildren, rootTotalSummary, + flameGraphWidthTotal, }; } @@ -1334,5 +1341,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 46eb4d6aa6..a25b674804 100644 --- a/src/profile-logic/flame-graph.ts +++ b/src/profile-logic/flame-graph.ts @@ -126,11 +126,13 @@ class FlameGraphTimingNonInverted implements 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. getRatioOfRootTotalSummary(depth: number, indexInRow: number): number { - return _getBoxWidth(this, depth, indexInRow); + const ratioOfFullWidth = _getBoxWidth(this, depth, indexInRow); + 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; @@ -167,8 +169,8 @@ class FlameGraphTimingNonInverted implements 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 702b836990..8ae46ca333 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -2362,6 +2362,7 @@ CallTree { 0, 0, ], + "flameGraphWidthTotal": 2, "rootTotalSummary": 2, "self": Float64Array [ 0, diff --git a/src/test/unit/profile-tree.test.ts b/src/test/unit/profile-tree.test.ts index 48c82e95e1..d535aadd59 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 10fbae4239..6d7b2fc41f 100644 --- a/src/types/profile-derived.ts +++ b/src/types/profile-derived.ts @@ -877,6 +877,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 1f1db57867624364541c7ef42c65175ea4335998 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sat, 17 Oct 2026 16:24:50 -0400 Subject: [PATCH 03/19] Skip the node-based git hooks when node is not on the PATH. Netlify reuses a cached checkout in which husky has set core.hooksPath. Its 'preparing repo' stage runs git checkout before node is installed, so the post-checkout hook failed with exit code 127 and aborted the deploy preview. --- .husky/post-checkout | 1 + .husky/post-merge | 1 + .husky/post-rewrite | 1 + 3 files changed, 3 insertions(+) diff --git a/.husky/post-checkout b/.husky/post-checkout index 2ba14899e1..026b537429 100644 --- a/.husky/post-checkout +++ b/.husky/post-checkout @@ -1 +1,2 @@ +command -v node >/dev/null 2>&1 || exit 0 node bin/post-checkout.js "$@" diff --git a/.husky/post-merge b/.husky/post-merge index 5fb14644f9..080461e6ca 100644 --- a/.husky/post-merge +++ b/.husky/post-merge @@ -1 +1,2 @@ +command -v node >/dev/null 2>&1 || exit 0 node bin/post-merge.js diff --git a/.husky/post-rewrite b/.husky/post-rewrite index 74dff5cf18..d0fd1aad36 100644 --- a/.husky/post-rewrite +++ b/.husky/post-rewrite @@ -1 +1,2 @@ +command -v node >/dev/null 2>&1 || exit 0 node bin/post-rewrite.js "$@" From 3b8527d22e8848da9b9a07fac54fe7c8cea5ea95 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:51:35 -0400 Subject: [PATCH 04/19] Add a function list panel. Add a new "Function List" tab which shows a flat, sortable list of functions rather than a tree. Selection is per-function instead of per-call-node, so this adds a per-thread selected function to the URL state, along with the URL handling, reducers and selectors that go with it. Selecting a function replaces the current history entry instead of pushing a new one. Otherwise, holding the down arrow key in the list would flood the back/forward history and get rate-limited by the browser. --- locales/en-US/app.ftl | 1 + res/css/style.css | 3 +- src/actions/profile-view.ts | 49 ++++ src/app-logic/tabs-handling.ts | 2 + src/app-logic/url-handling.ts | 92 +++++++ src/components/app/Details.tsx | 2 + src/components/calltree/CallTree.css | 6 +- src/components/calltree/CallTree.tsx | 4 - src/components/calltree/FunctionList.tsx | 250 ++++++++++++++++++ .../calltree/ProfileFunctionListView.tsx | 20 ++ src/components/calltree/columns.ts | 44 +++ src/components/sidebar/index.tsx | 1 + src/profile-logic/call-tree.ts | 38 ++- src/profile-logic/symbolication.ts | 15 ++ src/reducers/profile-view.ts | 49 ++++ src/reducers/url-state.ts | 70 ++++- src/selectors/per-thread/stack-sample.ts | 24 ++ src/selectors/url-state.ts | 10 + src/test/components/Details.test.tsx | 3 + src/test/components/DetailsContainer.test.tsx | 1 + src/test/store/bottom-box.test.ts | 27 ++ src/test/store/icons.test.ts | 1 + src/test/store/symbolication.test.ts | 23 ++ src/test/store/useful-tabs.test.ts | 4 + 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 | 13 + src/utils/types.ts | 1 + 30 files changed, 813 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 5a1811bd72..a2478d7ec7 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -940,6 +940,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 56cb1adc4b..9da70090ae 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,14 @@ import type { TableViewOptions, SelectionContext, BottomBoxInfo, + IndexIntoFuncTable, } 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'; @@ -131,6 +134,32 @@ export function changeSelectedCallNode( }; } +/** + * 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' } +): 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())); + }; +} + /** * 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 @@ -147,6 +176,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. @@ -1624,6 +1664,15 @@ export function changeMarkerTableSort( }; } +export function changeFunctionListSort( + sort: SingleColumnSortState[] | null +): 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 60c6b32a4c..1d3a03ff43 100644 --- a/src/app-logic/url-handling.ts +++ b/src/app-logic/url-handling.ts @@ -42,6 +42,7 @@ import type { IndexIntoFrameTable, MarkerIndex, SelectedMarkersPerThread, + SelectedFunctionsPerThread, } from 'firefox-profiler/types'; import { FuncFlag } from 'firefox-profiler/types'; import { @@ -121,6 +122,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) { @@ -188,6 +202,8 @@ type CallTreeQuery = BaseQuery & { invertFlameGraph: null | undefined; hideIdleSamples: null | undefined; ctSummary: string; + functionListSort?: string; // "total-desc~self-asc" — primary first + selectedFunc?: number; // Selected function index for the current thread, e.g. 42 }; type MarkersQuery = BaseQuery & { @@ -236,6 +252,10 @@ type Query = BaseQuery & { marker?: MarkerIndex; markerSort?: string; + // Function list specific + functionListSort?: string; + selectedFunc?: number; + // Network specific networkSearch?: string; @@ -342,6 +362,7 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { : undefined; /* falls through */ case 'flame-graph': + case 'function-list': case 'calltree': { query = baseQuery as CallTreeQueryShape; @@ -392,6 +413,16 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { query.bottomFullscreen = true; } } + if (selectedTab === 'function-list') { + query.functionListSort = convertFunctionListSortToString( + urlState.profileSpecific.functionListSort + ); + const selectedFunc = + selectedThreadsKey !== null + ? urlState.profileSpecific.selectedFunctions[selectedThreadsKey] + : undefined; + query.selectedFunc = selectedFunc ?? undefined; + } break; } case 'marker-table': @@ -556,6 +587,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))) { @@ -647,7 +691,11 @@ export function stateFromLocation( ? query.hiddenThreads.split('-').map((index) => Number(index)) : null, selectedMarkers, + selectedFunctions, markerTableSort: convertMarkerTableSortFromString(query.markerSort), + functionListSort: convertFunctionListSortFromString( + query.functionListSort + ), }, }; } @@ -694,6 +742,50 @@ function convertMarkerTableSortFromString( return parsed; } +// FunctionList sort URL encoding. Same convention as the marker table: +// internal storage is primary-last, URL is primary-first. `null` means "at the +// function list's default", encoded as an absent query parameter. +const VALID_FUNCTION_LIST_SORT_COLUMNS = new Set(['total', 'self']); + +function convertFunctionListSortToString( + sort: SingleColumnSortState[] | null +): string | undefined { + if (sort === null) { + return undefined; + } + return sort + .slice() + .reverse() + .map((s) => `${s.column}-${s.ascending ? 'asc' : 'desc'}`) + .join('~'); +} + +function convertFunctionListSortFromString( + raw: string | null | void +): SingleColumnSortState[] | null { + if (!raw) { + return null; + } + const parsed: SingleColumnSortState[] = []; + for (const part of raw.split('~')) { + const dashIndex = part.lastIndexOf('-'); + if (dashIndex === -1) { + return null; + } + 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 null; + } + 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..6c4c505a65 --- /dev/null +++ b/src/components/calltree/FunctionList.tsx @@ -0,0 +1,250 @@ +/* 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 }, +]; +const DEFAULT_FUNCTION_LIST_SORT_STATE = new ColumnSortState( + DEFAULT_FUNCTION_LIST_SORT +); + +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[] | null; +}; + +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 = []; + + _getColumnSort = memoize((sort: SingleColumnSortState[] | null) => + sort === null ? DEFAULT_FUNCTION_LIST_SORT_STATE : new ColumnSortState(sort) + ); + + _onColumnSortChange = (columnSort: ColumnSortState) => { + // Normalize matches-default to null so the URL omits the param and every + // client can treat "user hasn't overridden the default" uniformly. + this.props.changeFunctionListSort( + columnSort.equals(DEFAULT_FUNCTION_LIST_SORT_STATE) + ? null + : columnSort.entries + ); + }; + + 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) => {}; + + _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/profile-logic/call-tree.ts b/src/profile-logic/call-tree.ts index f870e0f703..e970a3a2d5 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, @@ -407,6 +410,12 @@ export class CallTree { // The call tree's own order (by total sample count) is the intended order, // so no columns are user-sortable. getSortableColumns(): SortableColumn[] { + if (this._internal instanceof CallTreeInternalFunctionList) { + return [ + { column: 'total', prefersDescending: true }, + { column: 'self', prefersDescending: true }, + ]; + } return []; } @@ -414,7 +423,28 @@ export class CallTree { return this._rootTotalSummary; } - getRoots() { + getRoots(sort: ColumnSortState | null = null): IndexIntoCallNodeTable[] { + if ( + sort !== null && + sort.entries.length > 0 && + this._internal instanceof CallTreeInternalFunctionList + ) { + const internal = this._internal; + return sort.sortItems( + 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; } @@ -518,7 +548,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); @@ -561,6 +591,7 @@ export class CallTree { self ); const totalPercent = `${formatPercent(totalRelative)}`; + const selfPercent = `${formatPercent(selfRelative)}`; let ariaLabel; let totalWithUnit; @@ -611,6 +642,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/symbolication.ts b/src/profile-logic/symbolication.ts index 91b7e075dd..7c5162b686 100644 --- a/src/profile-logic/symbolication.ts +++ b/src/profile-logic/symbolication.ts @@ -1094,6 +1094,21 @@ export async function symbolicateProfile( ); } +// Returns the func that replaces the given func after symbolication. If the +// func expanded into a path of new funcs (from inlined functions), this returns +// the innermost one. This matches applyFuncSubstitutionToCallPath, where the +// leaf of the new call path is the innermost func. +// A func that is not present as a key in the map stays unchanged. +export function applyFuncSubstitutionToFunc( + oldFuncToNewFuncsMap: FuncToFuncsMap, + func: IndexIntoFuncTable +): IndexIntoFuncTable { + const newFuncs = oldFuncToNewFuncsMap.get(func); + return newFuncs === undefined || newFuncs.length === 0 + ? func + : newFuncs[newFuncs.length - 1]; +} + // Create a new call path, where each func in the old call path is // replaced with one or more funcs from the FuncToFuncsMap. // This is used during symbolication, where some previously separate diff --git a/src/reducers/profile-view.ts b/src/reducers/profile-view.ts index b2a58f78c0..b595c81d20 100644 --- a/src/reducers/profile-view.ts +++ b/src/reducers/profile-view.ts @@ -30,8 +30,10 @@ import type { ThreadsKey, Milliseconds, TableViewOptions, + RightClickedFunction, } from 'firefox-profiler/types'; import { + applyFuncSubstitutionToFunc, applyFuncSubstitutionToCallPath, applyFuncSubstitutionToPathSetAndIncludeNewAncestors, } from '../profile-logic/symbolication'; @@ -636,6 +638,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 +784,51 @@ const rightClickedCallNode: Reducer = ( } }; +const rightClickedFunction: Reducer = ( + state = null, + action +) => { + switch (action.type) { + case 'BULK_SYMBOLICATION': { + if (state === null) { + return null; + } + + return { + ...state, + functionIndex: applyFuncSubstitutionToFunc( + action.oldFuncToNewFuncsMap, + state.functionIndex + ), + }; + } + 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 +930,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 5374e6440f..7ac1e7a3d3 100644 --- a/src/reducers/url-state.ts +++ b/src/reducers/url-state.ts @@ -4,7 +4,7 @@ import { combineReducers } from 'redux'; import { oneLine } from 'common-tags'; -import { objectEntries } from '../utils/types'; +import { objectEntries, objectMap } from '../utils/types'; import { tabSlugs, tabsShowingSampleData } from '../app-logic/tabs-handling'; import type { @@ -24,12 +24,15 @@ import type { IsOpenPerPanelState, TabID, SelectedMarkersPerThread, + SelectedFunctionsPerThread, } 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'; +import { translateFuncIndex } from 'firefox-profiler/profile-logic/index-translation'; +import { applyFuncSubstitutionToFunc } from 'firefox-profiler/profile-logic/symbolication'; /* * This state file governs the state that comes from, and alters, the window @@ -206,6 +209,18 @@ const markerTableSort: Reducer = ( } }; +const functionListSort: Reducer = ( + state = null, + 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': @@ -786,6 +801,57 @@ 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, + }; + } + case 'BULK_SYMBOLICATION': { + const { oldFuncToNewFuncsMap } = action; + return objectMap(state, (funcIndex) => + funcIndex === null + ? null + : applyFuncSubstitutionToFunc(oldFuncToNewFuncsMap, funcIndex) + ); + } + case 'SANITIZED_PROFILE_PUBLISHED': { + const { translationMaps } = action; + if (!translationMaps) { + return state; + } + + const newState: SelectedFunctionsPerThread = {}; + for (const [threadsKey, funcIndex] of objectEntries(state)) { + const newThreadsKey = translationMaps.oldThreadIndexToNew + ? translateThreadsKey(threadsKey, translationMaps.oldThreadIndexToNew) + : threadsKey; + if (newThreadsKey === null) { + // Discard the selection for threads that were removed. + continue; + } + + newState[newThreadsKey] = + funcIndex === null + ? null + : translateFuncIndex(funcIndex, translationMaps); + } + return newState; + } + default: + return state; + } +}; + /** * These values are specific to an individual profile. */ @@ -816,7 +882,9 @@ const profileSpecific = combineReducers({ showJsTracerSummary, tabFilter, selectedMarkers, + selectedFunctions, 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 63347c19c1..7b45a97beb 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, @@ -212,6 +213,10 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getSelectedFunctionIndex: Selector = ( + state + ) => UrlState.getSelectedFunction(state, threadsKey); + const getSelectedCallNodePath: Selector = createSelector( threadSelectors.getViewOptions, UrlState.getInvertCallstack, @@ -648,6 +653,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, @@ -658,6 +680,7 @@ export function getStackAndSampleSelectorsPerThread( getAssemblyViewStackAddressInfo, getSelectedCallNodePath, getSelectedCallNodeIndex, + getSelectedFunctionIndex, getExpandedCallNodePaths, getExpandedCallNodeIndexes, getSampleCallNodesForFilteredThread, @@ -681,5 +704,6 @@ export function getStackAndSampleSelectorsPerThread( getNonInvertedFilteredCallNodeMaxDepthPlusOne, getFlameGraphTiming, getRightClickedCallNodeIndex, + getRightClickedFunctionIndex, }; } diff --git a/src/selectors/url-state.ts b/src/selectors/url-state.ts index 599b59a748..413ed8e175 100644 --- a/src/selectors/url-state.ts +++ b/src/selectors/url-state.ts @@ -32,6 +32,7 @@ import type { TabID, IndexIntoSourceTable, MarkerIndex, + IndexIntoFuncTable, } from 'firefox-profiler/types'; import type { TabSlug } from '../app-logic/tabs-handling'; @@ -121,6 +122,9 @@ export const getMarkersSearchString: Selector = (state) => 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) => @@ -257,6 +261,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/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/bottom-box.test.ts b/src/test/store/bottom-box.test.ts index 7d3ef96ae3..4c62a329ef 100644 --- a/src/test/store/bottom-box.test.ts +++ b/src/test/store/bottom-box.test.ts @@ -425,4 +425,31 @@ describe('getBottomBoxInfoForFunction', function () { // 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 + ); + }); }); 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/symbolication.test.ts b/src/test/store/symbolication.test.ts index 1c230bc968..2b40ff9700 100644 --- a/src/test/store/symbolication.test.ts +++ b/src/test/store/symbolication.test.ts @@ -35,6 +35,7 @@ import { changeSelectedCallNode, changeExpandedCallNodes, changeImplementationFilter, + changeSelectedFunctionIndex, } from '../../actions/profile-view'; import { formatTree, formatStack } from '../fixtures/utils'; import { assertSetContainsOnly } from '../fixtures/custom-assertions'; @@ -449,6 +450,28 @@ describe('doSymbolicateProfile', function () { [['first symbol']].map(funcNamesToFuncIndexes) ); }); + + it('symbolicates the selected function', async function () { + const { + store: { dispatch, getState }, + profile, + symbolStore, + funcNamesToFuncIndexes, + } = init(); + + const [unsymbolicatedFunc] = funcNamesToFuncIndexes(['0x0000']); + dispatch(changeSelectedFunctionIndex(0, unsymbolicatedFunc)); + expect(selectedThreadSelectors.getSelectedFunctionIndex(getState())).toBe( + unsymbolicatedFunc + ); + + await doSymbolicateProfile(dispatch, profile, symbolStore); + const [symbolicatedFunc] = funcNamesToFuncIndexes(['first symbol']); + expect(symbolicatedFunc).not.toBe(unsymbolicatedFunc); + expect(selectedThreadSelectors.getSelectedFunctionIndex(getState())).toBe( + symbolicatedFunc + ); + }); }); it('can symbolicate a profile with a partial symbol table and re-symbolicate it with a complete symbol table', async () => { diff --git a/src/test/store/useful-tabs.test.ts b/src/test/store/useful-tabs.test.ts index 0d63df2759..5c84982d43 100644 --- a/src/test/store/useful-tabs.test.ts +++ b/src/test/store/useful-tabs.test.ts @@ -24,6 +24,7 @@ describe('getUsefulTabs', function () { const { getState } = storeWithProfile(profile); expect(selectedThreadSelectors.getUsefulTabs(getState())).toEqual([ 'calltree', + 'function-list', 'flame-graph', 'stack-chart', 'marker-chart', @@ -62,6 +63,7 @@ describe('getUsefulTabs', function () { }); expect(selectedThreadSelectors.getUsefulTabs(getState())).toEqual([ 'calltree', + 'function-list', 'flame-graph', 'stack-chart', 'marker-chart', @@ -89,6 +91,7 @@ describe('getUsefulTabs', function () { const { getState } = storeWithProfile(profile); expect(selectedThreadSelectors.getUsefulTabs(getState())).toEqual([ 'calltree', + 'function-list', 'flame-graph', 'stack-chart', 'marker-chart', @@ -121,6 +124,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-tree.test.ts b/src/test/unit/profile-tree.test.ts index 48c82e95e1..1b6afc1682 100644 --- a/src/test/unit/profile-tree.test.ts +++ b/src/test/unit/profile-tree.test.ts @@ -332,6 +332,7 @@ describe('unfiltered call tree', function () { name: 'A', self: '—', selfWithUnit: '—', + selfPercent: '0%', total: '3', totalWithUnit: '3 samples', totalPercent: '100%', @@ -348,6 +349,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 c5779cff8d..d12d624998 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 { @@ -556,6 +557,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).toBeNull(); + }); +}); + 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 01d57310b3..e59e601795 100644 --- a/src/types/actions.ts +++ b/src/types/actions.ts @@ -17,6 +17,7 @@ import type { RawFrameTable, SourceLocationTable, SourceTable, + IndexIntoFuncTable, } from './profile'; import type { Thread, @@ -195,6 +196,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; @@ -205,6 +212,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'; } @@ -566,6 +578,10 @@ type UrlStateAction = readonly type: 'CHANGE_MARKER_TABLE_SORT'; readonly sort: SingleColumnSortState[] | null; } + | { + readonly type: 'CHANGE_FUNCTION_LIST_SORT'; + readonly sort: SingleColumnSortState[] | null; + } | { readonly type: 'CHANGE_NETWORK_SEARCH_STRING'; readonly searchString: string; diff --git a/src/types/profile-derived.ts b/src/types/profile-derived.ts index 10fbae4239..4a5a1ddfa1 100644 --- a/src/types/profile-derived.ts +++ b/src/types/profile-derived.ts @@ -623,6 +623,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 5583e72181..0a4872e0cb 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -23,6 +23,7 @@ import type { Pid, TabID, IndexIntoSourceTable, + IndexIntoFuncTable, } from './profile'; import type { @@ -83,6 +84,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; @@ -92,6 +98,10 @@ export type SelectedMarkersPerThread = { [key: ThreadsKey]: MarkerIndex | null; }; +export type SelectedFunctionsPerThread = { + [key: ThreadsKey]: IndexIntoFuncTable | null; +}; + /** * Profile view state */ @@ -108,6 +118,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; @@ -386,7 +397,9 @@ export type ProfileSpecificUrlState = { legacyThreadOrder: ThreadIndex[] | null; legacyHiddenThreads: ThreadIndex[] | null; selectedMarkers: SelectedMarkersPerThread; + selectedFunctions: SelectedFunctionsPerThread; markerTableSort: SingleColumnSortState[] | null; + functionListSort: SingleColumnSortState[] | null; }; 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 21453a409c337184fcf1e74e9c8956942fa913fe Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:52:56 -0400 Subject: [PATCH 05/19] Add a context menu for the function list. The call tree's context menu operates on a call node path, which the function list does not have. Add a sibling menu that operates on a function index instead. The menu items are described by a data table rather than repeated JSX, because the same set of transforms is about to be reused by more than one view. --- src/actions/profile-view.ts | 2 +- src/components/app/Details.tsx | 2 + .../shared/FunctionListContextMenu.tsx | 486 ++++++++++++++++++ .../FunctionListContextMenu.test.tsx | 119 +++++ .../FunctionListContextMenu.test.tsx.snap | 216 ++++++++ 5 files changed, 824 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 9da70090ae..074c76ab73 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -179,7 +179,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..8625c16999 --- /dev/null +++ b/src/components/shared/FunctionListContextMenu.tsx @@ -0,0 +1,486 @@ +/* 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 { FuncFlag } from 'firefox-profiler/types'; +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 callNodeTable: CallNodeTable; + 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, callNodeTable }) => + funcHasRecursiveCall(callNodeTable, funcIndex), + }, + { + transform: 'collapse-direct-recursion', + shortcut: 'R', + icon: 'Collapse', + l10nId: 'CallNodeContextMenu--transform-collapse-direct-recursion-only', + content: () => 'Collapse direct recursion only', + visible: ({ funcIndex, callNodeTable }) => + funcHasDirectRecursiveCall(callNodeTable, 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 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.flags[funcIndex] & FuncFlag.IsJS) !== 0; + 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 funcFlags = funcTable.flags[funcIndex]; + if ((funcFlags & FuncFlag.IsJS) !== 0) { + if ((funcFlags & FuncFlag.HasSource) === 0) { + return null; + } + const sourceIndex = funcTable.source[funcIndex]; + return stringTable.getString(sources.filename[sourceIndex]); + } + if ((funcFlags & FuncFlag.HasResource) === 0) { + return null; + } + const resourceIndex = funcTable.resource[funcIndex]; + 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(item: TransformMenuItem, ctx: MenuItemContext) { + return ( + + + +
+ {item.content(ctx)} +
+
+ {item.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 ctx: MenuItemContext = { + funcIndex: info.funcIndex, + callNodeTable: info.callNodeTable, + nameForResource: this.getNameForSelectedResource(), + }; + + const renderedItems = MENU_ITEMS.filter( + (item) => !item.visible || item.visible(ctx) + ).map((item) => this.renderTransformMenuItem(item, ctx)); + + return ( + <> + {renderedItems} + +
+ + {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 b02cb44ae39144668be50c76b6228d78d1e80c19 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:53:02 -0400 Subject: [PATCH 06/19] Add transform shortcut keys to the function list. These are the same single-key shortcuts the call tree context menu offers (m, f, S, c, C, r, R, d), applied to the selected function. --- src/actions/profile-view.ts | 91 ++++++--- src/components/calltree/FunctionList.tsx | 24 ++- .../components/TransformShortcuts.test.tsx | 175 ++++++++++++++++++ 3 files changed, 259 insertions(+), 31 deletions(-) diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 074c76ab73..d185f425be 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -2081,49 +2081,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, }) ); @@ -2182,7 +2222,7 @@ export function handleCallNodeTransformShortcut( } break; } - case 'c': { + case 'c': dispatch( addTransformToStack(threadsKey, { type: 'collapse-function-subtree', @@ -2190,17 +2230,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 6c4c505a65..09d8b2e371 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'; @@ -62,6 +64,7 @@ const DEFAULT_FUNCTION_LIST_SORT_STATE = new ColumnSortState( type StateProps = { readonly threadsKey: ThreadsKey; + readonly callNodeInfo: CallNodeInfo; readonly scrollToSelectionGeneration: number; readonly focusCallTreeGeneration: number; readonly tree: CallTree; @@ -79,6 +82,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; }; @@ -167,7 +171,23 @@ class FunctionListImpl extends PureComponent { _newExpandedCallNodeIndexes: Array ) => {}; - _onKeyDown = (_event: React.KeyboardEvent) => {}; + _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) => { const { tree, updateBottomBoxContentsAndMaybeOpen } = this.props; @@ -224,6 +244,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), @@ -242,6 +263,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 2230dba3c5..cfe0a5e7fc 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') +} + // These expectations run inside a test block later, not at this point. // These actions will be used to generate use cases for each of the supported panels. const actions = { @@ -323,3 +424,77 @@ describe('stack chart transform shortcuts', () => { }); } }); + +// These expectations run inside a test block later, not at this point. +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(); + }, +}; + +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 e4707bc25cb1522218fb41c0459a030edc12aa94 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 3 Sep 2026 15:39:47 -0400 Subject: [PATCH 07/19] Highlight the selected function's samples in the activity graph. When the function list tab is active, the activity graph should highlight every sample whose stack contains the selected function, rather than the samples belonging to a selected call node. --- src/components/timeline/TrackThread.tsx | 5 +- src/profile-logic/profile-data.ts | 53 +++++++ src/selectors/per-thread/stack-sample.ts | 14 ++ src/test/unit/profile-data.test.ts | 176 +++++++++++++++++++++++ 4 files changed, 247 insertions(+), 1 deletion(-) diff --git a/src/components/timeline/TrackThread.tsx b/src/components/timeline/TrackThread.tsx index 39909bd0e5..50562db42d 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, @@ -348,7 +349,9 @@ export const TimelineTrackThread = explicitConnect< hasFileIoMarkers: selectors.getTimelineFileIoMarkerIndexes(state).length !== 0, sampleRelations: - selectors.getSampleRelationsToSelectedNodeInFilteredThread(state), + getSelectedTab(state) === 'function-list' + ? selectors.getSampleRelationsForFunctionListTab(state) + : selectors.getSampleRelationsToSelectedNodeInFilteredThread(state), treeOrderSampleComparator: selectors.getTreeOrderComparatorInFilteredThread(state), selectedThreadIndexes, diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index 01ebf66d3c..32994fb031 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -1280,6 +1280,59 @@ export function getSampleRelationsToNode( ); } +/** + * Go through the samples, and determine how each of them relates to the given + * function. A sample counts towards the function's total if the function is + * anywhere on its stack, and towards its self time if the stack ends in it. + * Unrelated samples are all marked as Before, since the function list has no + * tree order to follow. + */ +export function getSampleRelationsToFunction( + sampleCallNodes: Array, + selectedFunctionIndex: IndexIntoFuncTable | null, + callNodeTable: CallNodeTable +): SampleRelations { + if (selectedFunctionIndex === null) { + return _getSampleRelationsForNoNode(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; + } + } + + const relations = new Uint8Array(sampleCount); + for (let sampleIndex = 0; sampleIndex < sampleCount; sampleIndex++) { + let sampleRelation: SampleRelationToNode = SampleRelationToNode.FilteredOut; + const callNodeIndex = sampleCallNodes[sampleIndex]; + if (callNodeIndex !== null) { + if (callNodeTable.func[callNodeIndex] === selectedFunctionIndex) { + sampleRelation = SampleRelationToNode.TotalAndSelf; + } else if (callNodeContainsFunc[callNodeIndex] === 1) { + sampleRelation = SampleRelationToNode.TotalButNotSelf; + } else { + sampleRelation = SampleRelationToNode.Before; + } + } + relations[sampleIndex] = sampleRelation; + } + return new SampleRelations(relations); +} + /** * 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. diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index 7b45a97beb..47a8fde025 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -352,6 +352,19 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getSampleRelationsForFunctionListTab: Selector = + createSelector( + getSampleCallNodesForFilteredThread, + _getCallNodeTable, + getSelectedFunctionIndex, + (sampleCallNodes, callNodeTable, selectedFunctionIndex) => + ProfileData.getSampleRelationsToFunction( + sampleCallNodes, + selectedFunctionIndex, + callNodeTable + ) + ); + const getTreeOrderComparatorInFilteredThread: Selector< ( sampleIndexA: IndexIntoSamplesTable, @@ -685,6 +698,7 @@ export function getStackAndSampleSelectorsPerThread( getExpandedCallNodeIndexes, getSampleCallNodesForFilteredThread, getSampleRelationsToSelectedNodeInFilteredThread, + getSampleRelationsForFunctionListTab, getPreviewFilteredCtssSampleCallNodes, getPreviewFilteredCtssSampleRelations, getSelectedCallNodeIsInvertedRoot, diff --git a/src/test/unit/profile-data.test.ts b/src/test/unit/profile-data.test.ts index d8256f3af6..de6baccb6a 100644 --- a/src/test/unit/profile-data.test.ts +++ b/src/test/unit/profile-data.test.ts @@ -19,6 +19,7 @@ import { getSampleIndexToCallNodeIndex, getTreeOrderComparator, getSampleRelationsToNode, + getSampleRelationsToFunction, SampleRelations, extractProfileFilterPageData, findAddressProofForFile, @@ -1319,6 +1320,181 @@ describe('getSampleRelationsToNode', function () { }); }); +describe('getSampleRelationsToFunction', 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 contributing when nothing is selected', function () { + const { callNodeTable, sampleCallNodes } = setup(` + A A A + B C + `); + const relations = getSampleRelationsToFunction( + sampleCallNodes, + null, + callNodeTable + ); + expect([0, 1, 2].map((i) => relations.contributesToTotal(i))).toEqual([ + true, + true, + true, + ]); + }); + + it('marks samples whose stack contains the function as total, and those ending in it as self', 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( + getSampleRelationsToFunction( + sampleCallNodes, + B, + callNodeTable + ).toArrayForTesting() + ).toEqual([ + SampleRelationToNode.TotalButNotSelf, + SampleRelationToNode.Before, + SampleRelationToNode.TotalButNotSelf, + SampleRelationToNode.Before, + SampleRelationToNode.Before, + ]); + + // Selecting function D: samples 1, 3, 4 have D in their stack, and + // sample 4 ends in D. + expect( + getSampleRelationsToFunction( + sampleCallNodes, + D, + callNodeTable + ).toArrayForTesting() + ).toEqual([ + SampleRelationToNode.Before, + SampleRelationToNode.TotalButNotSelf, + SampleRelationToNode.Before, + SampleRelationToNode.TotalButNotSelf, + SampleRelationToNode.TotalAndSelf, + ]); + }); + + it('marks filtered-out samples as FilteredOut', function () { + const { + callNodeTable, + sampleCallNodes, + funcNamesDict: { B }, + } = setup(` + A A A + B C + `); + // Manually null out sample 2's call node, as if it was filtered out. + sampleCallNodes[2] = null; + + expect( + getSampleRelationsToFunction( + sampleCallNodes, + B, + callNodeTable + ).toArrayForTesting() + ).toEqual([ + SampleRelationToNode.TotalAndSelf, + SampleRelationToNode.Before, + SampleRelationToNode.FilteredOut, + ]); + }); + + it('matches samples whose ancestor call node contains the function, not just the leaf', function () { + // 0 1 + // A A + // B C + // D + const { + callNodeTable, + sampleCallNodes, + funcNamesDict: { A, B }, + } = setup(` + A A + B C + D + `); + + // A is an ancestor of everything. + expect( + getSampleRelationsToFunction( + sampleCallNodes, + A, + callNodeTable + ).toArrayForTesting() + ).toEqual([ + SampleRelationToNode.TotalButNotSelf, + SampleRelationToNode.TotalButNotSelf, + ]); + + // B is only in the path A->B->D. + expect( + getSampleRelationsToFunction( + sampleCallNodes, + B, + callNodeTable + ).toArrayForTesting() + ).toEqual([ + SampleRelationToNode.TotalButNotSelf, + SampleRelationToNode.Before, + ]); + }); + + it('counts recursive samples as self when the leaf is the function', function () { + const { + callNodeTable, + sampleCallNodes, + funcNamesDict: { A }, + } = setup(` + A A + B B + A + `); + expect( + getSampleRelationsToFunction( + sampleCallNodes, + A, + callNodeTable + ).toArrayForTesting() + ).toEqual([ + SampleRelationToNode.TotalAndSelf, + SampleRelationToNode.TotalButNotSelf, + ]); + }); +}); + describe('extractProfileFilterPageData', function () { const pages = { mozilla: { From c2002736831d42d8b3c248974f63d98f4a43ebe3 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:53:03 -0400 Subject: [PATCH 08/19] Select the self function when clicking in the activity graph. Clicking a sample already selects the corresponding self call node. When the function list tab is active, also select that call node's function so the click has an effect there too. --- src/actions/profile-view.ts | 32 +++++++++++++++++++++++++ src/components/timeline/TrackThread.tsx | 5 ++++ 2 files changed, 37 insertions(+) diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index d185f425be..6cf7597242 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -247,6 +247,38 @@ 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.getSampleCallNodesForFilteredThread(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 50562db42d..2e0eb8142d 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'; @@ -101,6 +102,7 @@ type DispatchProps = { readonly changeSelectedCallNode: typeof changeSelectedCallNode; readonly focusCallTree: typeof focusCallTree; readonly selectSelfCallNode: typeof selectSelfCallNode; + readonly selectSelfFunction: typeof selectSelfFunction; readonly reportTrackThreadHeight: typeof reportTrackThreadHeight; }; @@ -124,6 +126,7 @@ class TimelineTrackThreadImpl extends PureComponent { const { threadsKey, selectSelfCallNode, + selectSelfFunction, focusCallTree, selectedThreadIndexes, callTreeVisible, @@ -131,6 +134,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) { @@ -367,6 +371,7 @@ export const TimelineTrackThread = explicitConnect< changeSelectedCallNode, focusCallTree, selectSelfCallNode, + selectSelfFunction, reportTrackThreadHeight, }, component: withSize(TimelineTrackThreadImpl), From bc93bfe3ce46b7590af23b39ebd3979e8fa02bf9 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Tue, 22 Sep 2026 16:08:42 -0400 Subject: [PATCH 09/19] Implement sidebar timings for the function list. --- locales/en-US/app.ftl | 5 +- src/components/sidebar/CallTreeSidebar.tsx | 41 ++++- src/components/sidebar/index.tsx | 4 +- src/selectors/per-thread/index.ts | 75 ++++++++ src/selectors/per-thread/stack-sample.ts | 14 ++ src/test/components/CallTreeSidebar.test.tsx | 113 ++++++++++++- .../CallTreeSidebar.test.tsx.snap | 160 ++++++++++++++++++ 7 files changed, 403 insertions(+), 9 deletions(-) diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl index a2478d7ec7..ecc8089fbc 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -227,10 +227,13 @@ CallTree--inlining-badge = (inlined) .title = Calls to { $calledFunction } were inlined into { $outerFunction } by the compiler. ## CallTreeSidebar -## This is the sidebar component that is used in Call Tree and Flame Graph panels. +## This is the sidebar component that is used in Call Tree, Function List and +## Flame Graph panels. CallTreeSidebar--select-a-node = Select a node to display information about it. CallTreeSidebar--call-node-details = Call node details +CallTreeSidebar--select-a-function = Select a function to display information about it. +CallTreeSidebar--function-details = Function details ## CallTreeSidebar timing information ## diff --git a/src/components/sidebar/CallTreeSidebar.tsx b/src/components/sidebar/CallTreeSidebar.tsx index fe03d9dc5a..d4e42670d7 100644 --- a/src/components/sidebar/CallTreeSidebar.tsx +++ b/src/components/sidebar/CallTreeSidebar.tsx @@ -11,6 +11,7 @@ import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; import { selectedThreadSelectors, selectedNodeSelectors, + selectedFunctionSelectors, } from 'firefox-profiler/selectors/per-thread'; import { getSelectedThreadsKey } from 'firefox-profiler/selectors/url-state'; import { toggleOpenCategoryInSidebar } from 'firefox-profiler/actions/app'; @@ -193,6 +194,9 @@ export const CategoryBreakdown = explicitConnect< }); type StateProps = { + // Whether the sidebar describes a call node (call tree, flame graph) or a + // function (function list). + readonly selectionKind: 'call-node' | 'function'; readonly selectedNodeIndex: IndexIntoCallNodeTable | null; readonly selectedThreadsKey: ThreadsKey; readonly name: string; @@ -268,6 +272,7 @@ class CallTreeSidebarImpl extends React.PureComponent { override render() { const { + selectionKind, selectedNodeIndex, name, lib, @@ -284,7 +289,13 @@ class CallTreeSidebarImpl extends React.PureComponent { if (selectedNodeIndex === null) { return (
- +
Select a node to display some information about it.
@@ -323,9 +334,15 @@ class CallTreeSidebarImpl extends React.PureComponent { ) : null}

- -
Call node details
-
+ {selectionKind === 'function' ? ( + +
Function details
+
+ ) : ( + +
Call node details
+
+ )}

{selectedNodeTracedSelfAndTotal ? ( { export const CallTreeSidebar = explicitConnect<{}, StateProps, {}>({ mapStateToProps: (state) => ({ + selectionKind: 'call-node', selectedNodeIndex: selectedThreadSelectors.getSelectedCallNodeIndex(state), selectedThreadsKey: getSelectedThreadsKey(state), name: getFunctionName(selectedNodeSelectors.getName(state)), @@ -433,3 +451,18 @@ export const CallTreeSidebar = explicitConnect<{}, StateProps, {}>({ }), component: CallTreeSidebarImpl, }); + +export const FunctionListSidebar = explicitConnect<{}, StateProps, {}>({ + mapStateToProps: (state) => ({ + selectionKind: 'function', + selectedNodeIndex: selectedThreadSelectors.getSelectedFunctionIndex(state), + selectedThreadsKey: getSelectedThreadsKey(state), + name: getFunctionName(selectedFunctionSelectors.getName(state)), + lib: selectedFunctionSelectors.getLib(state), + timings: selectedFunctionSelectors.getTimingsForSidebar(state), + categoryList: getCategories(state), + weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), + selectedNodeTracedSelfAndTotal: null, + }), + component: CallTreeSidebarImpl, +}); diff --git a/src/components/sidebar/index.tsx b/src/components/sidebar/index.tsx index e4a6901069..6b601d72fa 100644 --- a/src/components/sidebar/index.tsx +++ b/src/components/sidebar/index.tsx @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import type * as React from 'react'; -import { CallTreeSidebar } from './CallTreeSidebar'; +import { CallTreeSidebar, FunctionListSidebar } from './CallTreeSidebar'; import { MarkerSidebar } from './MarkerSidebar'; import type { TabSlug } from 'firefox-profiler/app-logic/tabs-handling'; @@ -15,7 +15,7 @@ export function selectSidebar( ): React.ComponentType<{}> | null { return { calltree: CallTreeSidebar, - 'function-list': CallTreeSidebar, + 'function-list': FunctionListSidebar, 'flame-graph': CallTreeSidebar, 'stack-chart': null, 'marker-chart': null, diff --git a/src/selectors/per-thread/index.ts b/src/selectors/per-thread/index.ts index dc0d4aa863..2ba294fcc6 100644 --- a/src/selectors/per-thread/index.ts +++ b/src/selectors/per-thread/index.ts @@ -270,3 +270,78 @@ export const selectedNodeSelectors: NodeSelectors = (() => { getTimingsForSidebar, }; })(); + +export type FunctionSelectors = { + readonly getName: Selector; + readonly getLib: Selector; + readonly getTimingsForSidebar: Selector; +}; + +/** + * The equivalent of selectedNodeSelectors for the function selected in the + * function list. + */ +export const selectedFunctionSelectors: FunctionSelectors = (() => { + const getName: Selector = createSelector( + selectedThreadSelectors.getSelectedFunctionIndex, + selectedThreadSelectors.getFilteredThread, + (funcIndex, { stringTable, funcTable }) => { + if (funcIndex === null) { + return ''; + } + return stringTable.getString(funcTable.name[funcIndex]); + } + ); + + const getLib: Selector = createSelector( + selectedThreadSelectors.getSelectedFunctionIndex, + selectedThreadSelectors.getFilteredThread, + ProfileSelectors.getSourceTable, + ( + funcIndex, + { + stringTable, + frameTable, + funcTable, + resourceTable, + sourceLocationTable, + }, + sources + ) => { + if (funcIndex === null) { + return ''; + } + return ProfileData.getOriginAnnotationForFunc( + funcIndex, + null, + frameTable, + funcTable, + resourceTable, + stringTable, + sources, + sourceLocationTable + ); + } + ); + + const getTimingsForSidebar: Selector = createSelector( + ProfileSelectors.getCategories, + selectedThreadSelectors.getPreviewFilteredCtssSamples, + selectedThreadSelectors.getPreviewFilteredCtssSampleCategoriesAndSubcategories, + selectedThreadSelectors.getPreviewFilteredCtssSampleRelationsToSelectedFunction, + (categories, samples, sampleCategoriesAndSubcategories, sampleRelations) => + ProfileData.getCallNodeTimings( + categories, + samples, + sampleCategoriesAndSubcategories, + sampleRelations, + false + ) + ); + + return { + getName, + getLib, + getTimingsForSidebar, + }; +})(); diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index 47a8fde025..ebbf68a882 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -365,6 +365,19 @@ export function getStackAndSampleSelectorsPerThread( ) ); + const getPreviewFilteredCtssSampleRelationsToSelectedFunction: Selector = + createSelector( + getPreviewFilteredCtssSampleCallNodes, + _getCallNodeTable, + getSelectedFunctionIndex, + (sampleCallNodes, callNodeTable, selectedFunctionIndex) => + ProfileData.getSampleRelationsToFunction( + sampleCallNodes, + selectedFunctionIndex, + callNodeTable + ) + ); + const getTreeOrderComparatorInFilteredThread: Selector< ( sampleIndexA: IndexIntoSamplesTable, @@ -701,6 +714,7 @@ export function getStackAndSampleSelectorsPerThread( getSampleRelationsForFunctionListTab, getPreviewFilteredCtssSampleCallNodes, getPreviewFilteredCtssSampleRelations, + getPreviewFilteredCtssSampleRelationsToSelectedFunction, getSelectedCallNodeIsInvertedRoot, getTreeOrderComparatorInFilteredThread, getCallTree, diff --git a/src/test/components/CallTreeSidebar.test.tsx b/src/test/components/CallTreeSidebar.test.tsx index 49cbc7190c..232e2a2dae 100644 --- a/src/test/components/CallTreeSidebar.test.tsx +++ b/src/test/components/CallTreeSidebar.test.tsx @@ -4,9 +4,13 @@ import { Provider } from 'react-redux'; import { render, act } from 'firefox-profiler/test/fixtures/testing-library'; -import { CallTreeSidebar } from '../../components/sidebar/CallTreeSidebar'; +import { + CallTreeSidebar, + FunctionListSidebar, +} from '../../components/sidebar/CallTreeSidebar'; import { changeSelectedCallNode, + changeSelectedFunctionIndex, changeInvertCallstack, } from '../../actions/profile-view'; @@ -14,7 +18,11 @@ import { storeWithProfile } from '../fixtures/stores'; import type { FuncNamesDict } from '../fixtures/profiles/processed-profile'; import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; -import type { CallNodePath, Profile } from 'firefox-profiler/types'; +import type { + CallNodePath, + IndexIntoFuncTable, + Profile, +} from 'firefox-profiler/types'; import { ensureExists } from '../../utils/types'; import { fireFullClick } from '../fixtures/utils'; @@ -204,3 +212,104 @@ describe('CallTreeSidebar', function () { expect(getAllByText('FakeSubCategoryC')[1]).toBeInTheDocument(); }); }); + +describe('FunctionListSidebar', function () { + function setup(textSamples: string) { + const { + profile, + funcNamesDictPerThread: [funcNamesDict], + } = getProfileFromTextSamples(textSamples); + const store = storeWithProfile(profile); + + const selectFunction = (funcIndex: IndexIntoFuncTable) => { + act(() => { + store.dispatch(changeSelectedFunctionIndex(0, funcIndex)); + }); + }; + + const renderResult = render( + + + + ); + + return { ...renderResult, funcNamesDict, selectFunction }; + } + + // Returns the [label, percentage, value] triples of the sidebar details. + function getDetails(container: HTMLElement): string[][] { + return Array.from(container.querySelectorAll('.sidebar-label')).map( + (label) => [ + label.textContent ?? '', + label.nextElementSibling?.textContent ?? '', + label.nextElementSibling?.nextElementSibling?.textContent ?? '', + ] + ); + } + + const textSamples = ` + A A A A + B B B B + Cjs Cjs H[cat:Layout] H[cat:Layout] + D F I[cat:Idle] + Ejs Ejs + `; + + it('asks to select a function when none is selected', () => { + const { getByText } = setup(textSamples); + expect( + getByText('Select a function to display information about it.') + ).toBeInTheDocument(); + }); + + it('displays the timings of the selected function', () => { + const { + container, + getByText, + selectFunction, + funcNamesDict: { B, Ejs, H }, + } = setup(textSamples); + + selectFunction(B); + expect(getByText('Function details')).toBeInTheDocument(); + expect(container.querySelector('h2.sidebar-title')).toHaveAttribute( + 'title', + 'B\n(click to select)' + ); + expect(getDetails(container).slice(0, 2)).toEqual([ + ['Running samples', '100%', '4'], + ['Self samples', '—', '—'], + ]); + + selectFunction(Ejs); + expect(getDetails(container).slice(0, 2)).toEqual([ + ['Running samples', '50%', '2'], + ['Self samples', '50%', '2'], + ]); + + selectFunction(H); + expect(getDetails(container).slice(0, 2)).toEqual([ + ['Running samples', '50%', '2'], + ['Self samples', '25%', '1'], + ]); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('counts recursive functions only once per sample', () => { + const { + container, + selectFunction, + funcNamesDict: { A }, + } = setup(` + A A B + B A + A + `); + + selectFunction(A); + expect(getDetails(container).slice(0, 2)).toEqual([ + ['Running samples', '67%', '2'], + ['Self samples', '67%', '2'], + ]); + }); +}); diff --git a/src/test/components/__snapshots__/CallTreeSidebar.test.tsx.snap b/src/test/components/__snapshots__/CallTreeSidebar.test.tsx.snap index e313cdf49b..a8a535442c 100644 --- a/src/test/components/__snapshots__/CallTreeSidebar.test.tsx.snap +++ b/src/test/components/__snapshots__/CallTreeSidebar.test.tsx.snap @@ -1620,3 +1620,163 @@ exports[`CallTreeSidebar matches the snapshots when displaying data about the cu
`; + +exports[`FunctionListSidebar displays the timings of the selected function 1`] = ` + +`; From 09a58f256c157ba1f6130ba4b394cc3f82624dbc Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:55:52 -0400 Subject: [PATCH 10/19] Add the lower wing call tree. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lower wing answers "who calls the selected function?". It is an inverted tree, but unlike the regular inverted tree it has a single root — the selected function — and its totals count only the samples in which that function appears. LowerWingCallNodeInfo builds that tree eagerly from the selected function's root-most entry points, expanding by inverted depth as the view asks for it. computeLowerWingTimings maps each entry point's inclusive time onto the corresponding node so that the percentages stay relative to the full thread. --- src/profile-logic/call-node-info.ts | 640 +++++++++++++++++++++++++++- src/profile-logic/call-tree.ts | 58 +++ src/profile-logic/profile-data.ts | 23 + src/test/fixtures/utils.ts | 58 +++ src/test/unit/profile-tree.test.ts | 137 ++++++ 5 files changed, 908 insertions(+), 8 deletions(-) diff --git a/src/profile-logic/call-node-info.ts b/src/profile-logic/call-node-info.ts index 4e4f6eb47b..c524db0969 100644 --- a/src/profile-logic/call-node-info.ts +++ b/src/profile-logic/call-node-info.ts @@ -585,12 +585,12 @@ export type SuffixOrderIndex = number; * CallNodeInfo with the additional functionality that consumers of inverted * call trees need (suffix order, root identification, function count). * - * The only implementation so far is LazyInvertedCallNodeInfo, which has one - * root per func and materializes non-root nodes on demand. It is used for the - * regular "invert call stack" mode and the function list tab, where any func - * can be a displayed root. The interface deliberately does not assume that - * roots correspond one-to-one to funcs, so that implementations with a - * restricted set of roots can be added. + * 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. @@ -615,11 +615,12 @@ export interface CallNodeInfoInverted extends CallNodeInfo { // 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). + // (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. + // 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; @@ -1777,3 +1778,626 @@ export class LazyInvertedCallNodeInfo implements CallNodeInfoInverted { ]; } } + +// 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 a77b27a433..87df48bc4c 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -910,6 +910,64 @@ 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, + flameGraphWidthTotal: rootTotalSummary, + }), + }; +} + export function computeCallTreeTimings( callNodeInfo: CallNodeInfo, callNodeSelfAndSummary: CallNodeSelfAndSummary diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index 69a2360166..93cfa91138 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -15,6 +15,7 @@ import { import { CallNodeInfoNonInverted, LazyInvertedCallNodeInfo, + LowerWingCallNodeInfo, } from './call-node-info'; import { computeThreadCPUPercent } from './cpu'; import { @@ -680,6 +681,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 cabb0e3707..8156de24ea 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'; @@ -14,6 +15,7 @@ import { computeCallNodeFuncIsDuplicate, getCallNodeInfo, getInvertedCallNodeInfo, + getLowerWingCallNodeInfo, getSampleIndexToCallNodeIndex, getOriginAnnotationForFunc, createThreadFromDerivedTables, @@ -290,6 +292,62 @@ 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. + * + * 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 | null, + threadIndex: number = 0 +): CallTree { + const { derivedThreads, defaultCategory } = getProfileWithDicts(profile); + const thread = derivedThreads[threadIndex]; + const callNodeInfo = getCallNodeInfo( + thread.stackTable, + thread.frameTable, + defaultCategory + ); + 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, + selectedFuncIndex + ); + const selfAndSummary = computeCallNodeSelfAndSummary( + thread.samples, + getSampleIndexToCallNodeIndex( + thread.samples.stack, + callNodeInfo.getStackIndexToNonInvertedCallNodeIndex() + ), + callNodeInfo.getCallNodeTable().length + ); + const timings = computeLowerWingTimings( + lowerWingCallNodeInfo, + selfAndSummary, + selectedFuncIndex + ); + return getCallTree( + thread, + lowerWingCallNodeInfo, + 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 6c1e02e132..073d351c7a 100644 --- a/src/test/unit/profile-tree.test.ts +++ b/src/test/unit/profile-tree.test.ts @@ -26,6 +26,7 @@ import { ResourceType, FrameFlag, FuncFlag } from 'firefox-profiler/types'; import { callTreeFromProfile, functionListTreeFromProfile, + lowerWingTreeFromProfile, formatTree, formatTreeIncludeCategories, addSourceToTable, @@ -582,6 +583,142 @@ 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([]); + }); + + 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 () { function getProfile() { return getMergedProfileFromTextSamples([ From 73212d2b6204ca47738ffe9d7fdb8f52872aecf7 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:56:09 -0400 Subject: [PATCH 11/19] Add the upper wing call tree. The upper wing answers "what does the selected function call?". It is a non-inverted tree rooted at the selected function, built by re-rooting the thread's stacks at that function's entry points. --- 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 93cfa91138..f0d3354f93 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -210,7 +210,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( @@ -257,7 +260,6 @@ type CallNodeTableHierarchy = { // there are no call nodes. firstRoot: IndexIntoCallNodeTable; length: number; - stackIndexToCallNodeIndex: Int32Array; }; /** @@ -322,7 +324,7 @@ type CallNodeTableExtraColumns = { function _computeCallNodeTableHierarchy( stackTable: StackTable, frameTable: FrameTable -): CallNodeTableHierarchy { +): CallNodeTableHierarchy & { stackIndexToCallNodeIndex: Int32Array } { const stackIndexToCallNodeIndex = new Int32Array(stackTable.length); // The callNodeTable components. @@ -473,16 +475,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); @@ -576,6 +572,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. * @@ -665,6 +771,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. */ @@ -5223,3 +5404,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 8156de24ea..c79ca57531 100644 --- a/src/test/fixtures/utils.ts +++ b/src/test/fixtures/utils.ts @@ -26,6 +26,7 @@ import { computeSamplesTableFromRawSamplesTable, computeJsAllocationsTableFromRawJsAllocationsTable, computeNativeAllocationsTableFromRawNativeAllocationsTable, + createUpperWingCallNodeInfo, } from 'firefox-profiler/profile-logic/profile-data'; import { getProfileWithDicts } from './profiles/processed-profile'; import { StringTable } from '../../utils/string-table'; @@ -292,6 +293,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 073d351c7a..9a439a69ac 100644 --- a/src/test/unit/profile-tree.test.ts +++ b/src/test/unit/profile-tree.test.ts @@ -26,6 +26,7 @@ import { ResourceType, FrameFlag, FuncFlag } from 'firefox-profiler/types'; import { callTreeFromProfile, functionListTreeFromProfile, + upperWingTreeFromProfile, lowerWingTreeFromProfile, formatTree, formatTreeIncludeCategories, @@ -583,6 +584,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 14cdc4632deb46e5d7aba032be6f08587fea9d41 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:56:12 -0400 Subject: [PATCH 12/19] Add the lower wing flame graph computation. Lay out the lower wing as a flame graph. Because the lower wing tree is materialized lazily by inverted depth, the layout is computed row by row and memoizes its prefix sums rather than walking a fully built table. --- src/profile-logic/call-node-info.ts | 53 ++++ src/profile-logic/call-tree.ts | 4 +- src/profile-logic/flame-graph.ts | 414 +++++++++++++++++++++++++++- src/profile-logic/profile-data.ts | 5 +- src/test/unit/profile-tree.test.ts | 248 ++++++++++++++++- 5 files changed, 719 insertions(+), 5 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 87df48bc4c..6aac3a9846 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -915,7 +915,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 @@ -951,7 +951,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 ab85096f4c..cb1d1ddf42 100644 --- a/src/profile-logic/flame-graph.ts +++ b/src/profile-logic/flame-graph.ts @@ -6,13 +6,20 @@ import type { CallNodeTable, FuncTable, IndexIntoCallNodeTable, + IndexIntoFuncTable, + CallNodeSelfAndSummary, } from 'firefox-profiler/types'; import type { StringTable } from 'firefox-profiler/utils/string-table'; import type { CallTreeTimingsInverted, CallTreeTimingsNonInverted, } from './call-tree'; -import type { CallNodeInfoInverted } from './call-node-info'; +import { computeLowerWingCallNodeSelf } from './call-tree'; +import type { + CallNodeInfoInverted, + LowerWingCallNodeInfo, +} from './call-node-info'; +import { computeLowerWingMaxDepthPlusOne } from './call-node-info'; import { bisectionRightByStrKey } from 'firefox-profiler/utils/bisect'; @@ -646,3 +653,408 @@ export function getInvertedFlameGraphTiming( stringTable ); } +/** + * 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. + flameGraphWidthTotal: 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 implements FlameGraphTiming { + _lowerWing: LowerWingCallNodeInfo; + _callNodeSelfAndSummary: CallNodeSelfAndSummary; + _callNodeTable: CallNodeTable; + _selectedFuncIndex: IndexIntoFuncTable | null; + _funcTable: FuncTable; + _stringTable: StringTable; + + _rowCount: number; + _timingRows: FlameGraphTimingRow[]; + + // 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 + ) { + this._lowerWing = lowerWing; + this._callNodeSelfAndSummary = callNodeSelfAndSummary; + this._callNodeTable = callNodeTable; + this._selectedFuncIndex = selectedFuncIndex; + this._funcTable = funcTable; + this._stringTable = stringTable; + + this._rowCount = computeLowerWingMaxDepthPlusOne( + callNodeTable, + selectedFuncIndex + ); + this._timingRows = []; + + this._mappedSelf = null; + this._prefixSums = null; + this._prefixSumsForTableLength = -1; + this._rootTotalSummary = 0; + // Box widths are relative to the wing's own root total (total[0]), so that + // the root fills the full width. Tooltip percentages, on the other hand, + // are relative to all filtered samples, like everywhere else in the UI, so + // the tooltip converts a width ratio into a percentage by multiplying with + // total[0] / . We resolve this lazily + // inside `_ensurePrefixSums` since total[0] comes from the prefix-sums pass. + this._tooltipRatioMultiplier = 0; + + this._rowsCallNodes = []; + this._startPerTableIdx = new Float32Array(16); + this._totalPerTableIdx = new Float64Array(16); + } + + get rowCount(): number { + return this._rowCount; + } + + 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}` + ); + } + while (this._timingRows.length <= depth) { + this._buildNextTimingRow(); + } + return this._timingRows[depth]; + } + + // Convenience method for tests, don't call in production + getAllRowsForTesting(): FlameGraphTimingRow[] { + const rows = []; + for (let depth = 0; depth < this.rowCount; depth++) { + rows.push(this.getRow(depth)); + } + return rows; + } + + getRatioOfRootTotalSummary(depth: number, indexInRow: number): number { + // _getBoxWidth() calls getRow(), which runs _ensurePrefixSums(), so + // _tooltipRatioMultiplier is resolved afterwards. + const ratioOfFullWidth = _getBoxWidth(this, depth, indexInRow); + return ratioOfFullWidth * this._tooltipRatioMultiplier; + } + + _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]; + // The regular tree's total over all filtered samples, i.e. the 100% + // reference for the percentages shown in the tooltip. + const filteredTotalSummary = + this._callNodeSelfAndSummary.rootTotalSummary; + this._tooltipRatioMultiplier = + filteredTotalSummary === 0 + ? 0 + : this._rootTotalSummary / filteredTotalSummary; + } + } + + _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 flameGraphWidthTotal = this._rootTotalSummary; + if (flameGraphWidthTotal === 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 / flameGraphWidthTotal); + const selfRelativeVal = abs(selfVal / flameGraphWidthTotal); + + 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 f0d3354f93..c105688745 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, @@ -874,7 +877,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/unit/profile-tree.test.ts b/src/test/unit/profile-tree.test.ts index 9a439a69ac..fa90960f05 100644 --- a/src/test/unit/profile-tree.test.ts +++ b/src/test/unit/profile-tree.test.ts @@ -11,12 +11,21 @@ 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 { computeFrameTableFromRawFrameTable, computeFuncTableFromRawFuncTable, + computeLowerWingMaxDepthPlusOne, getCallNodeInfo, getInvertedCallNodeInfo, + getLowerWingCallNodeInfo, getOriginAnnotationForFunc, getOriginalPositionForFrame, filterRawThreadSamplesToRange, @@ -142,6 +151,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 bab7dc8773e6cdc9fda5d250feee31a9c9728e31 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Wed, 14 Oct 2026 17:16:42 -0400 Subject: [PATCH 13/19] Key the per-thread call node paths by a CallNodeArea instead of by isInverted. ThreadViewOptions stored the selected and expanded call node paths in four separate fields, one pair for the non-inverted tree and one pair for the inverted tree. Replace them with two records keyed by a CallNodeArea, and pass the area in the CHANGE_SELECTED_CALL_NODE, CHANGE_EXPANDED_CALL_NODES and CHANGE_RIGHT_CLICKED_CALL_NODE actions instead of an isInverted boolean. This doesn't change behavior. It makes it possible to add more areas, each with its own selected, expanded and right-clicked call node path. --- src/actions/profile-view.ts | 18 +- src/reducers/profile-view.ts | 220 ++++++++++-------- src/selectors/per-thread/stack-sample.ts | 19 +- src/selectors/right-clicked-call-node.tsx | 2 + .../__snapshots__/profile-view.test.ts.snap | 38 +-- src/test/store/profile-view.test.ts | 2 + src/types/actions.ts | 6 +- src/types/state.ts | 10 +- 8 files changed, 182 insertions(+), 133 deletions(-) diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 6cf7597242..59601a8299 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -125,7 +125,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, @@ -168,11 +168,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, + }); }; } @@ -1617,7 +1621,7 @@ export function changeExpandedCallNodes( const isInverted = getInvertCallstack(getState()); dispatch({ type: 'CHANGE_EXPANDED_CALL_NODES', - isInverted, + area: isInverted ? 'INVERTED_TREE' : 'NON_INVERTED_TREE', threadsKey, expandedCallNodePaths, }); diff --git a/src/reducers/profile-view.ts b/src/reducers/profile-view.ts index b595c81d20..f3f20200eb 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 { applyFuncSubstitutionToFunc, @@ -185,11 +186,21 @@ const sourceMapSymbolicationStatus: Reducer = ( } }; +const _emptySelectedCallNodePaths: Record = { + NON_INVERTED_TREE: [], + INVERTED_TREE: [], +}; + +function _emptyExpandedCallNodePaths(): Record { + return { + NON_INVERTED_TREE: new PathSet(), + INVERTED_TREE: new PathSet(), + }; +} + export const defaultThreadViewOptions: ThreadViewOptions = { - selectedNonInvertedCallNodePath: [], - selectedInvertedCallNodePath: [], - expandedNonInvertedCallNodePaths: new PathSet(), - expandedInvertedCallNodePaths: new PathSet(), + selectedCallNodePaths: _emptySelectedCallNodePaths, + expandedCallNodePaths: _emptyExpandedCallNodePaths(), selectedNetworkMarker: null, lastSeenTransformCount: 0, }; @@ -231,24 +242,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.selectedCallNodePaths.NON_INVERTED_TREE + ), + INVERTED_TREE: applyFuncSubstitutionToCallPath( oldFuncToNewFuncsMap, - threadViewOptions.expandedNonInvertedCallNodePaths + threadViewOptions.selectedCallNodePaths.INVERTED_TREE ), - expandedInvertedCallNodePaths: - applyFuncSubstitutionToPathSetAndIncludeNewAncestors( + }, + expandedCallNodePaths: { + ...threadViewOptions.expandedCallNodePaths, + NON_INVERTED_TREE: + applyFuncSubstitutionToPathSetAndIncludeNewAncestors( + oldFuncToNewFuncsMap, + threadViewOptions.expandedCallNodePaths.NON_INVERTED_TREE + ), + INVERTED_TREE: applyFuncSubstitutionToPathSetAndIncludeNewAncestors( oldFuncToNewFuncsMap, - threadViewOptions.expandedInvertedCallNodePaths + threadViewOptions.expandedCallNodePaths.INVERTED_TREE ), + }, }; }); @@ -256,17 +272,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 +291,7 @@ const viewOptionsPerThread: Reducer = ( return state; } - let expandedCallNodePaths = isInverted - ? threadState.expandedInvertedCallNodePaths - : threadState.expandedNonInvertedCallNodePaths; + let expandedCallNodePaths = threadState.expandedCallNodePaths[area]; const expandToNode = optionalExpandedToCallNodePath ? optionalExpandedToCallNodePath : selectedCallNodePath; @@ -303,19 +315,16 @@ 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_INVERT_CALLSTACK': { const { @@ -335,32 +344,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; @@ -396,27 +407,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, }); } @@ -424,11 +436,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, }); } @@ -451,10 +470,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, }; } @@ -519,23 +544,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: @@ -761,6 +788,7 @@ const rightClickedCallNode: Reducer = ( if (action.callNodePath !== null) { return { threadsKey: action.threadsKey, + area: action.area, callNodePath: action.callNodePath, }; } diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index ebbf68a882..ee9902510c 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -222,8 +222,8 @@ 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 = @@ -240,8 +240,8 @@ export function getStackAndSampleSelectorsPerThread( UrlState.getInvertCallstack, (threadViewOptions, invertCallStack) => invertCallStack - ? threadViewOptions.expandedInvertedCallNodePaths - : threadViewOptions.expandedNonInvertedCallNodePaths + ? threadViewOptions.expandedCallNodePaths.INVERTED_TREE + : threadViewOptions.expandedCallNodePaths.NON_INVERTED_TREE ); const getExpandedCallNodeIndexes: Selector< @@ -670,9 +670,14 @@ export function getStackAndSampleSelectorsPerThread( rightClickedCallNodeInfo !== null && threadsKey === rightClickedCallNodeInfo.threadsKey ) { - return callNodeInfo.getCallNodeIndexFromPath( - rightClickedCallNodeInfo.callNodePath - ); + const expectedArea = callNodeInfo.isInverted() + ? 'INVERTED_TREE' + : 'NON_INVERTED_TREE'; + if (rightClickedCallNodeInfo.area === expectedArea) { + return callNodeInfo.getCallNodeIndexFromPath( + rightClickedCallNodeInfo.callNodePath + ); + } } return null; 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/test/store/__snapshots__/profile-view.test.ts.snap b/src/test/store/__snapshots__/profile-view.test.ts.snap index 8ae46ca333..4476246966 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -4428,26 +4428,30 @@ 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 {}, + }, + "NON_INVERTED_TREE": PathSet { + "_table": Map { + "0" => Array [ + 0, + ], + "0-1" => Array [ + 0, + 1, + ], + }, }, }, "lastSeenTransformCount": 1, - "selectedInvertedCallNodePath": Array [], + "selectedCallNodePaths": Object { + "INVERTED_TREE": Array [], + "NON_INVERTED_TREE": Array [ + 0, + 1, + ], + }, "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 fdd3646329..e0336dfa34 100644 --- a/src/test/store/profile-view.test.ts +++ b/src/test/store/profile-view.test.ts @@ -3653,6 +3653,7 @@ describe('right clicked call node info', () => { expect(getRightClickedCallNodeInfo(getState())).toEqual({ threadsKey: 0, + area: 'NON_INVERTED_TREE', callNodePath: [0, 1], }); }); @@ -3664,6 +3665,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 e59e601795..cf895c55e1 100644 --- a/src/types/actions.ts +++ b/src/types/actions.ts @@ -47,6 +47,7 @@ import type { TableViewOptions, DecodedInstruction, ProfileEncodingResult, + CallNodeArea, } from './state'; import type { CssPixels, StartEndRange, Milliseconds } from './units'; import type { BrowserConnectionStatus } from '../app-logic/browser-connection'; @@ -190,7 +191,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; @@ -210,6 +211,7 @@ type ProfileAction = | { readonly type: 'CHANGE_RIGHT_CLICKED_CALL_NODE'; readonly threadsKey: ThreadsKey; + readonly area: CallNodeArea; readonly callNodePath: CallNodePath | null; } | { @@ -223,7 +225,7 @@ type ProfileAction = | { readonly type: 'CHANGE_EXPANDED_CALL_NODES'; readonly threadsKey: ThreadsKey; - readonly isInverted: boolean; + readonly area: CallNodeArea; readonly expandedCallNodePaths: Array; } | { diff --git a/src/types/state.ts b/src/types/state.ts index 0a4872e0cb..b679f060da 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -57,11 +57,10 @@ export type SourceMapSymbolicationStatus = | 'INACTIVE' | 'FETCHING' | 'SYMBOLICATING'; + export type ThreadViewOptions = { - readonly selectedNonInvertedCallNodePath: CallNodePath; - readonly selectedInvertedCallNodePath: CallNodePath; - readonly expandedNonInvertedCallNodePaths: PathSet; - readonly expandedInvertedCallNodePaths: PathSet; + 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 @@ -79,8 +78,11 @@ export type TableViewOptions = { export type TableViewOptionsPerTab = { [K in TabSlug]: TableViewOptions }; +export type CallNodeArea = 'NON_INVERTED_TREE' | 'INVERTED_TREE'; + export type RightClickedCallNode = { readonly threadsKey: ThreadsKey; + readonly area: CallNodeArea; readonly callNodePath: CallNodePath; }; From 9a0e0d179f34ef010d4d5018a99fd15faa2077c7 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:55:52 -0400 Subject: [PATCH 14/19] Add the state and selectors for the function list wings. The function list is about to grow "wings" around the selected function: descendants (upper) and ancestors (lower). Each needs its own selected, expanded and right-clicked call node path, so add LOWER_WING and UPPER_WING call node areas next to the two tree areas. The wings are rooted at the selected function. Mirror the selected function into the wing paths whenever it changes, both from the function list and from URL state, so that initial loads and back/forward navigation restore the wings to the right function. This commit adds the state, actions, reducers and call tree selectors; the views that consume them follow. --- src/actions/profile-view.ts | 50 ++++ src/reducers/profile-view.ts | 133 ++++++++- src/selectors/per-thread/stack-sample.ts | 257 ++++++++++++++++-- .../__snapshots__/profile-view.test.ts.snap | 8 + src/test/store/symbolication.test.ts | 38 +++ src/types/state.ts | 10 +- 6 files changed, 477 insertions(+), 19 deletions(-) diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 59601a8299..c5e4e01504 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -77,6 +77,8 @@ import type { SelectionContext, BottomBoxInfo, IndexIntoFuncTable, + CallNodeArea, + WingName, } from 'firefox-profiler/types'; import { funcHasDirectRecursiveCall, @@ -134,6 +136,27 @@ 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. * @@ -191,6 +214,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. @@ -1627,6 +1663,20 @@ export function changeExpandedCallNodes( }); }; } + +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, diff --git a/src/reducers/profile-view.ts b/src/reducers/profile-view.ts index f3f20200eb..cd6aea0df1 100644 --- a/src/reducers/profile-view.ts +++ b/src/reducers/profile-view.ts @@ -32,12 +32,14 @@ import type { TableViewOptions, RightClickedFunction, CallNodeArea, + IndexIntoFuncTable, } from 'firefox-profiler/types'; import { applyFuncSubstitutionToFunc, applyFuncSubstitutionToCallPath, applyFuncSubstitutionToPathSetAndIncludeNewAncestors, } from '../profile-logic/symbolication'; +import type { FuncToFuncsMap } from '../profile-logic/symbolication'; import type { TabSlug } from '../app-logic/tabs-handling'; import { objectMap } from '../utils/types'; @@ -189,12 +191,16 @@ 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(), }; } @@ -229,6 +235,83 @@ function _updateThreadViewOptions( return newState; } +// The function list wings are rooted at the selected function: all of their +// call node paths start with it. When the selected function changes, reset the +// wing paths so that only the new root is selected and expanded. Returns the +// unchanged view options if the wings are already rooted at this function. +function _rootWingsAtFunction( + viewOptions: ThreadViewOptions, + selectedFunctionIndex: IndexIntoFuncTable | null +): ThreadViewOptions { + const previousWingPath = viewOptions.selectedCallNodePaths.LOWER_WING; + const isAlreadyRooted = + selectedFunctionIndex === null + ? previousWingPath.length === 0 + : previousWingPath[0] === selectedFunctionIndex; + if (isAlreadyRooted) { + return viewOptions; + } + + const wingPath: CallNodePath = + selectedFunctionIndex !== null ? [selectedFunctionIndex] : []; + const wingExpanded = + selectedFunctionIndex !== null + ? new PathSet([[selectedFunctionIndex]]) + : new PathSet(); + return { + ...viewOptions, + selectedCallNodePaths: { + ...viewOptions.selectedCallNodePaths, + LOWER_WING: wingPath, + UPPER_WING: wingPath, + }, + expandedCallNodePaths: { + ...viewOptions.expandedCallNodePaths, + LOWER_WING: wingExpanded, + UPPER_WING: wingExpanded, + }, + }; +} + +// The root of a wing path is the selected function, which symbolication maps +// to a single func, like the selectedFunctions URL state. The rest of the path +// is substituted like a regular call node path. +function _applyFuncSubstitutionToWingPath( + oldFuncToNewFuncsMap: FuncToFuncsMap, + path: CallNodePath +): CallNodePath { + if (path.length === 0) { + return path; + } + const [root, ...rest] = path; + return [ + applyFuncSubstitutionToFunc(oldFuncToNewFuncsMap, root), + ...applyFuncSubstitutionToCallPath(oldFuncToNewFuncsMap, rest), + ]; +} + +function _applyFuncSubstitutionToWingPathSet( + oldFuncToNewFuncsMap: FuncToFuncsMap, + pathSet: PathSet +): PathSet { + const newPathSet = new PathSet(); + for (const [root, ...rest] of pathSet) { + const newRoot = applyFuncSubstitutionToFunc(oldFuncToNewFuncsMap, root); + newPathSet.add([newRoot]); + if (rest.length === 0) { + continue; + } + const newRests = applyFuncSubstitutionToPathSetAndIncludeNewAncestors( + oldFuncToNewFuncsMap, + new PathSet([rest]) + ); + for (const newRest of newRests) { + newPathSet.add([newRoot, ...newRest]); + } + } + return newPathSet; +} + const viewOptionsPerThread: Reducer = ( state = {} as ThreadViewOptionsPerThreads, action @@ -252,6 +335,14 @@ const viewOptionsPerThread: Reducer = ( oldFuncToNewFuncsMap, threadViewOptions.selectedCallNodePaths.INVERTED_TREE ), + LOWER_WING: _applyFuncSubstitutionToWingPath( + oldFuncToNewFuncsMap, + threadViewOptions.selectedCallNodePaths.LOWER_WING + ), + UPPER_WING: _applyFuncSubstitutionToWingPath( + oldFuncToNewFuncsMap, + threadViewOptions.selectedCallNodePaths.UPPER_WING + ), }, expandedCallNodePaths: { ...threadViewOptions.expandedCallNodePaths, @@ -264,6 +355,14 @@ const viewOptionsPerThread: Reducer = ( oldFuncToNewFuncsMap, threadViewOptions.expandedCallNodePaths.INVERTED_TREE ), + LOWER_WING: _applyFuncSubstitutionToWingPathSet( + oldFuncToNewFuncsMap, + threadViewOptions.expandedCallNodePaths.LOWER_WING + ), + UPPER_WING: _applyFuncSubstitutionToWingPathSet( + oldFuncToNewFuncsMap, + threadViewOptions.expandedCallNodePaths.UPPER_WING + ), }, }; }); @@ -326,6 +425,19 @@ const viewOptionsPerThread: Reducer = ( }, }); } + case 'CHANGE_SELECTED_FUNCTION': { + const { selectedFunctionIndex, threadsKey } = action; + + const threadState = _getThreadViewOptions(state, threadsKey); + const newThreadState = _rootWingsAtFunction( + threadState, + selectedFunctionIndex + ); + if (newThreadState === threadState) { + return state; + } + return { ...state, [threadsKey]: newThreadState }; + } case 'CHANGE_INVERT_CALLSTACK': { const { newSelectedCallNodePath, @@ -460,8 +572,25 @@ 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 viewOptions = _getThreadViewOptions(newState, threadsKey); + const newViewOptions = _rootWingsAtFunction( + viewOptions, + selectedFunctions[threadsKey] + ); + if (newViewOptions !== viewOptions) { + newState[threadsKey] = newViewOptions; + } + } + + return objectMap(newState, (viewOptions, threadsKey) => { const transformStack = transforms[threadsKey] || []; const newTransformCount = transformStack.length; const oldTransformCount = viewOptions.lastSeenTransformCount; diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index ee9902510c..f3a4fd0aa8 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -43,10 +43,13 @@ import type { State, CallNodeTableBitSet, IndexIntoFuncTable, + IndexIntoStackTable, + CallNodeArea, } from 'firefox-profiler/types'; import type { CallNodeInfo, CallNodeInfoInverted, + LowerWingCallNodeInfo, } from 'firefox-profiler/profile-logic/call-node-info'; import type { SampleRelations } from 'firefox-profiler/profile-logic/profile-data'; @@ -217,6 +220,64 @@ export function getStackAndSampleSelectorsPerThread( 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) => threadViewOptions.selectedCallNodePaths.LOWER_WING + ); + + const getUpperWingSelectedCallNodePath: Selector = + createSelector( + threadSelectors.getViewOptions, + (threadViewOptions) => threadViewOptions.selectedCallNodePaths.UPPER_WING + ); + const getSelectedCallNodePath: Selector = createSelector( threadSelectors.getViewOptions, UrlState.getInvertCallstack, @@ -226,14 +287,18 @@ export function getStackAndSampleSelectorsPerThread( : 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, @@ -244,15 +309,27 @@ export function getStackAndSampleSelectorsPerThread( : 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 _getSampleCallNodesForFilteredCtssThread: Selector< @@ -274,6 +351,27 @@ export function getStackAndSampleSelectorsPerThread( (filteredCallNodes, [b, e], [fb]) => filteredCallNodes.slice(b - fb, e - fb) ); + 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 getSampleCallNodesForFilteredThread: Selector< Array > = createSelector( @@ -454,12 +552,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( _getNonInvertedCallNodeInfo, @@ -530,6 +673,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, @@ -684,6 +847,53 @@ export function getStackAndSampleSelectorsPerThread( } ); + // 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, @@ -706,14 +916,24 @@ export function getStackAndSampleSelectorsPerThread( getWeightTypeForCallTree, getCallNodeInfo, getNonInvertedCallNodeInfo, + getLowerWingCallNodeInfo, + getUpperWingCallNodeInfo, getSourceViewStackLineInfo, getAssemblyViewNativeSymbolIndex, getAssemblyViewStackAddressInfo, getSelectedCallNodePath, getSelectedCallNodeIndex, + getLowerWingSelectedCallNodePath, + getLowerWingSelectedCallNodeIndex, + getUpperWingSelectedCallNodePath, + getUpperWingSelectedCallNodeIndex, getSelectedFunctionIndex, getExpandedCallNodePaths, getExpandedCallNodeIndexes, + getLowerWingExpandedCallNodePaths, + getLowerWingExpandedCallNodeIndexes, + getUpperWingExpandedCallNodePaths, + getUpperWingExpandedCallNodeIndexes, getSampleCallNodesForFilteredThread, getSampleRelationsToSelectedNodeInFilteredThread, getSampleRelationsForFunctionListTab, @@ -726,6 +946,8 @@ export function getStackAndSampleSelectorsPerThread( getNonInvertedCallTree, getFunctionListTree, getFunctionListTimings, + getLowerWingCallTree, + getUpperWingCallTree, getSourceViewLineTimings, getAssemblyViewAddressTimings, getTracedTiming, @@ -738,5 +960,8 @@ export function getStackAndSampleSelectorsPerThread( getFlameGraphTiming, getRightClickedCallNodeIndex, getRightClickedFunctionIndex, + getLowerWingRightClickedCallNodeIndex, + getLowerWingRightClickedFuncIndex, + getUpperWingRightClickedCallNodeIndex, }; } diff --git a/src/test/store/__snapshots__/profile-view.test.ts.snap b/src/test/store/__snapshots__/profile-view.test.ts.snap index 4476246966..4726b25c66 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -4432,6 +4432,9 @@ Object { "INVERTED_TREE": PathSet { "_table": Map {}, }, + "LOWER_WING": PathSet { + "_table": Map {}, + }, "NON_INVERTED_TREE": PathSet { "_table": Map { "0" => Array [ @@ -4443,14 +4446,19 @@ Object { ], }, }, + "UPPER_WING": PathSet { + "_table": Map {}, + }, }, "lastSeenTransformCount": 1, "selectedCallNodePaths": Object { "INVERTED_TREE": Array [], + "LOWER_WING": Array [], "NON_INVERTED_TREE": Array [ 0, 1, ], + "UPPER_WING": Array [], }, "selectedNetworkMarker": null, } diff --git a/src/test/store/symbolication.test.ts b/src/test/store/symbolication.test.ts index 2b40ff9700..bcd5b7fb49 100644 --- a/src/test/store/symbolication.test.ts +++ b/src/test/store/symbolication.test.ts @@ -36,6 +36,8 @@ import { changeExpandedCallNodes, changeImplementationFilter, changeSelectedFunctionIndex, + changeWingSelectedCallNode, + changeWingExpandedCallNodes, } from '../../actions/profile-view'; import { formatTree, formatStack } from '../fixtures/utils'; import { assertSetContainsOnly } from '../fixtures/custom-assertions'; @@ -472,6 +474,42 @@ describe('doSymbolicateProfile', function () { symbolicatedFunc ); }); + + it('symbolicates the function list wing paths', async function () { + const { + store: { dispatch, getState }, + profile, + symbolStore, + funcNamesToFuncIndexes, + } = init(); + + const threadsKey = 0; + const [rootFunc, calleeFunc] = funcNamesToFuncIndexes([ + '0x0000', + '0x2000', + ]); + dispatch(changeSelectedFunctionIndex(threadsKey, rootFunc)); + dispatch( + changeWingSelectedCallNode('upper', threadsKey, [rootFunc, calleeFunc]) + ); + dispatch(changeWingExpandedCallNodes('upper', threadsKey, [[rootFunc]])); + + await doSymbolicateProfile(dispatch, profile, symbolStore); + const [firstSymbol, lastSymbol] = funcNamesToFuncIndexes([ + 'first symbol', + 'last symbol', + ]); + expect( + selectedThreadSelectors.getUpperWingSelectedCallNodePath(getState()) + ).toEqual([firstSymbol, lastSymbol]); + assertSetContainsOnly( + selectedThreadSelectors.getUpperWingExpandedCallNodePaths(getState()), + [[firstSymbol]] + ); + expect( + selectedThreadSelectors.getLowerWingSelectedCallNodePath(getState()) + ).toEqual([firstSymbol]); + }); }); it('can symbolicate a profile with a partial symbol table and re-symbolicate it with a complete symbol table', async () => { diff --git a/src/types/state.ts b/src/types/state.ts index b679f060da..8c697a5805 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -78,7 +78,15 @@ export type TableViewOptions = { export type TableViewOptionsPerTab = { [K in TabSlug]: TableViewOptions }; -export type CallNodeArea = 'NON_INVERTED_TREE' | 'INVERTED_TREE'; +export type CallNodeArea = + | 'NON_INVERTED_TREE' + | 'INVERTED_TREE' + | 'LOWER_WING' + | 'UPPER_WING'; + +// Function-list wings with their own selected/expanded/right-clicked call +// node path. +export type WingName = 'upper' | 'lower'; export type RightClickedCallNode = { readonly threadsKey: ThreadsKey; From 860d895611993fe8605ac6c8ef7c47d34a1ebddb Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:55:52 -0400 Subject: [PATCH 15/19] Add a DisclosureBox component. A collapsible section with a header, used by the function list wings in the next commit. The header can hold extra controls via headerActions. --- 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 2079b6c3237455d807cf1e416c76097ed46abd20 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 3 Sep 2026 15:45:44 -0400 Subject: [PATCH 16/19] Add the upper and lower wing tree views. Render the descendants and ancestors wings next to the function list, each in its own disclosure box. Both are the same TreeView-based component, differing only in which selectors they read and which context menu they open. Whether each disclosure box is open is persisted in the URL. The function list context menu is now shared between the function list and the wings, so rename it to WingContextMenu and give it a second connected variant for the lower wing, whose right-clicked target is a call node rather than a bare function. --- src/actions/profile-view.ts | 12 + src/app-logic/constants.ts | 13 +- src/app-logic/url-handling.ts | 54 +++ src/components/app/Details.tsx | 6 +- src/components/calltree/Butterfly.css | 55 +++ .../calltree/ProfileFunctionListView.tsx | 97 ++++- src/components/calltree/WingTreeView.tsx | 378 ++++++++++++++++++ ...istContextMenu.tsx => WingContextMenu.tsx} | 119 ++++-- src/reducers/url-state.ts | 15 + src/selectors/url-state.ts | 4 + .../FunctionListContextMenu.test.tsx | 2 +- .../components/LowerWingContextMenu.test.tsx | 119 ++++++ src/types/actions.ts | 6 + src/types/state.ts | 8 + 14 files changed, 837 insertions(+), 51 deletions(-) create mode 100644 src/components/calltree/Butterfly.css create mode 100644 src/components/calltree/WingTreeView.tsx rename src/components/shared/{FunctionListContextMenu.tsx => WingContextMenu.tsx} (80%) create mode 100644 src/test/components/LowerWingContextMenu.test.tsx diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index c5e4e01504..a1e2d38ca8 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -79,6 +79,7 @@ import type { IndexIntoFuncTable, CallNodeArea, WingName, + FunctionListSectionName, } from 'firefox-profiler/types'; import { funcHasDirectRecursiveCall, @@ -1759,6 +1760,17 @@ export function changeFunctionListSort( }; } +export function changeFunctionListSectionOpen( + section: FunctionListSectionName, + isOpen: boolean +): Action { + return { + type: 'CHANGE_FUNCTION_LIST_SECTION_OPEN', + section, + isOpen, + }; +} + export function changeNetworkSearchString(searchString: string): Action { return { type: 'CHANGE_NETWORK_SEARCH_STRING', diff --git a/src/app-logic/constants.ts b/src/app-logic/constants.ts index 21eaa55802..7ad8ccbde9 100644 --- a/src/app-logic/constants.ts +++ b/src/app-logic/constants.ts @@ -2,7 +2,10 @@ * 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 type { MarkerPhase } from 'firefox-profiler/types'; +import type { + MarkerPhase, + FunctionListSectionsOpenState, +} from 'firefox-profiler/types'; // The current version of the Gecko profile format. // Please don't forget to update the gecko profile format changelog in @@ -111,3 +114,11 @@ export const INSTANT: MarkerPhase = 0; export const INTERVAL: MarkerPhase = 1; export const INTERVAL_START: MarkerPhase = 2; export const INTERVAL_END: MarkerPhase = 3; + +// The default open/closed state of the function list's sections. The URL +// omits the funcListSections param when the state matches this default. +export const FUNCTION_LIST_SECTIONS_OPEN_DEFAULT: FunctionListSectionsOpenState = + { + descendants: true, + ancestors: false, + }; diff --git a/src/app-logic/url-handling.ts b/src/app-logic/url-handling.ts index 1d3a03ff43..570ea07734 100644 --- a/src/app-logic/url-handling.ts +++ b/src/app-logic/url-handling.ts @@ -23,6 +23,7 @@ import { toValidCallTreeSummaryStrategy, } from 'firefox-profiler/profile-logic/profile-data'; import { oneLine } from 'common-tags'; +import { FUNCTION_LIST_SECTIONS_OPEN_DEFAULT } from './constants'; import type { UrlState, DataSource, @@ -43,6 +44,8 @@ import type { MarkerIndex, SelectedMarkersPerThread, SelectedFunctionsPerThread, + FunctionListSectionsOpenState, + FunctionListSectionName, } from 'firefox-profiler/types'; import { FuncFlag } from 'firefox-profiler/types'; import { @@ -203,6 +206,7 @@ type CallTreeQuery = BaseQuery & { hideIdleSamples: null | undefined; ctSummary: string; functionListSort?: string; // "total-desc~self-asc" — primary first + funcListSections?: string; // "descendants,ancestors" — comma-separated open sections selectedFunc?: number; // Selected function index for the current thread, e.g. 42 }; @@ -254,6 +258,7 @@ type Query = BaseQuery & { // Function list specific functionListSort?: string; + funcListSections?: string; selectedFunc?: number; // Network specific @@ -417,6 +422,9 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { query.functionListSort = convertFunctionListSortToString( urlState.profileSpecific.functionListSort ); + query.funcListSections = convertFunctionListSectionsOpenToString( + urlState.profileSpecific.functionListSectionsOpen + ); const selectedFunc = selectedThreadsKey !== null ? urlState.profileSpecific.selectedFunctions[selectedThreadsKey] @@ -696,6 +704,9 @@ export function stateFromLocation( functionListSort: convertFunctionListSortFromString( query.functionListSort ), + functionListSectionsOpen: convertFunctionListSectionsOpenFromString( + query.funcListSections + ), }, }; } @@ -786,6 +797,49 @@ 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 = [ + 'descendants', + 'ancestors', +]; + +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, + }; + if (raw === 'none' || raw === '') { + return result; + } + for (const part of raw.split(',')) { + if (part === 'descendants' || part === 'ancestors') { + result[part] = true; + } + } + 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/ProfileFunctionListView.tsx b/src/components/calltree/ProfileFunctionListView.tsx index 7695a6c4d2..024dde5877 100644 --- a/src/components/calltree/ProfileFunctionListView.tsx +++ b/src/components/calltree/ProfileFunctionListView.tsx @@ -2,19 +2,92 @@ * 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 { UpperWing, LowerWing } from './WingTreeView'; +import { DisclosureBox } from 'firefox-profiler/components/shared/DisclosureBox'; 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); + }; + + 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/WingTreeView.tsx b/src/components/calltree/WingTreeView.tsx new file mode 100644 index 0000000000..378e9219c6 --- /dev/null +++ b/src/components/calltree/WingTreeView.tsx @@ -0,0 +1,378 @@ +/* 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 { nameColumn, libColumn, treeColumnsForWeightType } from './columns'; +import { + getSearchStringsAsRegExp, + getSelectedThreadsKey, +} 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, + 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 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; +}; + +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; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps> & WingConfigProps; + +class WingTreeViewImpl extends PureComponent { + _treeView: TreeView | null = null; + _takeTreeViewRef = (treeView: TreeView | null) => { + this._treeView = treeView; + }; + 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._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() { + return this._renderCallTree(); + } +} + +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), + }; +} + +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), + }), + 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), + }), + mapDispatchToProps: makeMapDispatchToProps('lower'), + component: LowerWingComponent, +}); diff --git a/src/components/shared/FunctionListContextMenu.tsx b/src/components/shared/WingContextMenu.tsx similarity index 80% rename from src/components/shared/FunctionListContextMenu.tsx rename to src/components/shared/WingContextMenu.tsx index 8625c16999..ebaa1f0927 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 { @@ -140,7 +141,7 @@ const MENU_ITEMS: ReadonlyArray = [ type StateProps = { readonly thread: Thread | null; readonly threadsKey: ThreadsKey | null; - readonly rightClickedFunctionIndex: IndexIntoFuncTable | null; + readonly funcIndex: IndexIntoFuncTable | null; readonly callNodeTable: CallNodeTable | null; readonly implementation: ImplementationFilter; readonly displaySearchfox: boolean; @@ -152,9 +153,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 = () => { @@ -177,20 +182,14 @@ class FunctionListContextMenuImpl extends PureComponent { readonly funcIndex: IndexIntoFuncTable; readonly callNodeTable: CallNodeTable; } { - const { thread, threadsKey, rightClickedFunctionIndex, callNodeTable } = - this.props; + const { thread, threadsKey, funcIndex, callNodeTable } = this.props; if ( thread !== null && threadsKey !== null && - rightClickedFunctionIndex !== null && + funcIndex !== null && callNodeTable !== null ) { - return { - thread, - threadsKey, - funcIndex: rightClickedFunctionIndex, - callNodeTable, - }; + return { thread, threadsKey, funcIndex, callNodeTable }; } return null; } @@ -323,7 +322,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); @@ -432,7 +431,7 @@ class FunctionListContextMenuImpl extends PureComponent { return ( { } } +const dispatchToProps: DispatchProps = { + addTransformToStack, + addCollapseResourceTransformToStack, + setContextMenuVisibility, +}; + +// Connected variant used by the function list: the right-clicked function +// comes from the rightClickedFunction profile-view state. export const FunctionListContextMenu = explicitConnect< {}, StateProps, @@ -452,35 +459,75 @@ export const FunctionListContextMenu = explicitConnect< const rightClickedFunction = getProfileViewOptions(state).rightClickedFunction; - let thread = null; - let threadsKey = null; - let rightClickedFunctionIndex = null; - let callNodeTable = null; + if (rightClickedFunction === null) { + return { + thread: null, + threadsKey: null, + funcIndex: null, + callNodeTable: null, + implementation: getImplementationFilter(state), + displaySearchfox: getShouldDisplaySearchfox(state), + }; + } - if (rightClickedFunction !== null) { - const selectors = getThreadSelectorsFromThreadsKey( - rightClickedFunction.threadsKey - ); - thread = selectors.getFilteredThread(state); - threadsKey = rightClickedFunction.threadsKey; - rightClickedFunctionIndex = rightClickedFunction.functionIndex; + const selectors = getThreadSelectorsFromThreadsKey( + rightClickedFunction.threadsKey + ); + return { + thread: selectors.getFilteredThread(state), + threadsKey: rightClickedFunction.threadsKey, + funcIndex: rightClickedFunction.functionIndex, // Use the non-inverted call node table for recursion detection. - callNodeTable = selectors.getCallNodeInfo(state).getCallNodeTable(); + callNodeTable: selectors.getCallNodeInfo(state).getCallNodeTable(), + implementation: getImplementationFilter(state), + displaySearchfox: getShouldDisplaySearchfox(state), + }; + }, + 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. +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, + callNodeTable: null, + implementation: getImplementationFilter(state), + displaySearchfox: getShouldDisplaySearchfox(state), + }; } + const selectors = getThreadSelectorsFromThreadsKey( + rightClickedCallNodeInfo.threadsKey + ); return { - thread, - threadsKey, - rightClickedFunctionIndex, - callNodeTable, + thread: selectors.getFilteredThread(state), + threadsKey: rightClickedCallNodeInfo.threadsKey, + funcIndex: selectors.getLowerWingRightClickedFuncIndex(state), + // Use the non-inverted call node table for recursion detection. + callNodeTable: selectors.getCallNodeInfo(state).getCallNodeTable(), implementation: getImplementationFilter(state), displaySearchfox: getShouldDisplaySearchfox(state), }; }, - mapDispatchToProps: { - addTransformToStack, - addCollapseResourceTransformToStack, - setContextMenuVisibility, - }, - component: FunctionListContextMenuImpl, + mapDispatchToProps: dispatchToProps, + component: (props) => ( + + ), }); diff --git a/src/reducers/url-state.ts b/src/reducers/url-state.ts index 7ac1e7a3d3..6e26169b79 100644 --- a/src/reducers/url-state.ts +++ b/src/reducers/url-state.ts @@ -6,6 +6,7 @@ import { combineReducers } from 'redux'; import { oneLine } from 'common-tags'; import { objectEntries, objectMap } from '../utils/types'; import { tabSlugs, tabsShowingSampleData } from '../app-logic/tabs-handling'; +import { FUNCTION_LIST_SECTIONS_OPEN_DEFAULT } from '../app-logic/constants'; import type { ThreadIndex, @@ -25,6 +26,7 @@ import type { TabID, SelectedMarkersPerThread, SelectedFunctionsPerThread, + FunctionListSectionsOpenState, } from 'firefox-profiler/types'; import type { TabSlug } from '../app-logic/tabs-handling'; @@ -221,6 +223,18 @@ const functionListSort: Reducer = ( } }; +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 networkSearchString: Reducer = (state = '', action) => { switch (action.type) { case 'CHANGE_NETWORK_SEARCH_STRING': @@ -885,6 +899,7 @@ const profileSpecific = combineReducers({ selectedFunctions, markerTableSort, functionListSort, + functionListSectionsOpen, // 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 413ed8e175..1b85f7ce60 100644 --- a/src/selectors/url-state.ts +++ b/src/selectors/url-state.ts @@ -33,6 +33,7 @@ import type { IndexIntoSourceTable, MarkerIndex, IndexIntoFuncTable, + FunctionListSectionsOpenState, } from 'firefox-profiler/types'; import type { TabSlug } from '../app-logic/tabs-handling'; @@ -125,6 +126,9 @@ export const getMarkerTableSort: Selector = ( export const getFunctionListSort: Selector = ( state ) => getProfileSpecificState(state).functionListSort; +export const getFunctionListSectionsOpen: Selector< + FunctionListSectionsOpenState +> = (state) => getProfileSpecificState(state).functionListSectionsOpen; export const getNetworkSearchString: Selector = (state) => getProfileSpecificState(state).networkSearchString; export const getSelectedTab: Selector = (state) => 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/types/actions.ts b/src/types/actions.ts index cf895c55e1..6665bd470a 100644 --- a/src/types/actions.ts +++ b/src/types/actions.ts @@ -48,6 +48,7 @@ import type { DecodedInstruction, ProfileEncodingResult, CallNodeArea, + FunctionListSectionName, } from './state'; import type { CssPixels, StartEndRange, Milliseconds } from './units'; import type { BrowserConnectionStatus } from '../app-logic/browser-connection'; @@ -584,6 +585,11 @@ type UrlStateAction = readonly type: 'CHANGE_FUNCTION_LIST_SORT'; readonly sort: SingleColumnSortState[] | null; } + | { + readonly type: 'CHANGE_FUNCTION_LIST_SECTION_OPEN'; + readonly section: FunctionListSectionName; + readonly isOpen: boolean; + } | { readonly type: 'CHANGE_NETWORK_SEARCH_STRING'; readonly searchString: string; diff --git a/src/types/state.ts b/src/types/state.ts index 8c697a5805..3a851776cc 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -410,8 +410,16 @@ export type ProfileSpecificUrlState = { selectedFunctions: SelectedFunctionsPerThread; markerTableSort: SingleColumnSortState[] | null; functionListSort: SingleColumnSortState[] | null; + functionListSectionsOpen: FunctionListSectionsOpenState; }; +export type FunctionListSectionsOpenState = { + descendants: boolean; + ancestors: boolean; +}; + +export type FunctionListSectionName = keyof FunctionListSectionsOpenState; + export type UrlState = { readonly dataSource: DataSource; // This is used for the "public" dataSource". From 9c0736a564d4d4190a346b2cb6d80ba3674d80b2 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 3 Sep 2026 15:45:38 -0400 Subject: [PATCH 17/19] Add flame graph views for the function list wings. The upper and lower wings can now be displayed either as a call tree or as a flame graph, and WingViewToggle in the disclosure box header switches between them. The choice is per-wing and persisted in the URL. --- src/actions/profile-view.ts | 10 + src/app-logic/constants.ts | 8 + src/app-logic/url-handling.ts | 47 ++- .../calltree/LowerWingFlameGraph.tsx | 267 +++++++++++++++++ .../calltree/ProfileFunctionListView.tsx | 3 + .../calltree/UpperWingFlameGraph.tsx | 281 ++++++++++++++++++ src/components/calltree/WingTreeView.tsx | 54 +++- src/components/calltree/WingViewToggle.css | 44 +++ src/components/calltree/WingViewToggle.tsx | 119 ++++++++ src/reducers/url-state.ts | 19 +- src/selectors/per-thread/stack-sample.ts | 62 ++++ src/selectors/url-state.ts | 8 + src/types/actions.ts | 7 + src/types/state.ts | 10 + 14 files changed, 934 insertions(+), 5 deletions(-) create mode 100644 src/components/calltree/LowerWingFlameGraph.tsx create mode 100644 src/components/calltree/UpperWingFlameGraph.tsx create mode 100644 src/components/calltree/WingViewToggle.css create mode 100644 src/components/calltree/WingViewToggle.tsx diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index a1e2d38ca8..13361af37f 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -80,6 +80,8 @@ import type { CallNodeArea, WingName, FunctionListSectionName, + WingViewName, + WingViewType, } from 'firefox-profiler/types'; import { funcHasDirectRecursiveCall, @@ -1771,6 +1773,14 @@ export function changeFunctionListSectionOpen( }; } +export function changeWingView(wing: WingViewName, view: WingViewType): 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/constants.ts b/src/app-logic/constants.ts index 7ad8ccbde9..5ebfbf8ebf 100644 --- a/src/app-logic/constants.ts +++ b/src/app-logic/constants.ts @@ -5,6 +5,7 @@ import type { MarkerPhase, FunctionListSectionsOpenState, + WingViewsState, } from 'firefox-profiler/types'; // The current version of the Gecko profile format. @@ -122,3 +123,10 @@ export const FUNCTION_LIST_SECTIONS_OPEN_DEFAULT: FunctionListSectionsOpenState descendants: true, ancestors: false, }; + +// The default view for each function list wing. The URL omits the wingViews +// param when all wings use their default view. +export const WING_VIEWS_DEFAULT: WingViewsState = { + upper: 'flame-graph', + lower: 'flame-graph', +}; diff --git a/src/app-logic/url-handling.ts b/src/app-logic/url-handling.ts index 570ea07734..6eb15929df 100644 --- a/src/app-logic/url-handling.ts +++ b/src/app-logic/url-handling.ts @@ -23,7 +23,10 @@ import { toValidCallTreeSummaryStrategy, } from 'firefox-profiler/profile-logic/profile-data'; import { oneLine } from 'common-tags'; -import { FUNCTION_LIST_SECTIONS_OPEN_DEFAULT } from './constants'; +import { + FUNCTION_LIST_SECTIONS_OPEN_DEFAULT, + WING_VIEWS_DEFAULT, +} from './constants'; import type { UrlState, DataSource, @@ -46,6 +49,8 @@ import type { SelectedFunctionsPerThread, FunctionListSectionsOpenState, FunctionListSectionName, + WingViewName, + WingViewsState, } from 'firefox-profiler/types'; import { FuncFlag } from 'firefox-profiler/types'; import { @@ -207,6 +212,7 @@ type CallTreeQuery = BaseQuery & { ctSummary: string; functionListSort?: string; // "total-desc~self-asc" — primary first funcListSections?: string; // "descendants,ancestors" — 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 }; @@ -259,6 +265,7 @@ type Query = BaseQuery & { // Function list specific functionListSort?: string; funcListSections?: string; + wingViews?: string; selectedFunc?: number; // Network specific @@ -425,6 +432,9 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { query.funcListSections = convertFunctionListSectionsOpenToString( urlState.profileSpecific.functionListSectionsOpen ); + query.wingViews = convertWingViewsToString( + urlState.profileSpecific.wingViews + ); const selectedFunc = selectedThreadsKey !== null ? urlState.profileSpecific.selectedFunctions[selectedThreadsKey] @@ -707,6 +717,7 @@ export function stateFromLocation( functionListSectionsOpen: convertFunctionListSectionsOpenFromString( query.funcListSections ), + wingViews: convertWingViewsFromString(query.wingViews), }, }; } @@ -840,6 +851,40 @@ function convertFunctionListSectionsOpenFromString( return result; } +// WingView visualization choice for the Descendants ("upper") and Ancestors +// ("lower") 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_VIEW_NAMES: ReadonlyArray = ['upper', 'lower']; + +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') && + (view === 'flame-graph' || view === 'call-tree') + ) { + result[wing] = view; + } + } + return result; +} + function convertGlobalTrackOrderFromString( rawString: string | null | void ): TrackIndex[] { diff --git a/src/components/calltree/LowerWingFlameGraph.tsx b/src/components/calltree/LowerWingFlameGraph.tsx new file mode 100644 index 0000000000..80db9ee350 --- /dev/null +++ b/src/components/calltree/LowerWingFlameGraph.tsx @@ -0,0 +1,267 @@ +/* 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 ctssSampleCallNodes: Array; + 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, + ctssSampleCallNodes, + 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 + ), + ctssSampleCallNodes: + selectedThreadSelectors.getPreviewFilteredCtssSampleCallNodes(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 024dde5877..9edd1eec13 100644 --- a/src/components/calltree/ProfileFunctionListView.tsx +++ b/src/components/calltree/ProfileFunctionListView.tsx @@ -8,6 +8,7 @@ import explicitConnect from 'firefox-profiler/utils/connect'; import { FunctionList } from './FunctionList'; 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'; @@ -61,6 +62,7 @@ class ProfileFunctionListViewImpl extends React.PureComponent { label="Descendants" isOpen={sectionsOpen.descendants} onToggle={this._onDescendantsToggle} + headerActions={} > @@ -68,6 +70,7 @@ class ProfileFunctionListViewImpl extends React.PureComponent { label="Ancestors" isOpen={sectionsOpen.ancestors} onToggle={this._onAncestorsToggle} + headerActions={} > diff --git a/src/components/calltree/UpperWingFlameGraph.tsx b/src/components/calltree/UpperWingFlameGraph.tsx new file mode 100644 index 0000000000..0ce7a8b352 --- /dev/null +++ b/src/components/calltree/UpperWingFlameGraph.tsx @@ -0,0 +1,281 @@ +/* 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 ctssSampleCallNodes: Array; + 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, + ctssSampleCallNodes, + 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 + ), + ctssSampleCallNodes: + selectedThreadSelectors.getPreviewFilteredCtssSampleUpperWingCallNodes( + 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 index 378e9219c6..e6141e5bad 100644 --- a/src/components/calltree/WingTreeView.tsx +++ b/src/components/calltree/WingTreeView.tsx @@ -7,10 +7,20 @@ 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, @@ -37,6 +47,7 @@ import type { WeightType, TableViewOptions, SelectionContext, + WingViewType, CallNodePath, WingName, } from 'firefox-profiler/types'; @@ -49,6 +60,7 @@ 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; @@ -64,6 +76,7 @@ type StateProps = { readonly callNodeMaxDepthPlusOne: number; readonly weightType: WeightType; readonly tableViewOptions: TableViewOptions; + readonly view: WingViewType; }; type DispatchProps = { @@ -90,6 +103,9 @@ type DispatchProps = { // each wrapper below. type WingConfigProps = { readonly contextMenuId: string; + readonly renderFlameGraph: ( + ref: React.RefObject + ) => React.ReactNode; }; type Props = ConnectedProps<{}, StateProps, DispatchProps> & WingConfigProps; @@ -99,6 +115,9 @@ class WingTreeViewImpl extends PureComponent { _takeTreeViewRef = (treeView: TreeView | null) => { this._treeView = treeView; }; + _flameGraphRef: React.RefObject = + React.createRef(); + override componentDidMount() { this.focus(); this.maybeProcureInterestingInitialSelection(); @@ -122,6 +141,10 @@ class WingTreeViewImpl extends PureComponent { } focus() { + if (this.props.view === 'flame-graph') { + this._flameGraphRef.current?.focus(); + return; + } if (this._treeView) { this._treeView.focus(); } @@ -287,7 +310,10 @@ class WingTreeViewImpl extends PureComponent { } override render() { - return this._renderCallTree(); + if (this.props.view === 'call-tree') { + return this._renderCallTree(); + } + return this.props.renderFlameGraph(this._flameGraphRef); } } @@ -314,16 +340,36 @@ function makeMapDispatchToProps(wing: WingName) { }; } +const renderUpperWingFlameGraph = ( + ref: React.RefObject +) => } />; + +const renderLowerWingFlameGraph = ( + ref: React.RefObject +) => } />; + function UpperWingComponent( props: ConnectedProps<{}, StateProps, DispatchProps> ) { - return ; + return ( + + ); } function LowerWingComponent( props: ConnectedProps<{}, StateProps, DispatchProps> ) { - return ; + return ( + + ); } export const UpperWing = explicitConnect<{}, StateProps, DispatchProps>({ @@ -348,6 +394,7 @@ export const UpperWing = explicitConnect<{}, StateProps, DispatchProps>({ selectedThreadSelectors.getFilteredCallNodeMaxDepthPlusOne(state), weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), tableViewOptions: getCurrentTableViewOptions(state), + view: getUpperWingView(state), }), mapDispatchToProps: makeMapDispatchToProps('upper'), component: UpperWingComponent, @@ -372,6 +419,7 @@ export const LowerWing = explicitConnect<{}, StateProps, DispatchProps>({ 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..cf809565ec --- /dev/null +++ b/src/components/calltree/WingViewToggle.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 * as React from 'react'; + +import explicitConnect from 'firefox-profiler/utils/connect'; +import { + getUpperWingView, + getLowerWingView, +} from 'firefox-profiler/selectors/url-state'; +import { changeWingView } from 'firefox-profiler/actions/profile-view'; +import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; + +import type { State, WingViewName, WingViewType } from 'firefox-profiler/types'; +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +import './WingViewToggle.css'; + +type OwnProps = { + readonly wing: WingViewName; +}; + +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; + default: + throw assertExhaustiveCheck(ownProps.wing, 'Unhandled wing.'); + } + return { view }; + }, + mapDispatchToProps: { + changeWingView, + }, + component: WingViewToggleImpl, +}); diff --git a/src/reducers/url-state.ts b/src/reducers/url-state.ts index 6e26169b79..ebff4b090f 100644 --- a/src/reducers/url-state.ts +++ b/src/reducers/url-state.ts @@ -6,7 +6,10 @@ import { combineReducers } from 'redux'; import { oneLine } from 'common-tags'; import { objectEntries, objectMap } from '../utils/types'; import { tabSlugs, tabsShowingSampleData } from '../app-logic/tabs-handling'; -import { FUNCTION_LIST_SECTIONS_OPEN_DEFAULT } from '../app-logic/constants'; +import { + FUNCTION_LIST_SECTIONS_OPEN_DEFAULT, + WING_VIEWS_DEFAULT, +} from '../app-logic/constants'; import type { ThreadIndex, @@ -27,6 +30,7 @@ import type { SelectedMarkersPerThread, SelectedFunctionsPerThread, FunctionListSectionsOpenState, + WingViewsState, } from 'firefox-profiler/types'; import type { TabSlug } from '../app-logic/tabs-handling'; @@ -235,6 +239,18 @@ const functionListSectionsOpen: Reducer = ( } }; +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': @@ -900,6 +916,7 @@ const profileSpecific = combineReducers({ 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 f3a4fd0aa8..87f1bdcde7 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -824,6 +824,63 @@ export function getStackAndSampleSelectorsPerThread( ? getFlameGraphTimingInverted(state) : getFlameGraphTimingNonInverted(state); + 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 + ); + const getRightClickedCallNodeIndex: Selector = createSelector( getRightClickedCallNodeInfo, @@ -918,6 +975,8 @@ export function getStackAndSampleSelectorsPerThread( getNonInvertedCallNodeInfo, getLowerWingCallNodeInfo, getUpperWingCallNodeInfo, + getPreviewFilteredCtssSampleUpperWingCallNodes: + _getPreviewFilteredCtssSampleIndexToUpperWingCallNodeIndex, getSourceViewStackLineInfo, getAssemblyViewNativeSymbolIndex, getAssemblyViewStackAddressInfo, @@ -948,6 +1007,9 @@ export function getStackAndSampleSelectorsPerThread( getFunctionListTimings, getLowerWingCallTree, getUpperWingCallTree, + getUpperWingFlameGraphTiming, + getLowerWingFlameGraphTiming, + getLowerWingFlameGraphMaxDepthPlusOne, getSourceViewLineTimings, getAssemblyViewAddressTimings, getTracedTiming, diff --git a/src/selectors/url-state.ts b/src/selectors/url-state.ts index 1b85f7ce60..84097b6118 100644 --- a/src/selectors/url-state.ts +++ b/src/selectors/url-state.ts @@ -34,6 +34,8 @@ import type { MarkerIndex, IndexIntoFuncTable, FunctionListSectionsOpenState, + WingViewsState, + WingViewType, } from 'firefox-profiler/types'; import type { TabSlug } from '../app-logic/tabs-handling'; @@ -129,6 +131,12 @@ export const getFunctionListSort: Selector = ( 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 getNetworkSearchString: Selector = (state) => getProfileSpecificState(state).networkSearchString; export const getSelectedTab: Selector = (state) => diff --git a/src/types/actions.ts b/src/types/actions.ts index 6665bd470a..fb2becb8fc 100644 --- a/src/types/actions.ts +++ b/src/types/actions.ts @@ -49,6 +49,8 @@ import type { ProfileEncodingResult, CallNodeArea, FunctionListSectionName, + WingViewName, + WingViewType, } from './state'; import type { CssPixels, StartEndRange, Milliseconds } from './units'; import type { BrowserConnectionStatus } from '../app-logic/browser-connection'; @@ -590,6 +592,11 @@ type UrlStateAction = readonly section: FunctionListSectionName; readonly isOpen: boolean; } + | { + readonly type: 'CHANGE_WING_VIEW'; + readonly wing: WingViewName; + readonly view: WingViewType; + } | { readonly type: 'CHANGE_NETWORK_SEARCH_STRING'; readonly searchString: string; diff --git a/src/types/state.ts b/src/types/state.ts index 3a851776cc..953f23e8d8 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -411,6 +411,7 @@ export type ProfileSpecificUrlState = { markerTableSort: SingleColumnSortState[] | null; functionListSort: SingleColumnSortState[] | null; functionListSectionsOpen: FunctionListSectionsOpenState; + wingViews: WingViewsState; }; export type FunctionListSectionsOpenState = { @@ -420,6 +421,15 @@ export type FunctionListSectionsOpenState = { export type FunctionListSectionName = keyof FunctionListSectionsOpenState; +export type WingViewType = 'flame-graph' | 'call-tree'; + +export type WingViewsState = { + upper: WingViewType; + lower: WingViewType; +}; + +export type WingViewName = keyof WingViewsState; + export type UrlState = { readonly dataSource: DataSource; // This is used for the "public" dataSource". From 63233e3788ed87b101aa81205687ef35c18600c5 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 3 Sep 2026 15:45:44 -0400 Subject: [PATCH 18/19] Add the self wing. The self wing shows where the selected function spends its own self time. It is built from the focusSelf-filtered thread, taken before the implementation filter so that native frames inside a JS function's self time stay visible when the filter is set to "JS only". Like the other wings, it has its own disclosure box and can be displayed as a call tree or as a flame graph. The function list context menu now also checks the self wing's call node table for recursion, because the focusSelf filter can reveal recursion that the regular call node table hides. --- src/app-logic/constants.ts | 2 + src/app-logic/url-handling.ts | 20 +- .../calltree/ProfileFunctionListView.tsx | 12 + src/components/calltree/SelfWing.tsx | 338 ++++++++++++++++++ src/components/calltree/WingViewToggle.tsx | 4 + src/components/shared/WingContextMenu.tsx | 60 ++-- src/selectors/per-thread/stack-sample.ts | 161 +++++++++ src/selectors/url-state.ts | 2 + src/types/state.ts | 7 +- 9 files changed, 569 insertions(+), 37 deletions(-) create mode 100644 src/components/calltree/SelfWing.tsx diff --git a/src/app-logic/constants.ts b/src/app-logic/constants.ts index 5ebfbf8ebf..403ee3420d 100644 --- a/src/app-logic/constants.ts +++ b/src/app-logic/constants.ts @@ -122,6 +122,7 @@ export const FUNCTION_LIST_SECTIONS_OPEN_DEFAULT: FunctionListSectionsOpenState { descendants: true, ancestors: false, + self: false, }; // The default view for each function list wing. The URL omits the wingViews @@ -129,4 +130,5 @@ export const FUNCTION_LIST_SECTIONS_OPEN_DEFAULT: FunctionListSectionsOpenState export const WING_VIEWS_DEFAULT: WingViewsState = { upper: 'flame-graph', lower: 'flame-graph', + self: 'flame-graph', }; diff --git a/src/app-logic/url-handling.ts b/src/app-logic/url-handling.ts index 6eb15929df..44da693be0 100644 --- a/src/app-logic/url-handling.ts +++ b/src/app-logic/url-handling.ts @@ -211,7 +211,7 @@ type CallTreeQuery = BaseQuery & { hideIdleSamples: null | undefined; ctSummary: string; functionListSort?: string; // "total-desc~self-asc" — primary first - funcListSections?: string; // "descendants,ancestors" — comma-separated open sections + 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 }; @@ -815,6 +815,7 @@ function convertFunctionListSortFromString( const FUNCTION_LIST_SECTION_NAMES: ReadonlyArray = [ 'descendants', 'ancestors', + 'self', ]; function convertFunctionListSectionsOpenToString( @@ -839,24 +840,25 @@ function convertFunctionListSectionsOpenFromString( 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') { + if (part === 'descendants' || part === 'ancestors' || part === 'self') { result[part] = true; } } return result; } -// WingView visualization choice for the Descendants ("upper") and Ancestors -// ("lower") 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_VIEW_NAMES: ReadonlyArray = ['upper', 'lower']; +// 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_VIEW_NAMES: ReadonlyArray = ['upper', 'lower', 'self']; function convertWingViewsToString(state: WingViewsState): string | undefined { const parts: string[] = []; @@ -876,7 +878,7 @@ function convertWingViewsFromString(raw: string | null | void): WingViewsState { for (const part of raw.split(',')) { const [wing, view] = part.split(':'); if ( - (wing === 'upper' || wing === 'lower') && + (wing === 'upper' || wing === 'lower' || wing === 'self') && (view === 'flame-graph' || view === 'call-tree') ) { result[wing] = view; diff --git a/src/components/calltree/ProfileFunctionListView.tsx b/src/components/calltree/ProfileFunctionListView.tsx index 9edd1eec13..a3c2f0900b 100644 --- a/src/components/calltree/ProfileFunctionListView.tsx +++ b/src/components/calltree/ProfileFunctionListView.tsx @@ -6,6 +6,7 @@ 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'; @@ -37,6 +38,9 @@ class ProfileFunctionListViewImpl extends React.PureComponent { _onAncestorsToggle = (isOpen: boolean) => { this.props.changeFunctionListSectionOpen('ancestors', isOpen); }; + _onSelfToggle = (isOpen: boolean) => { + this.props.changeFunctionListSectionOpen('self', isOpen); + }; override render() { const { sectionsOpen } = this.props; @@ -74,6 +78,14 @@ class ProfileFunctionListViewImpl extends React.PureComponent { > + } + > + +
diff --git a/src/components/calltree/SelfWing.tsx b/src/components/calltree/SelfWing.tsx new file mode 100644 index 0000000000..7842bc9315 --- /dev/null +++ b/src/components/calltree/SelfWing.tsx @@ -0,0 +1,338 @@ +/* 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 ctssSampleCallNodes: Array; + 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, + ctssSampleCallNodes, + 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 + ), + ctssSampleCallNodes: + selectedThreadSelectors.getSelfWingCtssSampleCallNodes(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/WingViewToggle.tsx b/src/components/calltree/WingViewToggle.tsx index cf809565ec..1abfb49ef7 100644 --- a/src/components/calltree/WingViewToggle.tsx +++ b/src/components/calltree/WingViewToggle.tsx @@ -8,6 +8,7 @@ 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'; @@ -107,6 +108,9 @@ export const WingViewToggle = explicitConnect< case 'lower': view = getLowerWingView(state); break; + case 'self': + view = getSelfWingView(state); + break; default: throw assertExhaustiveCheck(ownProps.wing, 'Unhandled wing.'); } diff --git a/src/components/shared/WingContextMenu.tsx b/src/components/shared/WingContextMenu.tsx index ebaa1f0927..efa9e18310 100644 --- a/src/components/shared/WingContextMenu.tsx +++ b/src/components/shared/WingContextMenu.tsx @@ -53,7 +53,7 @@ import './CallNodeContextMenu.css'; // label, and l10n vars from the current right-clicked function. type MenuItemContext = { readonly funcIndex: IndexIntoFuncTable; - readonly callNodeTable: CallNodeTable; + readonly callNodeTables: ReadonlyArray; readonly nameForResource: string | null; }; @@ -117,8 +117,8 @@ const MENU_ITEMS: ReadonlyArray = [ icon: 'Collapse', l10nId: 'CallNodeContextMenu--transform-collapse-recursion', content: () => 'Collapse recursion', - visible: ({ funcIndex, callNodeTable }) => - funcHasRecursiveCall(callNodeTable, funcIndex), + visible: ({ funcIndex, callNodeTables }) => + callNodeTables.some((t) => funcHasRecursiveCall(t, funcIndex)), }, { transform: 'collapse-direct-recursion', @@ -126,8 +126,8 @@ const MENU_ITEMS: ReadonlyArray = [ icon: 'Collapse', l10nId: 'CallNodeContextMenu--transform-collapse-direct-recursion-only', content: () => 'Collapse direct recursion only', - visible: ({ funcIndex, callNodeTable }) => - funcHasDirectRecursiveCall(callNodeTable, funcIndex), + visible: ({ funcIndex, callNodeTables }) => + callNodeTables.some((t) => funcHasDirectRecursiveCall(t, funcIndex)), }, { transform: 'drop-function', @@ -142,7 +142,10 @@ type StateProps = { readonly thread: Thread | null; readonly threadsKey: ThreadsKey | null; readonly funcIndex: IndexIntoFuncTable | null; - readonly callNodeTable: CallNodeTable | 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; }; @@ -180,16 +183,10 @@ class WingContextMenuImpl extends PureComponent { readonly thread: Thread; readonly threadsKey: ThreadsKey; readonly funcIndex: IndexIntoFuncTable; - readonly callNodeTable: CallNodeTable; } { - const { thread, threadsKey, funcIndex, callNodeTable } = this.props; - if ( - thread !== null && - threadsKey !== null && - funcIndex !== null && - callNodeTable !== null - ) { - return { thread, threadsKey, funcIndex, callNodeTable }; + const { thread, threadsKey, funcIndex } = this.props; + if (thread !== null && threadsKey !== null && funcIndex !== null) { + return { thread, threadsKey, funcIndex }; } return null; } @@ -379,7 +376,7 @@ class WingContextMenuImpl extends PureComponent { } renderContextMenuContents() { - const { displaySearchfox } = this.props; + const { displaySearchfox, callNodeTables } = this.props; const info = this._getRightClickedInfo(); if (info === null) { @@ -391,7 +388,7 @@ class WingContextMenuImpl extends PureComponent { const ctx: MenuItemContext = { funcIndex: info.funcIndex, - callNodeTable: info.callNodeTable, + callNodeTables, nameForResource: this.getNameForSelectedResource(), }; @@ -448,8 +445,11 @@ const dispatchToProps: DispatchProps = { setContextMenuVisibility, }; -// Connected variant used by the function list: the right-clicked function -// comes from the rightClickedFunction profile-view state. +// 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, @@ -464,7 +464,7 @@ export const FunctionListContextMenu = explicitConnect< thread: null, threadsKey: null, funcIndex: null, - callNodeTable: null, + callNodeTables: [], implementation: getImplementationFilter(state), displaySearchfox: getShouldDisplaySearchfox(state), }; @@ -473,12 +473,16 @@ export const FunctionListContextMenu = explicitConnect< const selectors = getThreadSelectorsFromThreadsKey( rightClickedFunction.threadsKey ); + const callNodeTables: CallNodeTable[] = [ + selectors.getCallNodeInfo(state).getCallNodeTable(), + selectors.getSelfWingCallNodeInfo(state).getCallNodeTable(), + ]; + return { thread: selectors.getFilteredThread(state), threadsKey: rightClickedFunction.threadsKey, funcIndex: rightClickedFunction.functionIndex, - // Use the non-inverted call node table for recursion detection. - callNodeTable: selectors.getCallNodeInfo(state).getCallNodeTable(), + callNodeTables, implementation: getImplementationFilter(state), displaySearchfox: getShouldDisplaySearchfox(state), }; @@ -490,7 +494,8 @@ export const FunctionListContextMenu = explicitConnect< }); // Connected variant used by the lower wing: the right-clicked function comes -// from a right-clicked call node in the LOWER_WING area. +// 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, @@ -507,7 +512,7 @@ export const LowerWingContextMenu = explicitConnect< thread: null, threadsKey: null, funcIndex: null, - callNodeTable: null, + callNodeTables: [], implementation: getImplementationFilter(state), displaySearchfox: getShouldDisplaySearchfox(state), }; @@ -516,12 +521,15 @@ export const LowerWingContextMenu = explicitConnect< const selectors = getThreadSelectorsFromThreadsKey( rightClickedCallNodeInfo.threadsKey ); + const callNodeTables: CallNodeTable[] = [ + selectors.getCallNodeInfo(state).getCallNodeTable(), + ]; + return { thread: selectors.getFilteredThread(state), threadsKey: rightClickedCallNodeInfo.threadsKey, funcIndex: selectors.getLowerWingRightClickedFuncIndex(state), - // Use the non-inverted call node table for recursion detection. - callNodeTable: selectors.getCallNodeInfo(state).getCallNodeTable(), + callNodeTables, implementation: getImplementationFilter(state), displaySearchfox: getShouldDisplaySearchfox(state), }; diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index 87f1bdcde7..a47c505495 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'; @@ -44,6 +45,8 @@ import type { CallNodeTableBitSet, IndexIntoFuncTable, IndexIntoStackTable, + SamplesLikeTable, + SampleCategoriesAndSubcategories, CallNodeArea, } from 'firefox-profiler/types'; import type { @@ -881,6 +884,156 @@ export function getStackAndSampleSelectorsPerThread( ProfileData.computeLowerWingMaxDepthPlusOne ); + // Self wing: previewFiltered(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". Preview filtering is applied + // AFTER focusSelf so that dragging a selection only re-slices sample arrays + // instead of re-running the expensive focusSelf pass over the stack table. + const _getFocusedSelfWingThread: Selector = createSelector( + threadSelectors.getRangeAndTransformFilteredThread, + getSelectedFunctionIndex, + UrlState.getImplementationFilter, + (thread, funcIndex, implFilter) => { + if (funcIndex === null) { + return thread; + } + return Transforms.focusSelf(thread, funcIndex, implFilter); + } + ); + + const getSelfWingThread: Selector = createSelector( + _getFocusedSelfWingThread, + ProfileSelectors.getPreviewSelection, + (thread, previewSelection): Thread => { + if (!previewSelection) { + return thread; + } + const { selectionStart, selectionEnd } = previewSelection; + return ProfileData.filterThreadSamplesToRange( + thread, + selectionStart, + selectionEnd + ); + } + ); + + 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 getSelfWingCtssSampleCallNodes: Selector< + Array + > = _getSelfWingSampleIndexToCallNodeIndex; + + const getSelfWingCtssSampleCategoriesAndSubcategories: Selector = + createSelector( + getSelfWingThread, + _getSelfWingCtssSamples, + ProfileSelectors.getDefaultCategory, + CallTree.computeUnfilteredCtssSampleCategoriesAndSubcategories + ); + const getRightClickedCallNodeIndex: Selector = createSelector( getRightClickedCallNodeInfo, @@ -1010,6 +1163,14 @@ export function getStackAndSampleSelectorsPerThread( getUpperWingFlameGraphTiming, getLowerWingFlameGraphTiming, getLowerWingFlameGraphMaxDepthPlusOne, + getSelfWingThread, + getSelfWingCallNodeInfo, + getSelfWingCallTree, + getSelfWingFlameGraphTiming, + getSelfWingCallNodeMaxDepthPlusOne, + getSelfWingCtssSamples, + getSelfWingCtssSampleCallNodes, + getSelfWingCtssSampleCategoriesAndSubcategories, getSourceViewLineTimings, getAssemblyViewAddressTimings, getTracedTiming, diff --git a/src/selectors/url-state.ts b/src/selectors/url-state.ts index 84097b6118..fb62961730 100644 --- a/src/selectors/url-state.ts +++ b/src/selectors/url-state.ts @@ -137,6 +137,8 @@ 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) => diff --git a/src/types/state.ts b/src/types/state.ts index 953f23e8d8..af149abcb3 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -84,8 +84,9 @@ export type CallNodeArea = | 'LOWER_WING' | 'UPPER_WING'; -// Function-list wings with their own selected/expanded/right-clicked call -// node path. +// 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 = { @@ -417,6 +418,7 @@ export type ProfileSpecificUrlState = { export type FunctionListSectionsOpenState = { descendants: boolean; ancestors: boolean; + self: boolean; }; export type FunctionListSectionName = keyof FunctionListSectionsOpenState; @@ -426,6 +428,7 @@ export type WingViewType = 'flame-graph' | 'call-tree'; export type WingViewsState = { upper: WingViewType; lower: WingViewType; + self: WingViewType; }; export type WingViewName = keyof WingViewsState; From 46d1082fe3aa7414de762834d66b1656a6037214 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Wed, 14 Oct 2026 19:33:37 -0400 Subject: [PATCH 19/19] Improve the wing view toggle icons. --- .../calltree/ProfileFunctionListView.tsx | 12 ++- src/components/calltree/WingViewToggle.css | 4 + src/components/calltree/WingViewToggle.tsx | 84 +++++++++++++------ 3 files changed, 73 insertions(+), 27 deletions(-) diff --git a/src/components/calltree/ProfileFunctionListView.tsx b/src/components/calltree/ProfileFunctionListView.tsx index a3c2f0900b..9aff5d1f46 100644 --- a/src/components/calltree/ProfileFunctionListView.tsx +++ b/src/components/calltree/ProfileFunctionListView.tsx @@ -66,7 +66,9 @@ class ProfileFunctionListViewImpl extends React.PureComponent { label="Descendants" isOpen={sectionsOpen.descendants} onToggle={this._onDescendantsToggle} - headerActions={} + headerActions={ + + } > @@ -74,7 +76,9 @@ class ProfileFunctionListViewImpl extends React.PureComponent { label="Ancestors" isOpen={sectionsOpen.ancestors} onToggle={this._onAncestorsToggle} - headerActions={} + headerActions={ + + } > @@ -82,7 +86,9 @@ class ProfileFunctionListViewImpl extends React.PureComponent { label="Self" isOpen={sectionsOpen.self} onToggle={this._onSelfToggle} - headerActions={} + headerActions={ + + } > diff --git a/src/components/calltree/WingViewToggle.css b/src/components/calltree/WingViewToggle.css index 0743c3bfec..29d76fecc6 100644 --- a/src/components/calltree/WingViewToggle.css +++ b/src/components/calltree/WingViewToggle.css @@ -39,6 +39,10 @@ opacity: 0.55; } +.wingViewToggleIcon-flipped { + transform: scaleY(-1); +} + .wingViewToggleButton.selected svg { opacity: 1; } diff --git a/src/components/calltree/WingViewToggle.tsx b/src/components/calltree/WingViewToggle.tsx index 1abfb49ef7..f43de5b786 100644 --- a/src/components/calltree/WingViewToggle.tsx +++ b/src/components/calltree/WingViewToggle.tsx @@ -20,6 +20,9 @@ import './WingViewToggle.css'; type OwnProps = { readonly wing: WingViewName; + // This needs to match the startsAtBottom prop of the wing's flame graph, so + // that the icon has the same orientation as the flame graph it switches to. + readonly flameGraphStartsAtBottom: boolean; }; type StateProps = { @@ -32,6 +35,60 @@ type DispatchProps = { type Props = ConnectedProps; +/** + * A flame graph with a root box that spans the full width, and children that + * get narrower with increasing depth. Sibling boxes are separated by a 1px gap. + * When startsAtBottom is false, the icon is flipped vertically, so that the + * root is at the top, like an icicle graph. + */ +function FlameGraphIcon({ startsAtBottom }: { startsAtBottom: boolean }) { + return ( + + ); +} + +/** + * Rows of a tree view, drawn as thin lines so that the icon doesn't look like + * the boxes of the flame graph icon. The indentation shows the depth of each + * node, and the twisty triangles show whether a node is expanded. + */ +function CallTreeIcon() { + return ( + + ); +} + class WingViewToggleImpl extends React.PureComponent { _onFlameGraphClick = (event: React.MouseEvent) => { event.stopPropagation(); @@ -43,7 +100,7 @@ class WingViewToggleImpl extends React.PureComponent { }; override render() { - const { view } = this.props; + const { view, flameGraphStartsAtBottom } = this.props; return (
);