From db058cdf73303f5a133f457aaa9125f7c78a34b9 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Wed, 24 Jun 2026 16:24:30 -0400 Subject: [PATCH 01/18] 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..96963e5ffc 100644 --- a/src/profile-logic/flame-graph.ts +++ b/src/profile-logic/flame-graph.ts @@ -52,6 +52,10 @@ 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; } class FlameGraphTimingNonInverted implements FlameGraphTiming { @@ -104,6 +108,20 @@ 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 { + const row = this.getRow(depth); + if (indexInRow < 0 || indexInRow >= row.length) { + throw new Error( + `Out-of-bounds call to getRatioOfRootTotalSummary: For depth ${depth}, ${indexInRow} is outside 0..${row.length}` + ); + } + + const ratioOfFullWidth = row.end[indexInRow] - row.start[indexInRow]; + return ratioOfFullWidth; + } + _buildNextTimingRow(): void { const { total, self, rootTotalSummary } = this._callTreeTimings; const { prefix } = this._callNodeTable; @@ -435,6 +453,21 @@ 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 { + const row = this.getRow(depth); + if (indexInRow < 0 || indexInRow >= row.length) { + throw new Error( + `Out-of-bounds call to getRatioOfRootTotalSummary: For depth ${depth}, ${indexInRow} is outside 0..${row.length}` + ); + } + + return row.end[indexInRow] - row.start[indexInRow]; + } + _compareCallNodesByFuncName = ( a: IndexIntoCallNodeTable, b: IndexIntoCallNodeTable From 87441981f6caf19afa815a2bb23068bf8dafd9a6 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:53:03 -0400 Subject: [PATCH 02/18] Add "open bottom box for function X" support code. The bottom box can currently only be opened for a call node. Add the equivalent lookup for a bare function index, so that views which select functions rather than call nodes can open the bottom box too. --- src/profile-logic/bottom-box.ts | 96 +++++++++++++++++++++++++++++++ src/profile-logic/call-tree.ts | 13 ++++- src/profile-logic/profile-data.ts | 41 +++++++++++++ src/test/store/bottom-box.test.ts | 56 +++++++++++++++++- 4 files changed, 204 insertions(+), 2 deletions(-) diff --git a/src/profile-logic/bottom-box.ts b/src/profile-logic/bottom-box.ts index 95831f996f..f857ca95a2 100644 --- a/src/profile-logic/bottom-box.ts +++ b/src/profile-logic/bottom-box.ts @@ -8,12 +8,14 @@ import type { Thread, IndexIntoStackTable, IndexIntoCallNodeTable, + IndexIntoFuncTable, BottomBoxInfo, SamplesLikeTable, } from 'firefox-profiler/types'; import type { CallNodeInfo } from './call-node-info'; import { getCallNodeFramePerStack, + getFunctionFramePerStack, getNativeSymbolInfo, getNativeSymbolsForCallNode, getOriginalPositionForFrame, @@ -176,3 +178,97 @@ export function getBottomBoxInfoForStackFrame( highlightedInstructionAddress: instructionAddress, }; } + +/** + * Calculate the BottomBoxInfo for a function, i.e. information about which + * things should be shown in the profiler UI's "bottom box" when a function is + * double-clicked in the function list. + * + * Unlike getBottomBoxInfoForCallNode, this considers all stacks where the + * function appears anywhere (not just as the self function), using the + * innermost (leaf-most) frame when the function appears multiple times in one + * stack due to recursion. + */ +export function getBottomBoxInfoForFunction( + funcIndex: IndexIntoFuncTable, + thread: Thread, + samples: SamplesLikeTable +): BottomBoxInfo { + const { stackTable, frameTable, funcTable, stringTable, nativeSymbols } = + thread; + + const { source: sourceIndex, line: funcLine } = getOriginalPositionForFrame( + null, + funcIndex, + frameTable, + funcTable, + thread.sourceLocationTable + ); + + const funcFramePerStack = getFunctionFramePerStack( + funcIndex, + stackTable, + frameTable + ); + + const nativeSymbolsForFunc = getNativeSymbolsForCallNode( + funcFramePerStack, + frameTable + ); + let initialNativeSymbol = null; + const nativeSymbolTimings = getTotalNativeSymbolTimingsForCallNode( + samples, + funcFramePerStack, + frameTable + ); + const hottestNativeSymbol = mapGetKeyWithMaxValue(nativeSymbolTimings); + if (hottestNativeSymbol !== undefined) { + nativeSymbolsForFunc.add(hottestNativeSymbol); + initialNativeSymbol = hottestNativeSymbol; + } + const nativeSymbolsForFuncArr = [...nativeSymbolsForFunc]; + nativeSymbolsForFuncArr.sort((a, b) => a - b); + if (nativeSymbolsForFuncArr.length !== 0 && initialNativeSymbol === null) { + initialNativeSymbol = nativeSymbolsForFuncArr[0]; + } + + const nativeSymbolInfosForFunc = nativeSymbolsForFuncArr.map( + (nativeSymbolIndex) => + getNativeSymbolInfo( + nativeSymbolIndex, + nativeSymbols, + frameTable, + stringTable + ) + ); + + const lineTimings = getTotalLineTimingsForCallNode( + samples, + funcFramePerStack, + frameTable, + funcTable, + funcLine, + thread.sourceLocationTable + ); + const hottestLine = mapGetKeyWithMaxValue(lineTimings); + const addressTimings = getTotalAddressTimingsForCallNode( + samples, + funcFramePerStack, + frameTable, + initialNativeSymbol + ); + const hottestInstructionAddress = mapGetKeyWithMaxValue(addressTimings); + + return { + sourceIndex, + nativeSymbols: nativeSymbolInfosForFunc, + initialNativeSymbol: + initialNativeSymbol !== null + ? nativeSymbolsForFuncArr.indexOf(initialNativeSymbol) + : null, + scrollToLineNumber: hottestLine, + scrollToInstructionAddress: hottestInstructionAddress, + highlightedLineNumber: null, + highlightedInstructionAddress: null, + }; +} diff --git a/src/profile-logic/call-tree.ts b/src/profile-logic/call-tree.ts index c4a6360119..f870e0f703 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -39,7 +39,10 @@ import * as ProfileData from './profile-data'; import type { CallTreeSummaryStrategy } from '../types/actions'; import type { CallNodeInfo, CallNodeInfoInverted } from './call-node-info'; import type { SortableColumn } from '../components/shared/TreeView'; -import { getBottomBoxInfoForCallNode } from './bottom-box'; +import { + getBottomBoxInfoForCallNode, + getBottomBoxInfoForFunction, +} from './bottom-box'; type CallNodeChildren = IndexIntoCallNodeTable[]; @@ -652,6 +655,14 @@ export class CallTree { ); } + getBottomBoxInfoForFunction(funcIndex: IndexIntoFuncTable): BottomBoxInfo { + return getBottomBoxInfoForFunction( + funcIndex, + this._thread, + this._previewFilteredCtssSamples + ); + } + /** * Take a IndexIntoCallNodeTable, and compute an inverted path for it. * diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index 002ac3514d..86d0982755 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -969,6 +969,47 @@ export function getCallNodeFramePerStackInverted( return callNodeFramePerStack; } +/** + * For each stack, returns the innermost (leaf-most) frame whose function matches + * funcIndex, or -1 if funcIndex doesn't appear in that stack at all. + * + * This is used when double-clicking a function in the function list, to find + * which frame to show in the source and assembly views. When a function appears + * multiple times in a stack (due to recursion), we use the innermost occurrence, + * because that is the one doing the most specific work. + * + * Example: for stack A -> B -> C -> B -> D, asking for func B gives: + * - frame of the B in "C -> B" (the innermost B), not the B in "A -> B" + * + * The algorithm takes advantage of the stack table's ordering (parents before + * children): for each stack, we start with the parent's result and overwrite + * whenever we encounter funcIndex again, so the last write wins (innermost). + */ +export function getFunctionFramePerStack( + funcIndex: IndexIntoFuncTable, + stackTable: StackTable, + frameTable: FrameTable +): Int32Array { + const { frame: frameCol, prefix: prefixCol, length: stackCount } = stackTable; + const funcCol = frameTable.func; + + const funcFramePerStack = new Int32Array(stackCount); + + for (let stackIndex = 0; stackIndex < stackCount; stackIndex++) { + const frame = frameCol[stackIndex]; + if (funcCol[frame] === funcIndex) { + // This stack's own frame matches: it is the innermost so far, overwrite. + funcFramePerStack[stackIndex] = frame; + } else { + // Inherit from parent (or -1 if there is no parent). + const prefix = prefixCol[stackIndex]; + funcFramePerStack[stackIndex] = + prefix !== null ? funcFramePerStack[prefix] : -1; + } + } + return funcFramePerStack; +} + /** * Take a samples table, and return an array that contain indexes that point to the * leaf most call node, or null. diff --git a/src/test/store/bottom-box.test.ts b/src/test/store/bottom-box.test.ts index 64037f2faa..7d3ef96ae3 100644 --- a/src/test/store/bottom-box.test.ts +++ b/src/test/store/bottom-box.test.ts @@ -8,7 +8,10 @@ import * as UrlStateSelectors from '../../selectors/url-state'; import * as ProfileSelectors from '../../selectors/profile'; import { selectedThreadSelectors } from '../../selectors/per-thread'; import { emptyAddressTimings } from '../../profile-logic/address-timings'; -import { getBottomBoxInfoForCallNode } from '../../profile-logic/bottom-box'; +import { + getBottomBoxInfoForCallNode, + getBottomBoxInfoForFunction, +} from '../../profile-logic/bottom-box'; import { changeSelectedCallNode, updateBottomBoxContentsAndMaybeOpen, @@ -372,3 +375,54 @@ describe('bottom box', function () { // - A test with multiple threads: Open the assembly view for a symbol, switch // to a different thread, check timings }); + +describe('getBottomBoxInfoForFunction', function () { + it('uses the innermost frame when a function appears multiple times due to recursion (A->B->A->B)', function () { + // Stack: A[line:20] -> B[line:30] -> A[line:21] -> B[line:31] + // B appears at line 30 (outer) and line 31 (inner/leaf). + // Double-clicking B in the function list should use line 31 (innermost). + const { derivedThreads, funcNamesDictPerThread } = + getProfileFromTextSamples(` + A[file:a.js][line:20] + B[file:b.js][line:30] + A[file:a.js][line:21] + B[file:b.js][line:31] + `); + const [thread] = derivedThreads; + const [{ B }] = funcNamesDictPerThread; + + const bottomBoxInfo = getBottomBoxInfoForFunction( + B, + thread, + thread.samples + ); + + // scrollToLineNumber should be 31 (the innermost B), not 30 (the outer B). + expect(bottomBoxInfo.scrollToLineNumber).toBe(31); + }); + + it('uses the innermost frame when a function appears multiple times due to recursion via another function (A->B->C->B->D)', function () { + // Stack: A[line:20] -> B[line:30] -> C[line:40] -> B[line:31] -> D[line:50] + // B appears at line 30 (outer) and line 31 (inner), with C and D in between. + // Double-clicking B in the function list should use line 31 (innermost B). + const { derivedThreads, funcNamesDictPerThread } = + getProfileFromTextSamples(` + A[file:a.js][line:20] + B[file:b.js][line:30] + C[file:c.js][line:40] + B[file:b.js][line:31] + D[file:d.js][line:50] + `); + const [thread] = derivedThreads; + const [{ B }] = funcNamesDictPerThread; + + const bottomBoxInfo = getBottomBoxInfoForFunction( + B, + thread, + thread.samples + ); + + // scrollToLineNumber should be 31 (the innermost B), not 30 (the outer B). + expect(bottomBoxInfo.scrollToLineNumber).toBe(31); + }); +}); From 57b21232fd7c2b766b893fb940da60b9960e729e Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:51:35 -0400 Subject: [PATCH 03/18] 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 selectedFunctionIndex to the per-thread view options along with the URL handling, reducers and selectors that go with it. --- locales/en-US/app.ftl | 1 + res/css/style.css | 3 +- src/actions/profile-view.ts | 37 +++ src/app-logic/tabs-handling.ts | 2 + src/app-logic/url-handling.ts | 57 ++++ src/components/app/Details.tsx | 2 + src/components/calltree/CallTree.css | 6 +- src/components/calltree/CallTree.tsx | 4 - src/components/calltree/FunctionList.tsx | 264 ++++++++++++++++++ .../calltree/ProfileFunctionListView.tsx | 20 ++ src/components/calltree/columns.ts | 44 +++ src/components/sidebar/index.tsx | 1 + src/profile-logic/call-tree.ts | 38 ++- src/reducers/profile-view.ts | 69 +++++ src/reducers/url-state.ts | 13 + src/selectors/per-thread/stack-sample.ts | 28 ++ src/selectors/url-state.ts | 3 + src/test/components/Details.test.tsx | 3 + src/test/components/DetailsContainer.test.tsx | 1 + .../__snapshots__/profile-view.test.ts.snap | 1 + src/test/store/bottom-box.test.ts | 27 ++ src/test/store/icons.test.ts | 1 + 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 | 9 + src/utils/types.ts | 1 + 29 files changed, 700 insertions(+), 10 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 91c30908bb..d692311a7f 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -939,6 +939,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 6f5e3e99ae..039b0d63c1 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -75,6 +75,7 @@ import type { TableViewOptions, SelectionContext, BottomBoxInfo, + IndexIntoFuncTable, } from 'firefox-profiler/types'; import { funcHasDirectRecursiveCall, @@ -131,6 +132,22 @@ export function changeSelectedCallNode( }; } +/** + * Select a function for a given thread in the function list. + */ +export function changeSelectedFunctionIndex( + threadsKey: ThreadsKey, + selectedFunctionIndex: IndexIntoFuncTable | null, + context: SelectionContext = { source: 'auto' } +): Action { + return { + type: 'CHANGE_SELECTED_FUNCTION', + selectedFunctionIndex, + threadsKey, + context, + }; +} + /** * This action is used when the user right clicks on a call node (in panels such * as the call tree, the flame chart, or the stack chart). It's especially used @@ -147,6 +164,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 +1652,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 2e51d248b9..0cc88be6f0 100644 --- a/src/app-logic/url-handling.ts +++ b/src/app-logic/url-handling.ts @@ -188,6 +188,7 @@ type CallTreeQuery = BaseQuery & { invertFlameGraph: null | undefined; hideIdleSamples: null | undefined; ctSummary: string; + functionListSort?: string; // "total-desc~self-asc" — primary first }; type MarkersQuery = BaseQuery & { @@ -236,6 +237,9 @@ type Query = BaseQuery & { marker?: MarkerIndex; markerSort?: string; + // Function list specific + functionListSort?: string; + // Network specific networkSearch?: string; @@ -342,6 +346,7 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { : undefined; /* fallsthrough */ case 'flame-graph': + case 'function-list': case 'calltree': { query = baseQuery as CallTreeQueryShape; @@ -392,6 +397,11 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { query.bottomFullscreen = true; } } + if (selectedTab === 'function-list') { + query.functionListSort = convertFunctionListSortToString( + urlState.profileSpecific.functionListSort + ); + } break; } case 'marker-table': @@ -648,6 +658,9 @@ export function stateFromLocation( : null, selectedMarkers, markerTableSort: convertMarkerTableSortFromString(query.markerSort), + functionListSort: convertFunctionListSortFromString( + query.functionListSort + ), }, }; } @@ -694,6 +707,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..c92105ea53 --- /dev/null +++ b/src/components/calltree/FunctionList.tsx @@ -0,0 +1,264 @@ +/* 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) => { + // const { + // selectedFunctionIndex, + // rightClickedFunctionIndex, + // threadsKey, + // } = this.props; + // const nodeIndex = + // rightClickedFunctionIndex !== null + // ? rightClickedFunctionIndex + // : selectedFunctionIndex; + // if (nodeIndex === null) { + // return; + // } + // handleCallNodeTransformShortcut(event, threadsKey, nodeIndex); + }; + + _onEnterOrDoubleClick = (nodeId: IndexIntoFuncTable) => { + const { tree, updateBottomBoxContentsAndMaybeOpen } = this.props; + const bottomBoxInfo = tree.getBottomBoxInfoForFunction(nodeId); + updateBottomBoxContentsAndMaybeOpen('function-list', bottomBoxInfo); + }; + + override render() { + const { + tree, + selectedFunctionIndex, + rightClickedFunctionIndex, + searchStringsRegExp, + disableOverscan, + weightType, + tableViewOptions, + onTableViewOptionsChange, + sort, + } = this.props; + if (tree.getRoots().length === 0) { + return ; + } + return ( + + ); + } +} + +export const FunctionList = explicitConnect<{}, StateProps, DispatchProps>({ + mapStateToProps: (state: State) => ({ + threadsKey: getSelectedThreadsKey(state), + scrollToSelectionGeneration: getScrollToSelectionGeneration(state), + focusCallTreeGeneration: getFocusCallTreeGeneration(state), + tree: selectedThreadSelectors.getFunctionListTree(state), + selectedFunctionIndex: + selectedThreadSelectors.getSelectedFunctionIndex(state), + rightClickedFunctionIndex: + selectedThreadSelectors.getRightClickedFunctionIndex(state), + searchStringsRegExp: getSearchStringsAsRegExp(state), + disableOverscan: getPreviewSelectionIsBeingModified(state), + weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), + tableViewOptions: getCurrentTableViewOptions(state), + sort: getFunctionListSort(state), + }), + mapDispatchToProps: { + changeSelectedFunctionIndex, + changeRightClickedFunctionIndex, + addTransformToStack, + updateBottomBoxContentsAndMaybeOpen, + onTableViewOptionsChange: (options: TableViewOptions) => + changeTableViewOptions('calltree', options), + changeFunctionListSort, + }, + component: FunctionListImpl, +}); diff --git a/src/components/calltree/ProfileFunctionListView.tsx b/src/components/calltree/ProfileFunctionListView.tsx new file mode 100644 index 0000000000..7695a6c4d2 --- /dev/null +++ b/src/components/calltree/ProfileFunctionListView.tsx @@ -0,0 +1,20 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { FunctionList } from './FunctionList'; +import { StackSettings } from 'firefox-profiler/components/shared/StackSettings'; +import { TransformNavigator } from 'firefox-profiler/components/shared/TransformNavigator'; + +export const ProfileFunctionListView = () => ( +
+ + + +
+); diff --git a/src/components/calltree/columns.ts b/src/components/calltree/columns.ts index 14f597410a..a3f92fa837 100644 --- a/src/components/calltree/columns.ts +++ b/src/components/calltree/columns.ts @@ -128,3 +128,47 @@ export function treeColumnsForWeightType( throw assertExhaustiveCheck(weightType, 'Unhandled WeightType.'); } } + +function withSelfPercentColumn( + columns: MaybeResizableColumn[] +): MaybeResizableColumn[] { + const selfPercentColumn = { + propName: 'selfPercent', + titleL10nId: '', + initialWidth: 55, + hideDividerAfter: true, + }; + const selfColumnIndex = columns.findIndex((c) => c.propName === 'self'); + const selfColumn = { + ...columns[selfColumnIndex], + headerWidthAdjustment: selfPercentColumn.initialWidth, + }; + const newColumns = columns.slice(); + newColumns[selfColumnIndex] = selfColumn; + newColumns.splice(selfColumnIndex, 0, selfPercentColumn); + return newColumns; +} + +export const functionListColumnsForTracingMs: MaybeResizableColumn[] = + withSelfPercentColumn(treeColumnsForTracingMs); +export const functionListColumnsForSamples: MaybeResizableColumn[] = + withSelfPercentColumn(treeColumnsForSamples); +export const functionListColumnsForBytes: MaybeResizableColumn[] = + withSelfPercentColumn(treeColumnsForBytes); + +// Like `treeColumnsForWeightType`, but for the FunctionList table (which has +// an extra selfPercent column compared to the call-tree variants). +export function functionListColumnsForWeightType( + weightType: WeightType +): MaybeResizableColumn[] { + switch (weightType) { + case 'tracing-ms': + return functionListColumnsForTracingMs; + case 'samples': + return functionListColumnsForSamples; + case 'bytes': + return functionListColumnsForBytes; + default: + throw assertExhaustiveCheck(weightType, 'Unhandled WeightType.'); + } +} diff --git a/src/components/sidebar/index.tsx b/src/components/sidebar/index.tsx index a8115372a3..e4a6901069 100644 --- a/src/components/sidebar/index.tsx +++ b/src/components/sidebar/index.tsx @@ -15,6 +15,7 @@ export function selectSidebar( ): React.ComponentType<{}> | null { return { calltree: CallTreeSidebar, + 'function-list': CallTreeSidebar, 'flame-graph': CallTreeSidebar, 'stack-chart': null, 'marker-chart': null, diff --git a/src/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/reducers/profile-view.ts b/src/reducers/profile-view.ts index b2a58f78c0..fa2b3fd6c8 100644 --- a/src/reducers/profile-view.ts +++ b/src/reducers/profile-view.ts @@ -30,6 +30,7 @@ import type { ThreadsKey, Milliseconds, TableViewOptions, + RightClickedFunction, } from 'firefox-profiler/types'; import { applyFuncSubstitutionToCallPath, @@ -188,6 +189,7 @@ export const defaultThreadViewOptions: ThreadViewOptions = { selectedInvertedCallNodePath: [], expandedNonInvertedCallNodePaths: new PathSet(), expandedInvertedCallNodePaths: new PathSet(), + selectedFunctionIndex: null, selectedNetworkMarker: null, lastSeenTransformCount: 0, }; @@ -315,6 +317,23 @@ const viewOptionsPerThread: Reducer = ( } ); } + case 'CHANGE_SELECTED_FUNCTION': { + const { selectedFunctionIndex, threadsKey } = action; + + const threadState = _getThreadViewOptions(state, threadsKey); + + const previousSelectedFunction = threadState.selectedFunctionIndex; + + // If the selected function doesn't actually change, let's return the previous + // state to avoid rerenders. + if (selectedFunctionIndex === previousSelectedFunction) { + return state; + } + + return _updateThreadViewOptions(state, threadsKey, { + selectedFunctionIndex, + }); + } case 'CHANGE_INVERT_CALLSTACK': { const { newSelectedCallNodePath, @@ -636,6 +655,7 @@ const scrollToSelectionGeneration: Reducer = (state = 0, action) => { case 'CHANGE_NETWORK_SEARCH_STRING': return state + 1; case 'CHANGE_SELECTED_CALL_NODE': + case 'CHANGE_SELECTED_FUNCTION': case 'CHANGE_SELECTED_MARKER': case 'CHANGE_SELECTED_NETWORK_MARKER': if (action.context.source === 'pointer') { @@ -781,6 +801,54 @@ const rightClickedCallNode: Reducer = ( } }; +const rightClickedFunction: Reducer = ( + state = null, + action +) => { + switch (action.type) { + case 'BULK_SYMBOLICATION': { + if (state === null) { + return null; + } + + const { oldFuncToNewFuncsMap } = action; + const functionIndexes = oldFuncToNewFuncsMap.get(state.functionIndex); + if (functionIndexes === undefined || functionIndexes.length === 0) { + return null; + } + + return { + ...state, + functionIndex: functionIndexes[0], + }; + } + case 'CHANGE_RIGHT_CLICKED_FUNCTION': + if (action.functionIndex !== null) { + return { + threadsKey: action.threadsKey, + functionIndex: action.functionIndex, + }; + } + + return null; + case 'SET_CONTEXT_MENU_VISIBILITY': + // We want to change the state only when the menu is hidden. + if (action.isVisible) { + return state; + } + + return null; + case 'PROFILE_LOADED': + case 'CHANGE_INVERT_CALLSTACK': + case 'ADD_TRANSFORM_TO_STACK': + case 'POP_TRANSFORMS_FROM_STACK': + case 'CHANGE_IMPLEMENTATION_FILTER': + return null; + default: + return state; + } +}; + const rightClickedMarker: Reducer = ( state = null, action @@ -882,6 +950,7 @@ const profileViewReducer: Reducer = wrapReducerInResetter( lastNonShiftClick, rightClickedTrack, rightClickedCallNode, + rightClickedFunction, rightClickedMarker, hoveredMarker, mouseTimePosition, diff --git a/src/reducers/url-state.ts b/src/reducers/url-state.ts index 5374e6440f..ec155c30cc 100644 --- a/src/reducers/url-state.ts +++ b/src/reducers/url-state.ts @@ -206,6 +206,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': @@ -817,6 +829,7 @@ const profileSpecific = combineReducers({ tabFilter, selectedMarkers, markerTableSort, + functionListSort, // The timeline tracks used to be hidden and sorted by thread indexes, rather than // track indexes. The only way to migrate this information to tracks-based data is to // first retrieve the profile, so they can't be upgraded by the normal url upgrading diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index 63347c19c1..ad784c7ccb 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,14 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getSelectedFunctionIndex: Selector = + createSelector( + threadSelectors.getViewOptions, + (threadViewOptions): IndexIntoFuncTable | null => { + return threadViewOptions.selectedFunctionIndex; + } + ); + const getSelectedCallNodePath: Selector = createSelector( threadSelectors.getViewOptions, UrlState.getInvertCallstack, @@ -648,6 +657,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 +684,7 @@ export function getStackAndSampleSelectorsPerThread( getAssemblyViewStackAddressInfo, getSelectedCallNodePath, getSelectedCallNodeIndex, + getSelectedFunctionIndex, getExpandedCallNodePaths, getExpandedCallNodeIndexes, getSampleCallNodesForFilteredThread, @@ -681,5 +708,6 @@ export function getStackAndSampleSelectorsPerThread( getNonInvertedFilteredCallNodeMaxDepthPlusOne, getFlameGraphTiming, getRightClickedCallNodeIndex, + getRightClickedFunctionIndex, }; } diff --git a/src/selectors/url-state.ts b/src/selectors/url-state.ts index 599b59a748..8165eab7ab 100644 --- a/src/selectors/url-state.ts +++ b/src/selectors/url-state.ts @@ -121,6 +121,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) => diff --git a/src/test/components/Details.test.tsx b/src/test/components/Details.test.tsx index 73b05db864..0970c890d0 100644 --- a/src/test/components/Details.test.tsx +++ b/src/test/components/Details.test.tsx @@ -20,6 +20,9 @@ import type { TabSlug } from '../../app-logic/tabs-handling'; jest.mock('../../components/calltree/ProfileCallTreeView', () => ({ ProfileCallTreeView: 'call-tree', })); +jest.mock('../../components/calltree/ProfileFunctionListView', () => ({ + ProfileFunctionListView: 'function-list', +})); jest.mock('../../components/flame-graph', () => ({ FlameGraph: 'flame-graph', })); diff --git a/src/test/components/DetailsContainer.test.tsx b/src/test/components/DetailsContainer.test.tsx index 54c142dba0..59d449fccb 100644 --- a/src/test/components/DetailsContainer.test.tsx +++ b/src/test/components/DetailsContainer.test.tsx @@ -37,6 +37,7 @@ describe('app/DetailsContainer', function () { const expectedSidebar: { [slug in TabSlug]: boolean } = { calltree: true, + 'function-list': true, 'flame-graph': true, 'stack-chart': false, 'marker-chart': false, diff --git a/src/test/store/__snapshots__/profile-view.test.ts.snap b/src/test/store/__snapshots__/profile-view.test.ts.snap index fa0a6575f8..906c1e5b65 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -4442,6 +4442,7 @@ Object { }, }, "lastSeenTransformCount": 1, + "selectedFunctionIndex": null, "selectedInvertedCallNodePath": Array [], "selectedNetworkMarker": null, "selectedNonInvertedCallNodePath": Array [ 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/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 72e1a1f257..4f4e2ed1fb 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 { @@ -555,6 +556,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 682c845df5..5dfb4bd671 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..84df211bba 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 { @@ -61,6 +62,7 @@ export type ThreadViewOptions = { readonly selectedInvertedCallNodePath: CallNodePath; readonly expandedNonInvertedCallNodePaths: PathSet; readonly expandedInvertedCallNodePaths: PathSet; + readonly selectedFunctionIndex: IndexIntoFuncTable | null; readonly selectedNetworkMarker: MarkerIndex | null; // Track the number of transforms to detect when they change via browser // navigation. This helps us know when to reset paths that may be invalid @@ -83,6 +85,11 @@ export type RightClickedCallNode = { readonly callNodePath: CallNodePath; }; +export type RightClickedFunction = { + readonly threadsKey: ThreadsKey; + readonly functionIndex: IndexIntoFuncTable; +}; + export type MarkerReference = { readonly threadsKey: ThreadsKey; readonly markerIndex: MarkerIndex; @@ -108,6 +115,7 @@ export type ProfileViewState = { lastNonShiftClick: LastNonShiftClickInformation | null; rightClickedTrack: TrackReference | null; rightClickedCallNode: RightClickedCallNode | null; + rightClickedFunction: RightClickedFunction | null; rightClickedMarker: MarkerReference | null; hoveredMarker: MarkerReference | null; mouseTimePosition: Milliseconds | null; @@ -387,6 +395,7 @@ export type ProfileSpecificUrlState = { legacyHiddenThreads: ThreadIndex[] | null; selectedMarkers: SelectedMarkersPerThread; 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 0d60e635618afed13f5229833c756e897a2eb8dd Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:52:56 -0400 Subject: [PATCH 04/18] 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 039b0d63c1..558629334d 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -167,7 +167,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 72dcd40b9d6195f738647866ab38b1bc5a9221bb Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:53:02 -0400 Subject: [PATCH 05/18] 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 | 36 ++-- .../components/TransformShortcuts.test.tsx | 176 ++++++++++++++++++ 3 files changed, 259 insertions(+), 44 deletions(-) diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 558629334d..502f87285e 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -2067,49 +2067,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, }) ); @@ -2168,7 +2208,7 @@ export function handleCallNodeTransformShortcut( } break; } - case 'c': { + case 'c': dispatch( addTransformToStack(threadsKey, { type: 'collapse-function-subtree', @@ -2176,17 +2216,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 c92105ea53..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,20 +171,22 @@ class FunctionListImpl extends PureComponent { _newExpandedCallNodeIndexes: Array ) => {}; - _onKeyDown = (_event: React.KeyboardEvent) => { - // const { - // selectedFunctionIndex, - // rightClickedFunctionIndex, - // threadsKey, - // } = this.props; - // const nodeIndex = - // rightClickedFunctionIndex !== null - // ? rightClickedFunctionIndex - // : selectedFunctionIndex; - // if (nodeIndex === null) { - // return; - // } - // handleCallNodeTransformShortcut(event, threadsKey, nodeIndex); + _onKeyDown = (event: React.KeyboardEvent) => { + const { + selectedFunctionIndex, + rightClickedFunctionIndex, + threadsKey, + callNodeInfo, + handleFunctionTransformShortcut, + } = this.props; + const funcIndex = + rightClickedFunctionIndex !== null + ? rightClickedFunctionIndex + : selectedFunctionIndex; + if (funcIndex === null) { + return; + } + handleFunctionTransformShortcut(event, threadsKey, callNodeInfo, funcIndex); }; _onEnterOrDoubleClick = (nodeId: IndexIntoFuncTable) => { @@ -238,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), @@ -256,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 aefbcfbd4a..dd17a20645 100644 --- a/src/test/components/TransformShortcuts.test.tsx +++ b/src/test/components/TransformShortcuts.test.tsx @@ -11,6 +11,8 @@ import { storeWithProfile } from '../fixtures/stores'; import { changeSelectedCallNode, changeRightClickedCallNode, + changeSelectedFunctionIndex, + changeRightClickedFunctionIndex, } from '../../actions/profile-view'; import { FlameGraph } from '../../components/flame-graph'; import { selectedThreadSelectors } from 'firefox-profiler/selectors'; @@ -18,6 +20,7 @@ import { ensureExists, objectEntries } from '../../utils/types'; import { fireFullKeyPress } from '../fixtures/utils'; import { autoMockCanvasContext } from '../fixtures/mocks/canvas-context'; import { ProfileCallTreeView } from '../../components/calltree/ProfileCallTreeView'; +import { ProfileFunctionListView } from '../../components/calltree/ProfileFunctionListView'; import { StackChart } from 'firefox-profiler/components/stack-chart'; import type { Transform, @@ -186,6 +189,104 @@ const pressKeyBuilder = (className: string) => (options: KeyPressOptions) => { fireFullKeyPress(div, options); }; +function testFunctionTransformKeyboardShortcuts( + setup: () => { + getTransform: () => null | Transform; + pressKey: (options: KeyPressOptions) => void; + expectedFuncIndex: IndexIntoFuncTable; + expectedResourceIndex: IndexIntoResourceTable; + } +) { + describe('function shortcuts', () => { + it('handles focus-function', () => { + const { pressKey, getTransform, expectedFuncIndex } = setup(); + pressKey({ key: 'f' }); + expect(getTransform()).toEqual({ + type: 'focus-function', + funcIndex: expectedFuncIndex, + }); + }); + + it('handles focus-self', () => { + const { pressKey, getTransform, expectedFuncIndex } = setup(); + pressKey({ key: 'S' }); + expect(getTransform()).toMatchObject({ + type: 'focus-self', + funcIndex: expectedFuncIndex, + }); + }); + + it('handles merge function', () => { + const { pressKey, getTransform, expectedFuncIndex } = setup(); + pressKey({ key: 'm' }); + expect(getTransform()).toEqual({ + type: 'merge-function', + funcIndex: expectedFuncIndex, + }); + }); + + it('handles drop function', () => { + const { pressKey, getTransform, expectedFuncIndex } = setup(); + pressKey({ key: 'd' }); + expect(getTransform()).toEqual({ + type: 'drop-function', + funcIndex: expectedFuncIndex, + }); + }); + + it('handles collapse resource', () => { + const { pressKey, getTransform, expectedResourceIndex } = setup(); + pressKey({ key: 'C' }); + expect(getTransform()).toMatchObject({ + type: 'collapse-resource', + resourceIndex: expectedResourceIndex, + }); + }); + + it('handles collapse recursion', () => { + const { pressKey, getTransform, expectedFuncIndex } = setup(); + pressKey({ key: 'r' }); + expect(getTransform()).toMatchObject({ + type: 'collapse-recursion', + funcIndex: expectedFuncIndex, + }); + }); + + it('handles collapse direct recursion', () => { + const { pressKey, getTransform, expectedFuncIndex } = setup(); + pressKey({ key: 'R' }); + expect(getTransform()).toMatchObject({ + type: 'collapse-direct-recursion', + funcIndex: expectedFuncIndex, + }); + }); + + it('handles collapse function subtree', () => { + const { pressKey, getTransform, expectedFuncIndex } = setup(); + pressKey({ key: 'c' }); + expect(getTransform()).toEqual({ + type: 'collapse-function-subtree', + funcIndex: expectedFuncIndex, + }); + }); + + it('does not handle call-node-specific shortcuts', () => { + const { pressKey, getTransform } = setup(); + pressKey({ key: 'F' }); // focus-subtree + pressKey({ key: 'M' }); // merge-call-node + pressKey({ key: 'g' }); // focus-category + expect(getTransform()).toBeNull(); + }); + + it('ignores shortcuts with modifiers', () => { + const { pressKey, getTransform } = setup(); + pressKey({ key: 'c', ctrlKey: true }); + pressKey({ key: 'c', metaKey: true }); + expect(getTransform()).toBeNull(); + }); + }); // end describe('function shortcuts') +} + /* eslint-disable jest/no-standalone-expect */ // Disable the jest/no-standalone-expect rule because eslint doesn't know that // these expectations will run in a test block later. @@ -326,3 +427,78 @@ describe('stack chart transform shortcuts', () => { }); } }); + +/* eslint-disable jest/no-standalone-expect */ +const functionListActions = { + 'a selected function': ( + { dispatch, getState }: Store, + { B }: FuncNamesDict + ) => { + act(() => { + dispatch(changeSelectedFunctionIndex(0, B)); + }); + expect( + selectedThreadSelectors.getSelectedFunctionIndex(getState()) + ).not.toBeNull(); + expect( + selectedThreadSelectors.getRightClickedFunctionIndex(getState()) + ).toBeNull(); + }, + 'a right-clicked function': ( + { dispatch, getState }: Store, + { B }: FuncNamesDict + ) => { + act(() => { + dispatch(changeSelectedFunctionIndex(0, null)); + }); + act(() => { + dispatch(changeRightClickedFunctionIndex(0, B)); + }); + expect( + selectedThreadSelectors.getSelectedFunctionIndex(getState()) + ).toBeNull(); + expect( + selectedThreadSelectors.getRightClickedFunctionIndex(getState()) + ).not.toBeNull(); + }, + 'both a selected and a right-clicked function': ( + { dispatch, getState }: Store, + { A, B }: FuncNamesDict + ) => { + act(() => { + dispatch(changeSelectedFunctionIndex(0, A)); + }); + act(() => { + dispatch(changeRightClickedFunctionIndex(0, B)); + }); + expect( + selectedThreadSelectors.getSelectedFunctionIndex(getState()) + ).not.toBeNull(); + expect( + selectedThreadSelectors.getRightClickedFunctionIndex(getState()) + ).not.toBeNull(); + }, +}; +/* eslint-enable jest/no-standalone-expect */ + +describe('function list transform shortcuts', () => { + for (const [name, action] of objectEntries(functionListActions)) { + describe(`with ${name}`, () => { + testFunctionTransformKeyboardShortcuts(() => { + const { store, funcNames, getTransform } = setupStore( + + ); + + const { B } = funcNames; + action(store, funcNames); + + return { + getTransform, + pressKey: pressKeyBuilder('treeViewBody'), + expectedFuncIndex: B, + expectedResourceIndex: 0, + }; + }); + }); + } +}); From 4c09f3d98d8d3e07bb44ee16a78192259c164356 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 3 Sep 2026 15:39:47 -0400 Subject: [PATCH 06/18] 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 86d0982755..8d36789b37 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 ad784c7ccb..1e554a5ce8 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -356,6 +356,19 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getSampleRelationsForFunctionListTab: Selector = + createSelector( + getSampleCallNodesForFilteredThread, + _getCallNodeTable, + getSelectedFunctionIndex, + (sampleCallNodes, callNodeTable, selectedFunctionIndex) => + ProfileData.getSampleRelationsToFunction( + sampleCallNodes, + selectedFunctionIndex, + callNodeTable + ) + ); + const getTreeOrderComparatorInFilteredThread: Selector< ( sampleIndexA: IndexIntoSamplesTable, @@ -689,6 +702,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 0e83649442..63fe0c1e0e 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 d06622af9be05ad491ccec837af2616594856dd5 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:53:03 -0400 Subject: [PATCH 07/18] 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 502f87285e..3fd92f8d60 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -235,6 +235,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 11b7411e90796109b3c4cee04d4b1b4be36ecb43 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Tue, 22 Sep 2026 16:08:42 -0400 Subject: [PATCH 08/18] Implement sidebar timings for the function list. --- locales/en-US/app.ftl | 5 +- src/components/sidebar/CallTreeSidebar.tsx | 47 ++++- 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, 408 insertions(+), 10 deletions(-) diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl index d692311a7f..01e5e5339b 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..d5d92ec5da 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'; @@ -25,6 +26,7 @@ import type { ThreadsKey, CategoryList, IndexIntoCallNodeTable, + IndexIntoFuncTable, SelfAndTotal, WeightType, IndexIntoCategoryList, @@ -193,7 +195,13 @@ export const CategoryBreakdown = explicitConnect< }); type StateProps = { - readonly selectedNodeIndex: IndexIntoCallNodeTable | null; + // Whether the sidebar describes a call node (call tree, flame graph) or a + // function (function list). + readonly selectionKind: 'call-node' | 'function'; + readonly selectedNodeIndex: + | IndexIntoCallNodeTable + | IndexIntoFuncTable + | null; readonly selectedThreadsKey: ThreadsKey; readonly name: string; readonly lib: string; @@ -268,6 +276,7 @@ class CallTreeSidebarImpl extends React.PureComponent { override render() { const { + selectionKind, selectedNodeIndex, name, lib, @@ -284,7 +293,13 @@ class CallTreeSidebarImpl extends React.PureComponent { if (selectedNodeIndex === null) { return (
- +
Select a node to display some information about it.
@@ -323,9 +338,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 +455,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 1e554a5ce8..c14ddbc337 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -369,6 +369,19 @@ export function getStackAndSampleSelectorsPerThread( ) ); + const getPreviewFilteredCtssSampleRelationsToSelectedFunction: Selector = + createSelector( + getPreviewFilteredCtssSampleCallNodes, + _getCallNodeTable, + getSelectedFunctionIndex, + (sampleCallNodes, callNodeTable, selectedFunctionIndex) => + ProfileData.getSampleRelationsToFunction( + sampleCallNodes, + selectedFunctionIndex, + callNodeTable + ) + ); + const getTreeOrderComparatorInFilteredThread: Selector< ( sampleIndexA: IndexIntoSamplesTable, @@ -705,6 +718,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 d7131b74834b62a30af20ba42427e9c80eee3b5f Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 3 Sep 2026 15:36:50 -0400 Subject: [PATCH 09/18] Prepare CallNodeInfoInverted for multiple implementations. Turn CallNodeInfoInverted from a concrete class into an interface, and rename the existing class to LazyInvertedCallNodeInfo. Along with that, stop assuming that an inverted tree has exactly one root per function: the timings are now keyed by root call node handle, obtained via getRootNodeForFunc(), rather than by func index directly. For the lazy implementation the two are still the same number, so this is behavior-preserving. This makes room for an inverted CallNodeInfo whose roots are restricted to a single function, which the next commit adds. --- src/profile-logic/call-node-info.ts | 73 ++++++++++++++++++++++++++--- src/profile-logic/call-tree.ts | 67 +++++++++++++++----------- src/profile-logic/flame-graph.ts | 10 ++-- src/profile-logic/profile-data.ts | 10 ++-- 4 files changed, 118 insertions(+), 42 deletions(-) diff --git a/src/profile-logic/call-node-info.ts b/src/profile-logic/call-node-info.ts index 7cf3a8daf3..4e4f6eb47b 100644 --- a/src/profile-logic/call-node-info.ts +++ b/src/profile-logic/call-node-info.ts @@ -581,8 +581,59 @@ type ChildrenInfo = { export type SuffixOrderIndex = number; /** - * The CallNodeInfo implementation for the inverted tree, with additional - * functionality for the inverted call tree. + * The interface for the inverted-mode call node info, which extends + * CallNodeInfo with the additional functionality that consumers of inverted + * call trees need (suffix order, root identification, function count). + * + * 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. + */ +export interface CallNodeInfoInverted extends CallNodeInfo { + // Get a mapping SuffixOrderIndex -> IndexIntoCallNodeTable. + // The contents may be mutated by the implementation as inverted nodes get + // materialized on demand. Callers should not hold on to this array across + // calls that may create new inverted nodes (getChildren, getCallNodeIndexFromPath). + getSuffixOrderedCallNodes(): Uint32Array; + + // The inverse of getSuffixOrderedCallNodes: IndexIntoCallNodeTable -> SuffixOrderIndex. + // Same staleness caveat as above. + getSuffixOrderIndexes(): Uint32Array; + + // The [start, exclusiveEnd] suffix order index range for this inverted node. + getSuffixOrderIndexRangeForCallNode( + nodeHandle: IndexIntoCallNodeTable + ): [SuffixOrderIndex, SuffixOrderIndex]; + + // The number of functions in the func table this CNI was built from. + // (Sizes per-func scratch buffers in callers; not directly tied to the + // number of inverted-tree roots — see getRootCount.) + getFuncCount(): number; + + // The number of roots in the inverted tree. Root call node handles are + // 0..getRootCount()-1. For the full inverted tree this equals funcCount + // (one root per func). + getRootCount(): number; + + // Returns the inverted root call node handle for `funcIndex`, or -1 if no + // root in this CNI corresponds to that function. + getRootNodeForFunc( + funcIndex: IndexIntoFuncTable + ): IndexIntoCallNodeTable | -1; + + // True if the given node is a root of the inverted tree. + isRoot(nodeHandle: IndexIntoCallNodeTable): boolean; + + // Materialize and return the children of this inverted node. Sorted by func. + getChildren(nodeIndex: IndexIntoCallNodeTable): IndexIntoCallNodeTable[]; +} + +/** + * The lazy CallNodeInfoInverted implementation. It has one root per func and + * materializes non-root nodes on demand. * * # The Suffix Order * @@ -902,7 +953,7 @@ export type SuffixOrderIndex = number; * deep nodes. They're not needed anymore. So _takeDeepNodesForInvertedNode * nulls out the stored deepNodes for an inverted node when it's called. */ -export class CallNodeInfoInverted implements CallNodeInfo { +export class LazyInvertedCallNodeInfo implements CallNodeInfoInverted { // The non-inverted call node table. _callNodeTable: CallNodeTable; @@ -1020,13 +1071,23 @@ export class CallNodeInfoInverted implements CallNodeInfo { return this._suffixOrderIndexes; } - // Get the number of functions. There is one root per function. - // So this is also the number of roots at the same time. - // The inverted call node index for a root is the same as the function index. + // Get the number of functions. There is one root per function in this + // (full inverted) CNI, so it also equals getRootCount(). + // For roots, the inverted call node handle equals the function index. getFuncCount(): number { return this._rootCount; } + getRootCount(): number { + return this._rootCount; + } + + // For the full inverted tree, each func has its own root and the root + // handle equals the func index. + getRootNodeForFunc(funcIndex: IndexIntoFuncTable): InvertedCallNodeHandle { + return funcIndex; + } + // Returns whether the given node is a root node. isRoot(nodeHandle: InvertedCallNodeHandle): boolean { return nodeHandle < this._rootCount; diff --git a/src/profile-logic/call-tree.ts b/src/profile-logic/call-tree.ts index e970a3a2d5..e6fe48e5a6 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -66,9 +66,15 @@ export type InvertedCallTreeRoot = { export type CallTreeTimingsInverted = { callNodeSelf: Float64Array; rootTotalSummary: number; - sortedRoots: IndexIntoFuncTable[]; - totalPerRootFunc: Float64Array; - hasChildrenPerRootFunc: Uint8Array; + // Inverted-tree root call node indices, sorted by their total descending. + // For LazyInvertedCallNodeInfo these happen to equal func indices; for other + // CNIs (e.g. lower-wing) they're whatever index the CNI uses for its root. + sortedRoots: IndexIntoCallNodeTable[]; + // Indexed by inverted-tree root call node index (i.e. 0..rootCount-1, where + // rootCount = callNodeInfo.getRootCount()). For the standard inverted tree, + // root index === func index; for a lower-wing CNI, the array has length 1. + totalPerRootNode: Float64Array; + hasChildrenPerRootNode: Uint8Array; }; export type CallTreeTimingsFunctionList = { @@ -248,8 +254,8 @@ class CallTreeInternalInverted implements CallTreeInternal { _callNodeTable: CallNodeTable; _callNodeSelf: Float64Array; _rootNodes: IndexIntoCallNodeTable[]; - _totalPerRootFunc: Float64Array; - _hasChildrenPerRootFunc: Uint8Array; + _totalPerRootNode: Float64Array; + _hasChildrenPerRootNode: Uint8Array; _totalAndHasChildrenPerNonRootNode: Map< IndexIntoCallNodeTable, TotalAndHasChildren @@ -262,10 +268,10 @@ class CallTreeInternalInverted implements CallTreeInternal { this._callNodeInfo = callNodeInfo; this._callNodeTable = callNodeInfo.getCallNodeTable(); this._callNodeSelf = callTreeTimingsInverted.callNodeSelf; - const { sortedRoots, totalPerRootFunc, hasChildrenPerRootFunc } = + const { sortedRoots, totalPerRootNode, hasChildrenPerRootNode } = callTreeTimingsInverted; - this._totalPerRootFunc = totalPerRootFunc; - this._hasChildrenPerRootFunc = hasChildrenPerRootFunc; + this._totalPerRootNode = totalPerRootNode; + this._hasChildrenPerRootNode = hasChildrenPerRootNode; this._rootNodes = sortedRoots; } @@ -275,7 +281,7 @@ class CallTreeInternalInverted implements CallTreeInternal { hasChildren(callNodeIndex: IndexIntoCallNodeTable): boolean { if (this._callNodeInfo.isRoot(callNodeIndex)) { - return this._hasChildrenPerRootFunc[callNodeIndex] !== 0; + return this._hasChildrenPerRootNode[callNodeIndex] !== 0; } return this._getTotalAndHasChildren(callNodeIndex).hasChildren; } @@ -301,14 +307,14 @@ class CallTreeInternalInverted implements CallTreeInternal { getSelf(callNodeIndex: IndexIntoCallNodeTable): number { if (this._callNodeInfo.isRoot(callNodeIndex)) { - return this._totalPerRootFunc[callNodeIndex]; + return this._totalPerRootNode[callNodeIndex]; } return 0; } getTotal(callNodeIndex: IndexIntoCallNodeTable): number { if (this._callNodeInfo.isRoot(callNodeIndex)) { - return this._totalPerRootFunc[callNodeIndex]; + return this._totalPerRootNode[callNodeIndex]; } const { total } = this._getTotalAndHasChildren(callNodeIndex); return total; @@ -784,9 +790,9 @@ export function getSelfAndTotalForCallNode( case 'INVERTED': { const callNodeInfoInverted = ensureExists(callNodeInfo.asInverted()); const { timings } = callTreeTimings; - const { callNodeSelf, totalPerRootFunc } = timings; + const { callNodeSelf, totalPerRootNode } = timings; if (callNodeInfoInverted.isRoot(callNodeIndex)) { - const total = totalPerRootFunc[callNodeIndex]; + const total = totalPerRootNode[callNodeIndex]; return { self: total, total }; } const { total } = _getInvertedTreeNodeTotalAndHasChildren( @@ -854,14 +860,14 @@ export function computeCallTreeTimingsInverted( callNodeInfo: CallNodeInfoInverted, { callNodeSelf, rootTotalSummary }: CallNodeSelfAndSummary ): CallTreeTimingsInverted { - const funcCount = callNodeInfo.getFuncCount(); + const rootCount = callNodeInfo.getRootCount(); const callNodeTable = callNodeInfo.getCallNodeTable(); const callNodeTableFuncCol = callNodeTable.func; const callNodeTableDepthCol = callNodeTable.depth; - const totalPerRootFunc = new Float64Array(funcCount); - const hasChildrenPerRootFunc = new Uint8Array(funcCount); - const seenPerRootFunc = new Uint8Array(funcCount); - const sortedRoots = []; + const totalPerRootNode = new Float64Array(rootCount); + const hasChildrenPerRootNode = new Uint8Array(rootCount); + const seenPerRootNode = new Uint8Array(rootCount); + const sortedRoots: IndexIntoCallNodeTable[] = []; for (let i = 0; i < callNodeSelf.length; i++) { const self = callNodeSelf[i]; if (self === 0) { @@ -869,28 +875,33 @@ export function computeCallTreeTimingsInverted( } // Map the non-inverted call node to its corresponding root in the inverted - // call tree. This is done by finding the inverted root which corresponds to - // the self function of the non-inverted call node. + // call tree, via the CNI. For the full inverted tree this is the identity + // on func index; for a lower-wing CNI only one func has a root, so other + // funcs return -1 and are skipped here. const func = callNodeTableFuncCol[i]; + const rootNode = callNodeInfo.getRootNodeForFunc(func); + if (rootNode === -1) { + continue; + } - totalPerRootFunc[func] += self; - if (seenPerRootFunc[func] === 0) { - seenPerRootFunc[func] = 1; - sortedRoots.push(func); + totalPerRootNode[rootNode] += self; + if (seenPerRootNode[rootNode] === 0) { + seenPerRootNode[rootNode] = 1; + sortedRoots.push(rootNode); } if (callNodeTableDepthCol[i] !== 0) { - hasChildrenPerRootFunc[func] = 1; + hasChildrenPerRootNode[rootNode] = 1; } } sortedRoots.sort( - (a, b) => Math.abs(totalPerRootFunc[b]) - Math.abs(totalPerRootFunc[a]) + (a, b) => Math.abs(totalPerRootNode[b]) - Math.abs(totalPerRootNode[a]) ); return { callNodeSelf, rootTotalSummary, sortedRoots, - totalPerRootFunc, - hasChildrenPerRootFunc, + totalPerRootNode, + hasChildrenPerRootNode, }; } diff --git a/src/profile-logic/flame-graph.ts b/src/profile-logic/flame-graph.ts index 96963e5ffc..4a057d24ac 100644 --- a/src/profile-logic/flame-graph.ts +++ b/src/profile-logic/flame-graph.ts @@ -490,13 +490,13 @@ class FlameGraphTimingInverted implements FlameGraphTiming { _getNodeTiming(nodeIndex: IndexIntoCallNodeTable): InvertedNodeTiming { const callNodeInfo = this._callNodeInfo; - const { callNodeSelf, totalPerRootFunc, hasChildrenPerRootFunc } = + const { callNodeSelf, totalPerRootNode, hasChildrenPerRootNode } = this._callTreeTimings; if (callNodeInfo.isRoot(nodeIndex)) { return { - total: totalPerRootFunc[nodeIndex], - hasChildren: hasChildrenPerRootFunc[nodeIndex] !== 0, + total: totalPerRootNode[nodeIndex], + hasChildren: hasChildrenPerRootNode[nodeIndex] !== 0, }; } @@ -545,7 +545,7 @@ class FlameGraphTimingInverted implements FlameGraphTiming { } _getRootRowItems(): InvertedFlameGraphRowItem[] { - const { rootTotalSummary, sortedRoots, totalPerRootFunc } = + const { rootTotalSummary, sortedRoots, totalPerRootNode } = this._callTreeTimings; const abs = Math.abs; @@ -563,7 +563,7 @@ class FlameGraphTimingInverted implements FlameGraphTiming { let currentRootStart = 0; for (let i = 0; i < roots.length; i++) { const root = roots[i]; - const totalRelative = abs(totalPerRootFunc[root] / rootTotalSummary); + const totalRelative = abs(totalPerRootNode[root] / rootTotalSummary); const start = currentRootStart; const end = start + totalRelative; if (totalRelative >= MIN_INVERTED_FLAME_GRAPH_BOX_WIDTH) { diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index 8d36789b37..025e49a90f 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -14,7 +14,7 @@ import { } from './data-structures'; import { CallNodeInfoNonInverted, - CallNodeInfoInverted, + LazyInvertedCallNodeInfo, } from './call-node-info'; import { computeThreadCPUPercent } from './cpu'; import { @@ -118,7 +118,11 @@ import type { SampleCategoriesAndSubcategories, SourceLocationTable, } from 'firefox-profiler/types'; -import type { CallNodeInfo, SuffixOrderIndex } from './call-node-info'; +import type { + CallNodeInfo, + CallNodeInfoInverted, + SuffixOrderIndex, +} from './call-node-info'; import { toFloat64Array, toInt32Array, @@ -668,7 +672,7 @@ export function getInvertedCallNodeInfo( defaultCategory: IndexIntoCategoryList, funcCount: number ): CallNodeInfoInverted { - return new CallNodeInfoInverted( + return new LazyInvertedCallNodeInfo( callNodeInfo.getCallNodeTable(), callNodeInfo.getStackIndexToNonInvertedCallNodeIndex(), defaultCategory, From 96b94f41c4e40c9756e33902cd8c913f884ec24d Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:55:52 -0400 Subject: [PATCH 10/18] 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 | 57 +++ src/profile-logic/profile-data.ts | 23 + src/test/fixtures/utils.ts | 58 +++ src/test/unit/profile-tree.test.ts | 137 ++++++ 5 files changed, 907 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 e6fe48e5a6..aab079b3ae 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -905,6 +905,63 @@ export function computeCallTreeTimingsInverted( }; } +// Returns a "self" array indexed by non-inverted call node. Only root-most +// entries to selectedFuncIndex are non-zero, and at each one we store the +// *inclusive* time of that invocation (self + descendants). Feeding this +// through computeCallTreeTimingsInverted then attributes each invocation's +// inclusive time to the lower-wing node whose path is its caller chain. +function _computeLowerWingCallNodeSelf( + callNodeSelf: Float64Array, + callNodeTable: CallNodeTable, + selectedFuncIndex: IndexIntoFuncTable +): Float64Array { + const callNodeCount = callNodeTable.length; + const funcCol = callNodeTable.func; + const subtreeEndCol = callNodeTable.subtreeRangeEnd; + const mappedSelf = new Float64Array(callNodeCount); + for (let i = 0; i < callNodeCount; i++) { + if (funcCol[i] !== selectedFuncIndex) { + continue; + } + + // Call node i is the root of a subtree for the selected function. + const subtreeEnd = subtreeEndCol[i]; + let subtreeTotal = 0; + for (let j = i; j < subtreeEnd; j++) { + subtreeTotal += callNodeSelf[j]; + } + mappedSelf[i] = subtreeTotal; + // Skip nested re-entries of selectedFuncIndex; their time is already + // included in the outer subtree total above. + i = subtreeEnd - 1; + } + return mappedSelf; +} + +export function computeLowerWingTimings( + callNodeInfo: CallNodeInfoInverted, + { callNodeSelf, rootTotalSummary }: CallNodeSelfAndSummary, + selectedFuncIndex: IndexIntoFuncTable | null +): CallTreeTimings { + const callNodeTable = callNodeInfo.getCallNodeTable(); + const mappedSelf = + selectedFuncIndex !== null + ? _computeLowerWingCallNodeSelf( + callNodeSelf, + callNodeTable, + selectedFuncIndex + ) + : new Float64Array(callNodeSelf.length); + + return { + type: 'INVERTED', + timings: computeCallTreeTimingsInverted(callNodeInfo, { + callNodeSelf: mappedSelf, + rootTotalSummary, + }), + }; +} + export function computeCallTreeTimings( callNodeInfo: CallNodeInfo, callNodeSelfAndSummary: CallNodeSelfAndSummary diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index 025e49a90f..b11d0b3345 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 1b6afc1682..5d4a5484f4 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, @@ -581,6 +582,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 22fb9a14c060dbb857cd90a0c947ed8080fb58e8 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:56:09 -0400 Subject: [PATCH 11/18] 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 b11d0b3345..a625c5c5e7 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. */ @@ -5220,3 +5401,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 5d4a5484f4..58c0da2f4a 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, @@ -582,6 +583,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 33116ea8f5726c27bee84b3bdc8f379c0afdd2fa Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:56:12 -0400 Subject: [PATCH 12/18] 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 | 420 +++++++++++++++++++++++++++- src/profile-logic/profile-data.ts | 5 +- src/test/unit/profile-tree.test.ts | 248 +++++++++++++++- 5 files changed, 725 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 aab079b3ae..aaa4e96c7e 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -910,7 +910,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 @@ -946,7 +946,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 4a057d24ac..8f0f864992 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'; @@ -644,3 +651,414 @@ 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. + flameGraphTotalForScaling: number; +}; + +/** + * Lazy, depth-incremental FlameGraphTiming for the lower wing. + * + * Wraps a `LowerWingCallNodeInfo` and produces a `FlameGraphTimingRow` for any + * depth on demand. Internal state: + * + * - `_rowsCallNodes[d]` is the row of call-node handles for depth `d`, sorted + * in flame-graph display order. Lazily built one depth at a time from the + * previous row's children — same algorithm as the old + * `computeLowerWingFlameGraphRows`, just sliced. + * - `_timingRows[d]` is the per-cell start/end/self/etc. for depth `d`. + * - `_prefixSums` is the running prefix sum over `lowerWing.getSuffixOrderedCallNodes()` + * in its *current* (partition-refined) order. We recompute whenever the + * underlying table has grown since the last recompute — extending the BFS + * permutes the suffix order within nodes' fixed `[soStart, soEnd)` ranges, + * which invalidates per-position prefix sums but leaves per-range sums + * unchanged. Detection is just `lowerWing.getLowerWingTableUpToDepth(-1).length`. + * - `_startPerTableIdx` carries cells' left edges across rows so children can + * be aligned under their parent. Grown as the table grows. + * + * `numRows` is the cheap upper bound from `computeLowerWingMaxDepthPlusOne`, + * so the Canvas can size the scroll area without forcing any tree build. + * + * Self-time correctness: row D's self values depend on the totals of nodes at + * depth D+1. `getRow(D)` extends the lower-wing CNI to depth D, which processes + * nodes at depth D and emits their depth-D+1 children — so children's + * `[soStart, soEnd)` ranges are set and their totals are computable. (For the + * deepest row, nodes have no children and self = total, which is also correct.) + */ +export class LowerWingFlameGraphTiming 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 { + // getRow() has run _ensurePrefixSums(), so _tooltipRatioMultiplier is resolved. + const row = this.getRow(depth); + if (indexInRow < 0 || indexInRow >= row.length) { + throw new Error( + `Out-of-bounds call to getRatioOfRootTotalSummary: For depth ${depth}, ${indexInRow} is outside 0..${row.length}` + ); + } + + const ratioOfFullWidth = row.end[indexInRow] - row.start[indexInRow]; + return ratioOfFullWidth * 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 flameGraphTotalForScaling = this._rootTotalSummary; + if (flameGraphTotalForScaling === 0) { + this._timingRows.push({ + start: [], + end: [], + selfRelative: [], + callNode: [], + length: 0, + }); + return; + } + + const tablePrefix = table.prefix; + const tableSoStart = table.suffixOrderIndexRangeStart; + const tableSoEnd = table.suffixOrderIndexRangeEnd; + const tableChildren = table.children; + const prefixSums = this._prefixSums as Float64Array; + const startPerTableIdx = this._startPerTableIdx; + const totalPerTableIdx = this._totalPerTableIdx; + + // Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1858310 + const abs = Math.abs; + + const start: UnitIntervalOfProfileRange[] = []; + const end: UnitIntervalOfProfileRange[] = []; + const selfRelative: number[] = []; + const timingCallNodes: IndexIntoCallNodeTable[] = []; + + let currentStart = 0; + let previousPrefixIdx = -1; + // Handle === table index for the lower-wing CNI. + for (let i = 0; i < rowNodes.length; i++) { + const tableIdx = rowNodes[i]; + // Row-0 roots are never seen as a child, so their total isn't cached; + // compute directly. Deeper rows read the total cached by their parent's + // childSum loop on the previous row. + const totalVal = + nextDepth === 0 + ? prefixSums[tableSoEnd[tableIdx]] - + prefixSums[tableSoStart[tableIdx]] + : totalPerTableIdx[tableIdx]; + if (totalVal === 0) { + continue; + } + + // self = total − Σ children's totals. Children at depth nextDepth+1 + // are guaranteed to exist (CNI was extended to nextDepth), so each + // child's [soStart, soEnd) is final and its total is well-defined. + // Cache each child's total so the next row can read it directly. + const children = tableChildren[tableIdx]; + let childSum = 0; + for (let c = 0; c < children.length; c++) { + const childIdx = children[c]; + const childTotal = + prefixSums[tableSoEnd[childIdx]] - prefixSums[tableSoStart[childIdx]]; + totalPerTableIdx[childIdx] = childTotal; + childSum += childTotal; + } + const selfVal = totalVal - childSum; + + const parentIdx = tablePrefix[tableIdx]; + + if (parentIdx !== previousPrefixIdx) { + currentStart = parentIdx === -1 ? 0 : startPerTableIdx[parentIdx]; + previousPrefixIdx = parentIdx; + } + startPerTableIdx[tableIdx] = currentStart; + + const totalRelative = abs(totalVal / flameGraphTotalForScaling); + const selfRelativeVal = abs(selfVal / flameGraphTotalForScaling); + + const currentEnd = currentStart + totalRelative; + start.push(currentStart); + end.push(currentEnd); + selfRelative.push(selfRelativeVal); + timingCallNodes.push(tableIdx); + + currentStart = currentEnd; + } + + this._timingRows.push({ + start, + end, + selfRelative, + callNode: timingCallNodes, + length: timingCallNodes.length, + }); + } +} + +/** + * Construct the (lazy) flame-graph timing for the lower wing. + * + * The returned object is the unit of work the Canvas talks to: it asks for a + * row by depth and the timing object extends the lower-wing CNI, refreshes + * prefix sums, and computes only the rows that have been asked for. + */ +export function createLowerWingFlameGraphTiming( + lowerWing: LowerWingCallNodeInfo, + callNodeSelfAndSummary: CallNodeSelfAndSummary, + callNodeTable: CallNodeTable, + selectedFuncIndex: IndexIntoFuncTable | null, + funcTable: FuncTable, + stringTable: StringTable +): LowerWingFlameGraphTiming { + return new LowerWingFlameGraphTiming( + lowerWing, + callNodeSelfAndSummary, + callNodeTable, + selectedFuncIndex, + funcTable, + stringTable + ); +} diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index a625c5c5e7..0bfbaea97e 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 58c0da2f4a..d1e9a25618 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, @@ -141,6 +150,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 935241b7e8930a329ebc0ca47ec9284b13027413 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:56:11 -0400 Subject: [PATCH 13/18] 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 upper wing needs those to differ: its 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 | 18 +++++++++++++++--- src/profile-logic/flame-graph.ts | 9 +++++---- .../__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 aaa4e96c7e..6aac3a9846 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -54,6 +54,7 @@ export type CallTreeTimingsNonInverted = { self: Float64Array; total: Float64Array; rootTotalSummary: number; // sum of absolute values, this is used for computing percentages + flameGraphWidthTotal: number; // used as 100% reference for flame graph box widths }; type TotalAndHasChildren = { total: number; hasChildren: boolean }; @@ -761,7 +762,11 @@ export function computeCallNodeSelfAndSummary( rootTotalSummary += abs(callNodeSelf[callNodeIndex]); } - return { callNodeSelf, rootTotalSummary }; + return { + callNodeSelf, + rootTotalSummary, + flameGraphWidthTotal: rootTotalSummary, + }; } export function getSelfAndTotalForCallNode( @@ -958,6 +963,7 @@ export function computeLowerWingTimings( timings: computeCallTreeTimingsInverted(callNodeInfo, { callNodeSelf: mappedSelf, rootTotalSummary, + flameGraphWidthTotal: rootTotalSummary, }), }; } @@ -994,7 +1000,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); @@ -1032,6 +1039,7 @@ export function computeCallTreeTimingsNonInverted( total: callNodeTotal, callNodeHasChildren, rootTotalSummary, + flameGraphWidthTotal, }; } @@ -1434,5 +1442,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 8f0f864992..66ae2202c4 100644 --- a/src/profile-logic/flame-graph.ts +++ b/src/profile-logic/flame-graph.ts @@ -126,11 +126,12 @@ class FlameGraphTimingNonInverted implements FlameGraphTiming { } const ratioOfFullWidth = row.end[indexInRow] - row.start[indexInRow]; - return ratioOfFullWidth; + const total = ratioOfFullWidth * this._callTreeTimings.flameGraphWidthTotal; + return total / this._callTreeTimings.rootTotalSummary; } _buildNextTimingRow(): void { - const { total, self, rootTotalSummary } = this._callTreeTimings; + const { total, self, flameGraphWidthTotal } = this._callTreeTimings; const { prefix } = this._callNodeTable; const depth = this._timingRows.length; @@ -167,8 +168,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 906c1e5b65..55e98646be 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 d1e9a25618..fa90960f05 100644 --- a/src/test/unit/profile-tree.test.ts +++ b/src/test/unit/profile-tree.test.ts @@ -97,6 +97,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 5dfb4bd671..1f9daf8b76 100644 --- a/src/types/profile-derived.ts +++ b/src/types/profile-derived.ts @@ -878,6 +878,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 bb9a7897c97e42f190f018b5740e87f3c1f1e88f Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:55:52 -0400 Subject: [PATCH 14/18] Add the state and selectors for the function list wings. The function list is about to grow three "wings" around the selected function: descendants (upper), ancestors (lower), and self. Each needs its own selected, expanded and right-clicked call node path. Rather than adding three more pairs of fields, key the existing per-thread paths by a CallNodeArea instead of by an isInverted boolean, and add the two wing areas alongside the two tree areas. The selected function itself moves into URL state so it can be shared and restored, and is mirrored into the wing paths as the wings' root. This commit adds the state, actions, reducers, selectors and URL handling; the views that consume them follow. --- src/actions/profile-view.ts | 114 +++- src/app-logic/url-handling.ts | 145 ++++++ src/reducers/profile-view.ts | 302 +++++++---- src/reducers/url-state.ts | 62 +++ src/selectors/per-thread/stack-sample.ts | 492 +++++++++++++++++- src/selectors/right-clicked-call-node.tsx | 2 + src/selectors/url-state.ts | 21 + .../__snapshots__/profile-view.test.ts.snap | 47 +- src/test/store/profile-view.test.ts | 2 + src/types/actions.ts | 16 +- src/types/state.ts | 41 +- 11 files changed, 1075 insertions(+), 169 deletions(-) diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 3fd92f8d60..cf95b2550b 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, @@ -76,12 +77,15 @@ import type { SelectionContext, BottomBoxInfo, IndexIntoFuncTable, + CallNodeArea, + WingName, } from 'firefox-profiler/types'; import { funcHasDirectRecursiveCall, funcHasRecursiveCall, } from '../profile-logic/transforms'; import { changeStoredProfileNameInDb } from 'firefox-profiler/app-logic/uploaded-profiles-db'; +import { replaceHistoryWithUrlState } from 'firefox-profiler/app-logic/url-handling'; import type { TabSlug } from '../app-logic/tabs-handling'; import type { CallNodeInfo } from '../profile-logic/call-node-info'; import type { SingleColumnSortState } from '../components/shared/TreeView'; @@ -123,7 +127,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, @@ -132,19 +136,50 @@ export function changeSelectedCallNode( }; } +const WING_AREAS: Record = { + upper: 'UPPER_WING', + lower: 'LOWER_WING', +}; + +export function changeWingSelectedCallNode( + wing: WingName, + threadsKey: ThreadsKey, + selectedCallNodePath: CallNodePath, + context: SelectionContext = { source: 'auto' } +): Action { + return { + type: 'CHANGE_SELECTED_CALL_NODE', + area: WING_AREAS[wing], + selectedCallNodePath, + optionalExpandedToCallNodePath: [], + threadsKey, + context, + }; +} + /** * Select a function for a given thread in the function list. + * + * Replaces the current history entry rather than pushing a new one, so that + * holding e.g. the down arrow key in the function list doesn't get rate-limited + * by the browser and doesn't flood the back/forward history. */ export function changeSelectedFunctionIndex( threadsKey: ThreadsKey, selectedFunctionIndex: IndexIntoFuncTable | null, context: SelectionContext = { source: 'auto' } -): Action { - return { - type: 'CHANGE_SELECTED_FUNCTION', - selectedFunctionIndex, - threadsKey, - context, +): ThunkAction { + return (dispatch, getState) => { + dispatch({ + type: 'CHANGE_SELECTED_FUNCTION', + selectedFunctionIndex, + threadsKey, + context, + }); + // Update window.history synchronously instead of waiting for the + // UrlManager's componentDidUpdate, which is deferred by React's render + // scheduling and would otherwise pushState a new entry. + replaceHistoryWithUrlState(getUrlState(getState())); }; } @@ -156,11 +191,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, + }); }; } @@ -175,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. @@ -1605,12 +1657,26 @@ export function changeExpandedCallNodes( const isInverted = getInvertCallstack(getState()); dispatch({ type: 'CHANGE_EXPANDED_CALL_NODES', - isInverted, + area: isInverted ? 'INVERTED_TREE' : 'NON_INVERTED_TREE', threadsKey, expandedCallNodePaths, }); }; } + +export function changeWingExpandedCallNodes( + wing: WingName, + threadsKey: ThreadsKey, + expandedCallNodePaths: Array +): Action { + return { + type: 'CHANGE_EXPANDED_CALL_NODES', + area: WING_AREAS[wing], + threadsKey, + expandedCallNodePaths, + }; +} + export function changeSelectedMarker( threadsKey: ThreadsKey, selectedMarker: MarkerIndex | null, @@ -1693,6 +1759,28 @@ export function changeFunctionListSort( }; } +export function changeFunctionListSectionOpen( + section: 'descendants' | 'ancestors' | 'self', + isOpen: boolean +): Action { + return { + type: 'CHANGE_FUNCTION_LIST_SECTION_OPEN', + section, + isOpen, + }; +} + +export function changeWingView( + wing: 'upper' | 'lower' | 'self', + view: 'flame-graph' | 'call-tree' +): Action { + return { + type: 'CHANGE_WING_VIEW', + wing, + view, + }; +} + export function changeNetworkSearchString(searchString: string): Action { return { type: 'CHANGE_NETWORK_SEARCH_STRING', diff --git a/src/app-logic/url-handling.ts b/src/app-logic/url-handling.ts index 0cc88be6f0..21d175663d 100644 --- a/src/app-logic/url-handling.ts +++ b/src/app-logic/url-handling.ts @@ -42,6 +42,9 @@ import type { IndexIntoFrameTable, MarkerIndex, SelectedMarkersPerThread, + SelectedFunctionsPerThread, + FunctionListSectionsOpenState, + WingViewsState, } from 'firefox-profiler/types'; import { FuncFlag } from 'firefox-profiler/types'; import { @@ -121,6 +124,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) { @@ -189,6 +205,9 @@ type CallTreeQuery = BaseQuery & { hideIdleSamples: null | undefined; ctSummary: string; functionListSort?: string; // "total-desc~self-asc" — primary first + funcListSections?: string; // "descendants,self" — comma-separated open sections + wingViews?: string; // "upper:call-tree,lower:call-tree" — non-default wing views + selectedFunc?: number; // Selected function index for the current thread, e.g. 42 }; type MarkersQuery = BaseQuery & { @@ -239,6 +258,9 @@ type Query = BaseQuery & { // Function list specific functionListSort?: string; + funcListSections?: string; + wingViews?: string; + selectedFunc?: number; // Network specific networkSearch?: string; @@ -401,6 +423,20 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { query.functionListSort = convertFunctionListSortToString( urlState.profileSpecific.functionListSort ); + query.funcListSections = convertFunctionListSectionsOpenToString( + urlState.profileSpecific.functionListSectionsOpen + ); + query.wingViews = convertWingViewsToString( + urlState.profileSpecific.wingViews + ); + query.selectedFunc = + selectedThreadsKey !== null && + urlState.profileSpecific.selectedFunctions[selectedThreadsKey] !== + null && + urlState.profileSpecific.selectedFunctions[selectedThreadsKey] !== + undefined + ? urlState.profileSpecific.selectedFunctions[selectedThreadsKey] + : undefined; } break; } @@ -566,6 +602,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))) { @@ -657,10 +706,15 @@ export function stateFromLocation( ? query.hiddenThreads.split('-').map((index) => Number(index)) : null, selectedMarkers, + selectedFunctions, markerTableSort: convertMarkerTableSortFromString(query.markerSort), functionListSort: convertFunctionListSortFromString( query.functionListSort ), + functionListSectionsOpen: convertFunctionListSectionsOpenFromString( + query.funcListSections + ), + wingViews: convertWingViewsFromString(query.wingViews), }, }; } @@ -751,6 +805,97 @@ function convertFunctionListSortFromString( return parsed.reverse(); } +// FunctionList section disclosure-box open/closed state. The URL stores a +// comma-separated list of the open sections; the param is omitted when the +// state matches the default (only "descendants" open). The value "none" is +// used as a sentinel for the all-closed case so the param is non-empty. +const FUNCTION_LIST_SECTION_NAMES: ReadonlyArray< + keyof FunctionListSectionsOpenState +> = ['descendants', 'ancestors', 'self']; +const FUNCTION_LIST_SECTIONS_OPEN_DEFAULT: FunctionListSectionsOpenState = { + descendants: true, + ancestors: false, + self: false, +}; + +function convertFunctionListSectionsOpenToString( + state: FunctionListSectionsOpenState +): string | undefined { + const matchesDefault = FUNCTION_LIST_SECTION_NAMES.every( + (name) => state[name] === FUNCTION_LIST_SECTIONS_OPEN_DEFAULT[name] + ); + if (matchesDefault) { + return undefined; + } + const open = FUNCTION_LIST_SECTION_NAMES.filter((name) => state[name]); + return open.length === 0 ? 'none' : open.join(','); +} + +function convertFunctionListSectionsOpenFromString( + raw: string | null | void +): FunctionListSectionsOpenState { + if (raw === undefined || raw === null) { + return { ...FUNCTION_LIST_SECTIONS_OPEN_DEFAULT }; + } + const result: FunctionListSectionsOpenState = { + descendants: false, + ancestors: false, + self: false, + }; + if (raw === 'none' || raw === '') { + return result; + } + for (const part of raw.split(',')) { + if (part === 'descendants' || part === 'ancestors' || part === 'self') { + result[part] = true; + } + } + return result; +} + +// WingView visualization choice for the Descendants ("upper"), Ancestors +// ("lower"), and Self wings of the FunctionList tab. The URL stores a +// comma-separated list of "wing:view" pairs for wings whose view differs from +// the default (flame-graph), e.g. "upper:call-tree". The param is omitted +// when all wings use the default. +const WING_VIEWS_DEFAULT: WingViewsState = { + upper: 'flame-graph', + lower: 'flame-graph', + self: 'flame-graph', +}; +const WING_VIEW_NAMES: ReadonlyArray = [ + 'upper', + 'lower', + 'self', +]; + +function convertWingViewsToString(state: WingViewsState): string | undefined { + const parts: string[] = []; + for (const wing of WING_VIEW_NAMES) { + if (state[wing] !== WING_VIEWS_DEFAULT[wing]) { + parts.push(`${wing}:${state[wing]}`); + } + } + return parts.length === 0 ? undefined : parts.join(','); +} + +function convertWingViewsFromString(raw: string | null | void): WingViewsState { + const result: WingViewsState = { ...WING_VIEWS_DEFAULT }; + if (raw === undefined || raw === null || raw === '') { + return result; + } + for (const part of raw.split(',')) { + const [wing, view] = part.split(':'); + if ( + (wing === 'upper' || wing === 'lower' || wing === 'self') && + (view === 'flame-graph' || view === 'call-tree') + ) { + result[wing] = view; + } + } + return result; +} + function convertGlobalTrackOrderFromString( rawString: string | null | void ): TrackIndex[] { diff --git a/src/reducers/profile-view.ts b/src/reducers/profile-view.ts index fa2b3fd6c8..0f9bffb48e 100644 --- a/src/reducers/profile-view.ts +++ b/src/reducers/profile-view.ts @@ -31,6 +31,7 @@ import type { Milliseconds, TableViewOptions, RightClickedFunction, + CallNodeArea, } from 'firefox-profiler/types'; import { applyFuncSubstitutionToCallPath, @@ -184,12 +185,25 @@ const sourceMapSymbolicationStatus: Reducer = ( } }; +const _emptySelectedCallNodePaths: Record = { + NON_INVERTED_TREE: [], + INVERTED_TREE: [], + LOWER_WING: [], + UPPER_WING: [], +}; + +function _emptyExpandedCallNodePaths(): Record { + return { + NON_INVERTED_TREE: new PathSet(), + INVERTED_TREE: new PathSet(), + LOWER_WING: new PathSet(), + UPPER_WING: new PathSet(), + }; +} + export const defaultThreadViewOptions: ThreadViewOptions = { - selectedNonInvertedCallNodePath: [], - selectedInvertedCallNodePath: [], - expandedNonInvertedCallNodePaths: new PathSet(), - expandedInvertedCallNodePaths: new PathSet(), - selectedFunctionIndex: null, + selectedCallNodePaths: _emptySelectedCallNodePaths, + expandedCallNodePaths: _emptyExpandedCallNodePaths(), selectedNetworkMarker: null, lastSeenTransformCount: 0, }; @@ -231,24 +245,29 @@ const viewOptionsPerThread: Reducer = ( const newState = objectMap(state, (threadViewOptions) => { return { ...threadViewOptions, - selectedNonInvertedCallNodePath: applyFuncSubstitutionToCallPath( - oldFuncToNewFuncsMap, - threadViewOptions.selectedNonInvertedCallNodePath - ), - selectedInvertedCallNodePath: applyFuncSubstitutionToCallPath( - oldFuncToNewFuncsMap, - threadViewOptions.selectedInvertedCallNodePath - ), - expandedNonInvertedCallNodePaths: - applyFuncSubstitutionToPathSetAndIncludeNewAncestors( + selectedCallNodePaths: { + ...threadViewOptions.selectedCallNodePaths, + NON_INVERTED_TREE: applyFuncSubstitutionToCallPath( oldFuncToNewFuncsMap, - threadViewOptions.expandedNonInvertedCallNodePaths + threadViewOptions.selectedCallNodePaths.NON_INVERTED_TREE ), - expandedInvertedCallNodePaths: - applyFuncSubstitutionToPathSetAndIncludeNewAncestors( + INVERTED_TREE: applyFuncSubstitutionToCallPath( oldFuncToNewFuncsMap, - threadViewOptions.expandedInvertedCallNodePaths + threadViewOptions.selectedCallNodePaths.INVERTED_TREE ), + }, + expandedCallNodePaths: { + ...threadViewOptions.expandedCallNodePaths, + NON_INVERTED_TREE: + applyFuncSubstitutionToPathSetAndIncludeNewAncestors( + oldFuncToNewFuncsMap, + threadViewOptions.expandedCallNodePaths.NON_INVERTED_TREE + ), + INVERTED_TREE: applyFuncSubstitutionToPathSetAndIncludeNewAncestors( + oldFuncToNewFuncsMap, + threadViewOptions.expandedCallNodePaths.INVERTED_TREE + ), + }, }; }); @@ -256,17 +275,15 @@ const viewOptionsPerThread: Reducer = ( } case 'CHANGE_SELECTED_CALL_NODE': { const { - isInverted, + area, selectedCallNodePath, threadsKey, optionalExpandedToCallNodePath, } = action; const threadState = _getThreadViewOptions(state, threadsKey); - - const previousSelectedCallNodePath = isInverted - ? threadState.selectedInvertedCallNodePath - : threadState.selectedNonInvertedCallNodePath; + const previousSelectedCallNodePath = + threadState.selectedCallNodePaths[area]; // If the selected node doesn't actually change, let's return the previous // state to avoid rerenders. @@ -277,9 +294,7 @@ const viewOptionsPerThread: Reducer = ( return state; } - let expandedCallNodePaths = isInverted - ? threadState.expandedInvertedCallNodePaths - : threadState.expandedNonInvertedCallNodePaths; + let expandedCallNodePaths = threadState.expandedCallNodePaths[area]; const expandToNode = optionalExpandedToCallNodePath ? optionalExpandedToCallNodePath : selectedCallNodePath; @@ -303,35 +318,51 @@ const viewOptionsPerThread: Reducer = ( ); } - return _updateThreadViewOptions( - state, - threadsKey, - isInverted - ? { - selectedInvertedCallNodePath: selectedCallNodePath, - expandedInvertedCallNodePaths: expandedCallNodePaths, - } - : { - selectedNonInvertedCallNodePath: selectedCallNodePath, - expandedNonInvertedCallNodePaths: expandedCallNodePaths, - } - ); + return _updateThreadViewOptions(state, threadsKey, { + selectedCallNodePaths: { + ...threadState.selectedCallNodePaths, + [area]: selectedCallNodePath, + }, + expandedCallNodePaths: { + ...threadState.expandedCallNodePaths, + [area]: expandedCallNodePaths, + }, + }); } case 'CHANGE_SELECTED_FUNCTION': { const { selectedFunctionIndex, threadsKey } = action; const threadState = _getThreadViewOptions(state, threadsKey); - - const previousSelectedFunction = threadState.selectedFunctionIndex; - - // If the selected function doesn't actually change, let's return the previous - // state to avoid rerenders. - if (selectedFunctionIndex === previousSelectedFunction) { + const previousLowerWingPath = + threadState.selectedCallNodePaths.LOWER_WING; + const isSameSelection = + selectedFunctionIndex === null + ? previousLowerWingPath.length === 0 + : previousLowerWingPath.length === 1 && + previousLowerWingPath[0] === selectedFunctionIndex; + + if (isSameSelection) { return state; } + const wingPath: CallNodePath = + selectedFunctionIndex !== null ? [selectedFunctionIndex] : []; + const wingExpanded = + selectedFunctionIndex !== null + ? new PathSet([[selectedFunctionIndex]]) + : new PathSet(); + return _updateThreadViewOptions(state, threadsKey, { - selectedFunctionIndex, + selectedCallNodePaths: { + ...threadState.selectedCallNodePaths, + LOWER_WING: wingPath, + UPPER_WING: wingPath, + }, + expandedCallNodePaths: { + ...threadState.expandedCallNodePaths, + LOWER_WING: wingExpanded, + UPPER_WING: wingExpanded, + }, }); } case 'CHANGE_INVERT_CALLSTACK': { @@ -352,32 +383,34 @@ const viewOptionsPerThread: Reducer = ( expandedCallNodePaths.add(newSelectedCallNodePath.slice(0, i)); } - return invertCallstack - ? { - ...viewOptions, - selectedInvertedCallNodePath: newSelectedCallNodePath, - expandedInvertedCallNodePaths: expandedCallNodePaths, - } - : { - ...viewOptions, - selectedNonInvertedCallNodePath: newSelectedCallNodePath, - expandedNonInvertedCallNodePaths: expandedCallNodePaths, - }; + const area: CallNodeArea = invertCallstack + ? 'INVERTED_TREE' + : 'NON_INVERTED_TREE'; + return { + ...viewOptions, + selectedCallNodePaths: { + ...viewOptions.selectedCallNodePaths, + [area]: newSelectedCallNodePath, + }, + expandedCallNodePaths: { + ...viewOptions.expandedCallNodePaths, + [area]: expandedCallNodePaths, + }, + }; } return viewOptions; }); } case 'CHANGE_EXPANDED_CALL_NODES': { - const { threadsKey, isInverted } = action; + const { threadsKey, area } = action; const expandedCallNodePaths = new PathSet(action.expandedCallNodePaths); - - return _updateThreadViewOptions( - state, - threadsKey, - isInverted - ? { expandedInvertedCallNodePaths: expandedCallNodePaths } - : { expandedNonInvertedCallNodePaths: expandedCallNodePaths } - ); + const threadState = _getThreadViewOptions(state, threadsKey); + return _updateThreadViewOptions(state, threadsKey, { + expandedCallNodePaths: { + ...threadState.expandedCallNodePaths, + [area]: expandedCallNodePaths, + }, + }); } case 'CHANGE_SELECTED_NETWORK_MARKER': { const { threadsKey, selectedNetworkMarker } = action; @@ -413,27 +446,28 @@ const viewOptionsPerThread: Reducer = ( }; const threadViewOptions = _getThreadViewOptions(state, threadsKey); - const selectedNonInvertedCallNodePath = getFilteredPath( - threadViewOptions.selectedNonInvertedCallNodePath - ); - const selectedInvertedCallNodePath = getFilteredPath( - threadViewOptions.selectedInvertedCallNodePath - ); - const expandedNonInvertedCallNodePaths = getFilteredPathSet( - threadViewOptions.expandedNonInvertedCallNodePaths - ); - const expandedInvertedCallNodePaths = getFilteredPathSet( - threadViewOptions.expandedInvertedCallNodePaths - ); - const lastSeenTransformCount = threadViewOptions.lastSeenTransformCount + 1; return _updateThreadViewOptions(state, threadsKey, { - selectedNonInvertedCallNodePath, - selectedInvertedCallNodePath, - expandedNonInvertedCallNodePaths, - expandedInvertedCallNodePaths, + selectedCallNodePaths: { + ...threadViewOptions.selectedCallNodePaths, + NON_INVERTED_TREE: getFilteredPath( + threadViewOptions.selectedCallNodePaths.NON_INVERTED_TREE + ), + INVERTED_TREE: getFilteredPath( + threadViewOptions.selectedCallNodePaths.INVERTED_TREE + ), + }, + expandedCallNodePaths: { + ...threadViewOptions.expandedCallNodePaths, + NON_INVERTED_TREE: getFilteredPathSet( + threadViewOptions.expandedCallNodePaths.NON_INVERTED_TREE + ), + INVERTED_TREE: getFilteredPathSet( + threadViewOptions.expandedCallNodePaths.INVERTED_TREE + ), + }, lastSeenTransformCount, }); } @@ -441,11 +475,18 @@ const viewOptionsPerThread: Reducer = ( // Simply reset the stored paths until this bug is fixed: // https://github.com/firefox-devtools/profiler/issues/882 const { threadsKey } = action; + const threadState = _getThreadViewOptions(state, threadsKey); return _updateThreadViewOptions(state, threadsKey, { - selectedNonInvertedCallNodePath: [], - selectedInvertedCallNodePath: [], - expandedNonInvertedCallNodePaths: new PathSet(), - expandedInvertedCallNodePaths: new PathSet(), + selectedCallNodePaths: { + ...threadState.selectedCallNodePaths, + NON_INVERTED_TREE: [], + INVERTED_TREE: [], + }, + expandedCallNodePaths: { + ...threadState.expandedCallNodePaths, + NON_INVERTED_TREE: new PathSet(), + INVERTED_TREE: new PathSet(), + }, lastSeenTransformCount: 0, }); } @@ -458,8 +499,48 @@ const viewOptionsPerThread: Reducer = ( return state; } - const { transforms } = action.newUrlState.profileSpecific; - return objectMap(state, (viewOptions, threadsKey) => { + const { transforms, selectedFunctions } = + action.newUrlState.profileSpecific; + + // The selected function lives in URL state; mirror it into the per-thread + // wing paths so that initial loads and back/forward navigation restore the + // wings to the right function. + const newState: ThreadViewOptionsPerThreads = { ...state }; + for (const threadsKey of Object.keys(selectedFunctions)) { + const selectedFunctionIndex = selectedFunctions[threadsKey]; + const viewOptions = _getThreadViewOptions(newState, threadsKey); + const previousLowerWingPath = + viewOptions.selectedCallNodePaths.LOWER_WING; + const matchesExisting = + selectedFunctionIndex === null + ? previousLowerWingPath.length === 0 + : previousLowerWingPath.length === 1 && + previousLowerWingPath[0] === selectedFunctionIndex; + if (matchesExisting) { + continue; + } + const wingPath: CallNodePath = + selectedFunctionIndex !== null ? [selectedFunctionIndex] : []; + const wingExpanded = + selectedFunctionIndex !== null + ? new PathSet([[selectedFunctionIndex]]) + : new PathSet(); + newState[threadsKey] = { + ...viewOptions, + selectedCallNodePaths: { + ...viewOptions.selectedCallNodePaths, + LOWER_WING: wingPath, + UPPER_WING: wingPath, + }, + expandedCallNodePaths: { + ...viewOptions.expandedCallNodePaths, + LOWER_WING: wingExpanded, + UPPER_WING: wingExpanded, + }, + }; + } + + return objectMap(newState, (viewOptions, threadsKey) => { const transformStack = transforms[threadsKey] || []; const newTransformCount = transformStack.length; const oldTransformCount = viewOptions.lastSeenTransformCount; @@ -468,10 +549,16 @@ const viewOptionsPerThread: Reducer = ( if (newTransformCount < oldTransformCount) { return { ...viewOptions, - selectedNonInvertedCallNodePath: [], - selectedInvertedCallNodePath: [], - expandedNonInvertedCallNodePaths: new PathSet(), - expandedInvertedCallNodePaths: new PathSet(), + selectedCallNodePaths: { + ...viewOptions.selectedCallNodePaths, + NON_INVERTED_TREE: [], + INVERTED_TREE: [], + }, + expandedCallNodePaths: { + ...viewOptions.expandedCallNodePaths, + NON_INVERTED_TREE: new PathSet(), + INVERTED_TREE: new PathSet(), + }, lastSeenTransformCount: newTransformCount, }; } @@ -536,23 +623,25 @@ const viewOptionsPerThread: Reducer = ( }; const selectedNonInvertedCallNodePath = getUpdatedPath( - viewOptions.selectedNonInvertedCallNodePath + viewOptions.selectedCallNodePaths.NON_INVERTED_TREE ); const selectedInvertedCallNodePath = getUpdatedPath( - viewOptions.selectedInvertedCallNodePath - ); - const expandedNonInvertedCallNodePaths = getAncestorPathSet( - selectedNonInvertedCallNodePath - ); - const expandedInvertedCallNodePaths = getAncestorPathSet( - selectedInvertedCallNodePath + viewOptions.selectedCallNodePaths.INVERTED_TREE ); return _updateThreadViewOptions(state, threadsKey, { - selectedNonInvertedCallNodePath, - selectedInvertedCallNodePath, - expandedNonInvertedCallNodePaths, - expandedInvertedCallNodePaths, + selectedCallNodePaths: { + ...viewOptions.selectedCallNodePaths, + NON_INVERTED_TREE: selectedNonInvertedCallNodePath, + INVERTED_TREE: selectedInvertedCallNodePath, + }, + expandedCallNodePaths: { + ...viewOptions.expandedCallNodePaths, + NON_INVERTED_TREE: getAncestorPathSet( + selectedNonInvertedCallNodePath + ), + INVERTED_TREE: getAncestorPathSet(selectedInvertedCallNodePath), + }, }); } default: @@ -778,6 +867,7 @@ const rightClickedCallNode: Reducer = ( if (action.callNodePath !== null) { return { threadsKey: action.threadsKey, + area: action.area, callNodePath: action.callNodePath, }; } diff --git a/src/reducers/url-state.ts b/src/reducers/url-state.ts index ec155c30cc..dff3aa1058 100644 --- a/src/reducers/url-state.ts +++ b/src/reducers/url-state.ts @@ -24,6 +24,9 @@ import type { IsOpenPerPanelState, TabID, SelectedMarkersPerThread, + SelectedFunctionsPerThread, + FunctionListSectionsOpenState, + WingViewsState, } from 'firefox-profiler/types'; import type { TabSlug } from '../app-logic/tabs-handling'; @@ -218,6 +221,42 @@ const functionListSort: Reducer = ( } }; +const FUNCTION_LIST_SECTIONS_OPEN_DEFAULT: FunctionListSectionsOpenState = { + descendants: true, + ancestors: false, + self: false, +}; + +const functionListSectionsOpen: Reducer = ( + state = FUNCTION_LIST_SECTIONS_OPEN_DEFAULT, + action +) => { + switch (action.type) { + case 'CHANGE_FUNCTION_LIST_SECTION_OPEN': + return { ...state, [action.section]: action.isOpen }; + default: + return state; + } +}; + +const WING_VIEWS_DEFAULT: WingViewsState = { + upper: 'flame-graph', + lower: 'flame-graph', + self: 'flame-graph', +}; + +const wingViews: Reducer = ( + state = WING_VIEWS_DEFAULT, + action +) => { + switch (action.type) { + case 'CHANGE_WING_VIEW': + return { ...state, [action.wing]: action.view }; + default: + return state; + } +}; + const networkSearchString: Reducer = (state = '', action) => { switch (action.type) { case 'CHANGE_NETWORK_SEARCH_STRING': @@ -798,6 +837,26 @@ const selectedMarkers: Reducer = ( } }; +const selectedFunctions: Reducer = ( + state = {}, + action +): SelectedFunctionsPerThread => { + switch (action.type) { + case 'CHANGE_SELECTED_FUNCTION': { + const { threadsKey, selectedFunctionIndex } = action; + if (state[threadsKey] === selectedFunctionIndex) { + return state; + } + return { + ...state, + [threadsKey]: selectedFunctionIndex, + }; + } + default: + return state; + } +}; + /** * These values are specific to an individual profile. */ @@ -828,8 +887,11 @@ const profileSpecific = combineReducers({ showJsTracerSummary, tabFilter, selectedMarkers, + selectedFunctions, markerTableSort, functionListSort, + functionListSectionsOpen, + wingViews, // The timeline tracks used to be hidden and sorted by thread indexes, rather than // track indexes. The only way to migrate this information to tracks-based data is to // first retrieve the profile, so they can't be upgraded by the normal url upgrading diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index c14ddbc337..803d522046 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -11,6 +11,7 @@ import * as ProfileData from '../../profile-logic/profile-data'; import * as StackTiming from '../../profile-logic/stack-timing'; import * as FlameGraph from '../../profile-logic/flame-graph'; import * as CallTree from '../../profile-logic/call-tree'; +import * as Transforms from '../../profile-logic/transforms'; import type { PathSet } from '../../utils/path'; import * as ProfileSelectors from '../profile'; import { getRightClickedCallNodeInfo } from '../right-clicked-call-node'; @@ -43,10 +44,15 @@ import type { State, CallNodeTableBitSet, IndexIntoFuncTable, + IndexIntoStackTable, + SamplesLikeTable, + SampleCategoriesAndSubcategories, + CallNodeArea, } from 'firefox-profiler/types'; import type { CallNodeInfo, CallNodeInfoInverted, + LowerWingCallNodeInfo, } from 'firefox-profiler/profile-logic/call-node-info'; import type { SampleRelations } from 'firefox-profiler/profile-logic/profile-data'; @@ -213,12 +219,66 @@ export function getStackAndSampleSelectorsPerThread( } ); - const getSelectedFunctionIndex: Selector = + const getSelectedFunctionIndex: Selector = ( + state + ) => UrlState.getSelectedFunction(state, threadsKey); + + const getUpperWingCallNodeInfo: Selector = createSelector( + _getNonInvertedCallNodeInfo, + getSelectedFunctionIndex, + (state: State) => threadSelectors.getFilteredThread(state).stackTable, + (state: State) => threadSelectors.getFilteredThread(state).frameTable, + (state: State) => threadSelectors.getFilteredThread(state).funcTable.length, + ProfileSelectors.getDefaultCategory, + ProfileData.createUpperWingCallNodeInfo + ); + + // The lower wing uses its own dedicated CallNodeInfo: a single inverted + // root for the selected function, with the entire subtree built eagerly + // from that function's entry points. See LowerWingCallNodeInfo in + // call-node-info.ts. + const getLowerWingCallNodeInfo: Selector = + createSelector( + _getNonInvertedCallNodeInfo, + ProfileSelectors.getDefaultCategory, + (state: State) => + threadSelectors.getFilteredThread(state).funcTable.length, + getSelectedFunctionIndex, + ProfileData.getLowerWingCallNodeInfo + ); + + // Combines a CallNodeInfo selector with a CallNodePath selector to produce + // the corresponding call node index. + const makeCallNodeIndexFromPathSelector = ( + callNodeInfoSel: Selector, + pathSel: Selector + ): Selector => + createSelector(callNodeInfoSel, pathSel, (callNodeInfo, callNodePath) => + callNodeInfo.getCallNodeIndexFromPath(callNodePath) + ); + + // Combines a CallNodeInfo selector with an expanded-paths selector to + // produce the corresponding array of call node indexes. + const makeExpandedIndexesSelector = ( + callNodeInfoSel: Selector, + pathsSel: Selector + ): Selector> => + createSelector(callNodeInfoSel, pathsSel, (callNodeInfo, callNodePaths) => + Array.from(callNodePaths).map((path) => + callNodeInfo.getCallNodeIndexFromPath(path) + ) + ); + + const getLowerWingSelectedCallNodePath: Selector = createSelector( threadSelectors.getViewOptions, - (threadViewOptions): IndexIntoFuncTable | null => { - return threadViewOptions.selectedFunctionIndex; - } + (threadViewOptions) => threadViewOptions.selectedCallNodePaths.LOWER_WING + ); + + const getUpperWingSelectedCallNodePath: Selector = + createSelector( + threadSelectors.getViewOptions, + (threadViewOptions) => threadViewOptions.selectedCallNodePaths.UPPER_WING ); const getSelectedCallNodePath: Selector = createSelector( @@ -226,37 +286,53 @@ export function getStackAndSampleSelectorsPerThread( UrlState.getInvertCallstack, (threadViewOptions, invertCallStack): CallNodePath => invertCallStack - ? threadViewOptions.selectedInvertedCallNodePath - : threadViewOptions.selectedNonInvertedCallNodePath + ? threadViewOptions.selectedCallNodePaths.INVERTED_TREE + : threadViewOptions.selectedCallNodePaths.NON_INVERTED_TREE ); - const getSelectedCallNodeIndex: Selector = - createSelector( - getCallNodeInfo, - getSelectedCallNodePath, - (callNodeInfo, callNodePath) => { - return callNodeInfo.getCallNodeIndexFromPath(callNodePath); - } - ); + const getSelectedCallNodeIndex = makeCallNodeIndexFromPathSelector( + getCallNodeInfo, + getSelectedCallNodePath + ); + const getLowerWingSelectedCallNodeIndex = makeCallNodeIndexFromPathSelector( + getLowerWingCallNodeInfo, + getLowerWingSelectedCallNodePath + ); + const getUpperWingSelectedCallNodeIndex = makeCallNodeIndexFromPathSelector( + getUpperWingCallNodeInfo, + getUpperWingSelectedCallNodePath + ); const getExpandedCallNodePaths: Selector = createSelector( threadSelectors.getViewOptions, UrlState.getInvertCallstack, (threadViewOptions, invertCallStack) => invertCallStack - ? threadViewOptions.expandedInvertedCallNodePaths - : threadViewOptions.expandedNonInvertedCallNodePaths + ? threadViewOptions.expandedCallNodePaths.INVERTED_TREE + : threadViewOptions.expandedCallNodePaths.NON_INVERTED_TREE ); - const getExpandedCallNodeIndexes: Selector< - Array - > = createSelector( + const getLowerWingExpandedCallNodePaths: Selector = createSelector( + threadSelectors.getViewOptions, + (threadViewOptions) => threadViewOptions.expandedCallNodePaths.LOWER_WING + ); + + const getUpperWingExpandedCallNodePaths: Selector = createSelector( + threadSelectors.getViewOptions, + (threadViewOptions) => threadViewOptions.expandedCallNodePaths.UPPER_WING + ); + + const getExpandedCallNodeIndexes = makeExpandedIndexesSelector( getCallNodeInfo, - getExpandedCallNodePaths, - (callNodeInfo, callNodePaths) => - Array.from(callNodePaths).map((path) => - callNodeInfo.getCallNodeIndexFromPath(path) - ) + getExpandedCallNodePaths + ); + const getLowerWingExpandedCallNodeIndexes = makeExpandedIndexesSelector( + getLowerWingCallNodeInfo, + getLowerWingExpandedCallNodePaths + ); + const getUpperWingExpandedCallNodeIndexes = makeExpandedIndexesSelector( + getUpperWingCallNodeInfo, + getUpperWingExpandedCallNodePaths ); const _getSampleCallNodesForFilteredCtssThread: Selector< @@ -278,6 +354,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( @@ -458,12 +555,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, @@ -534,6 +676,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, @@ -665,6 +827,209 @@ 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 + ); + + // 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 getSelfWingCtssSampleCategoriesAndSubcategories: Selector = + createSelector( + getSelfWingThread, + _getSelfWingCtssSamples, + ProfileSelectors.getDefaultCategory, + CallTree.computeUnfilteredCtssSampleCategoriesAndSubcategories + ); + const getRightClickedCallNodeIndex: Selector = createSelector( getRightClickedCallNodeInfo, @@ -673,16 +1038,68 @@ export function getStackAndSampleSelectorsPerThread( if ( rightClickedCallNodeInfo !== null && threadsKey === rightClickedCallNodeInfo.threadsKey + ) { + const expectedArea = callNodeInfo.isInverted() + ? 'INVERTED_TREE' + : 'NON_INVERTED_TREE'; + if (rightClickedCallNodeInfo.area === expectedArea) { + return callNodeInfo.getCallNodeIndexFromPath( + rightClickedCallNodeInfo.callNodePath + ); + } + } + + return null; + } + ); + + // Returns the right-clicked call node index in `area` for this thread, or + // null if the right-clicked call node belongs to a different thread or area. + const makeAreaRightClickedCallNodeIndexSelector = ( + area: CallNodeArea, + callNodeInfoSel: Selector + ): Selector => + createSelector( + getRightClickedCallNodeInfo, + callNodeInfoSel, + (rightClickedCallNodeInfo, callNodeInfo) => { + if ( + rightClickedCallNodeInfo !== null && + rightClickedCallNodeInfo.threadsKey === threadsKey && + rightClickedCallNodeInfo.area === area ) { return callNodeInfo.getCallNodeIndexFromPath( rightClickedCallNodeInfo.callNodePath ); } - return null; } ); + const getLowerWingRightClickedCallNodeIndex = + makeAreaRightClickedCallNodeIndexSelector( + 'LOWER_WING', + getLowerWingCallNodeInfo + ); + + const getUpperWingRightClickedCallNodeIndex = + makeAreaRightClickedCallNodeIndexSelector( + 'UPPER_WING', + getUpperWingCallNodeInfo + ); + + const getLowerWingRightClickedFuncIndex: Selector = + createSelector( + getLowerWingRightClickedCallNodeIndex, + getLowerWingCallNodeInfo, + (callNodeIndex, callNodeInfo) => { + if (callNodeIndex === null) { + return null; + } + return callNodeInfo.funcForNode(callNodeIndex); + } + ); + const getRightClickedFunctionIndex: Selector = createSelector( ProfileSelectors.getProfileViewOptions, @@ -705,14 +1122,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, @@ -725,6 +1152,18 @@ export function getStackAndSampleSelectorsPerThread( getNonInvertedCallTree, getFunctionListTree, getFunctionListTimings, + getLowerWingCallTree, + getUpperWingCallTree, + getUpperWingFlameGraphTiming, + getLowerWingFlameGraphTiming, + getLowerWingFlameGraphMaxDepthPlusOne, + getSelfWingThread, + getSelfWingCallNodeInfo, + getSelfWingCallTree, + getSelfWingFlameGraphTiming, + getSelfWingCallNodeMaxDepthPlusOne, + getSelfWingCtssSamples, + getSelfWingCtssSampleCategoriesAndSubcategories, getSourceViewLineTimings, getAssemblyViewAddressTimings, getTracedTiming, @@ -737,5 +1176,8 @@ export function getStackAndSampleSelectorsPerThread( getFlameGraphTiming, getRightClickedCallNodeIndex, getRightClickedFunctionIndex, + getLowerWingRightClickedCallNodeIndex, + getLowerWingRightClickedFuncIndex, + getUpperWingRightClickedCallNodeIndex, }; } diff --git a/src/selectors/right-clicked-call-node.tsx b/src/selectors/right-clicked-call-node.tsx index a72dc73d35..f793e4df86 100644 --- a/src/selectors/right-clicked-call-node.tsx +++ b/src/selectors/right-clicked-call-node.tsx @@ -9,10 +9,12 @@ import type { ThreadsKey, CallNodePath, Selector, + CallNodeArea, } from 'firefox-profiler/types'; export type RightClickedCallNodeInfo = { readonly threadsKey: ThreadsKey; + readonly area: CallNodeArea; readonly callNodePath: CallNodePath; }; diff --git a/src/selectors/url-state.ts b/src/selectors/url-state.ts index 8165eab7ab..fb62961730 100644 --- a/src/selectors/url-state.ts +++ b/src/selectors/url-state.ts @@ -32,6 +32,10 @@ import type { TabID, IndexIntoSourceTable, MarkerIndex, + IndexIntoFuncTable, + FunctionListSectionsOpenState, + WingViewsState, + WingViewType, } from 'firefox-profiler/types'; import type { TabSlug } from '../app-logic/tabs-handling'; @@ -124,6 +128,17 @@ export const getMarkerTableSort: Selector = ( export const getFunctionListSort: Selector = ( state ) => getProfileSpecificState(state).functionListSort; +export const getFunctionListSectionsOpen: Selector< + FunctionListSectionsOpenState +> = (state) => getProfileSpecificState(state).functionListSectionsOpen; +export const getWingViews: Selector = (state) => + getProfileSpecificState(state).wingViews; +export const getUpperWingView: Selector = (state) => + getWingViews(state).upper; +export const getLowerWingView: Selector = (state) => + getWingViews(state).lower; +export const getSelfWingView: Selector = (state) => + getWingViews(state).self; export const getNetworkSearchString: Selector = (state) => getProfileSpecificState(state).networkSearchString; export const getSelectedTab: Selector = (state) => @@ -260,6 +275,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/store/__snapshots__/profile-view.test.ts.snap b/src/test/store/__snapshots__/profile-view.test.ts.snap index 55e98646be..e6c0622900 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -4428,27 +4428,38 @@ Process: \\"default\\" (0)" exports[`snapshots of selectors/profile matches the last stored run of selectedThreadSelector.getViewOptions 1`] = ` Object { - "expandedInvertedCallNodePaths": PathSet { - "_table": Map {}, - }, - "expandedNonInvertedCallNodePaths": PathSet { - "_table": Map { - "0" => Array [ - 0, - ], - "0-1" => Array [ - 0, - 1, - ], + "expandedCallNodePaths": Object { + "INVERTED_TREE": PathSet { + "_table": Map {}, + }, + "LOWER_WING": PathSet { + "_table": Map {}, + }, + "NON_INVERTED_TREE": PathSet { + "_table": Map { + "0" => Array [ + 0, + ], + "0-1" => Array [ + 0, + 1, + ], + }, + }, + "UPPER_WING": PathSet { + "_table": Map {}, }, }, "lastSeenTransformCount": 1, - "selectedFunctionIndex": null, - "selectedInvertedCallNodePath": Array [], + "selectedCallNodePaths": Object { + "INVERTED_TREE": Array [], + "LOWER_WING": Array [], + "NON_INVERTED_TREE": Array [ + 0, + 1, + ], + "UPPER_WING": Array [], + }, "selectedNetworkMarker": null, - "selectedNonInvertedCallNodePath": Array [ - 0, - 1, - ], } `; diff --git a/src/test/store/profile-view.test.ts b/src/test/store/profile-view.test.ts index 58863b5142..aa4edaed92 100644 --- a/src/test/store/profile-view.test.ts +++ b/src/test/store/profile-view.test.ts @@ -3629,6 +3629,7 @@ describe('right clicked call node info', () => { expect(getRightClickedCallNodeInfo(getState())).toEqual({ threadsKey: 0, + area: 'NON_INVERTED_TREE', callNodePath: [0, 1], }); }); @@ -3640,6 +3641,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..c250e2e3b7 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; } | { @@ -582,6 +584,16 @@ type UrlStateAction = readonly type: 'CHANGE_FUNCTION_LIST_SORT'; readonly sort: SingleColumnSortState[] | null; } + | { + readonly type: 'CHANGE_FUNCTION_LIST_SECTION_OPEN'; + readonly section: 'descendants' | 'ancestors' | 'self'; + readonly isOpen: boolean; + } + | { + readonly type: 'CHANGE_WING_VIEW'; + readonly wing: 'upper' | 'lower' | 'self'; + readonly view: 'flame-graph' | 'call-tree'; + } | { readonly type: 'CHANGE_NETWORK_SEARCH_STRING'; readonly searchString: string; diff --git a/src/types/state.ts b/src/types/state.ts index 84df211bba..d9d212c9bc 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -57,12 +57,10 @@ export type SourceMapSymbolicationStatus = | 'INACTIVE' | 'FETCHING' | 'SYMBOLICATING'; + export type ThreadViewOptions = { - readonly selectedNonInvertedCallNodePath: CallNodePath; - readonly selectedInvertedCallNodePath: CallNodePath; - readonly expandedNonInvertedCallNodePaths: PathSet; - readonly expandedInvertedCallNodePaths: PathSet; - readonly selectedFunctionIndex: IndexIntoFuncTable | null; + readonly selectedCallNodePaths: Record; + readonly expandedCallNodePaths: Record; readonly selectedNetworkMarker: MarkerIndex | null; // Track the number of transforms to detect when they change via browser // navigation. This helps us know when to reset paths that may be invalid @@ -80,8 +78,20 @@ export type TableViewOptions = { export type TableViewOptionsPerTab = { [K in TabSlug]: TableViewOptions }; +export type CallNodeArea = + | 'NON_INVERTED_TREE' + | 'INVERTED_TREE' + | 'LOWER_WING' + | 'UPPER_WING'; + +// State-bearing function-list wings: each has its own selected/expanded/ +// right-clicked call node path. The self wing has no such state of its own +// (it shares it with the main tree). +export type WingName = 'upper' | 'lower'; + export type RightClickedCallNode = { readonly threadsKey: ThreadsKey; + readonly area: CallNodeArea; readonly callNodePath: CallNodePath; }; @@ -99,6 +109,10 @@ export type SelectedMarkersPerThread = { [key: ThreadsKey]: MarkerIndex | null; }; +export type SelectedFunctionsPerThread = { + [key: ThreadsKey]: IndexIntoFuncTable | null; +}; + /** * Profile view state */ @@ -394,8 +408,25 @@ export type ProfileSpecificUrlState = { legacyThreadOrder: ThreadIndex[] | null; legacyHiddenThreads: ThreadIndex[] | null; selectedMarkers: SelectedMarkersPerThread; + selectedFunctions: SelectedFunctionsPerThread; markerTableSort: SingleColumnSortState[] | null; functionListSort: SingleColumnSortState[] | null; + functionListSectionsOpen: FunctionListSectionsOpenState; + wingViews: WingViewsState; +}; + +export type FunctionListSectionsOpenState = { + descendants: boolean; + ancestors: boolean; + self: boolean; +}; + +export type WingViewType = 'flame-graph' | 'call-tree'; + +export type WingViewsState = { + upper: WingViewType; + lower: WingViewType; + self: WingViewType; }; export type UrlState = { From 702c3f5885283e02687ed9b3d4dedfb8cdfbe8c9 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:55:52 -0400 Subject: [PATCH 15/18] 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 ce4736b839c6f6e87764c66129a22e75025a08bc Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 3 Sep 2026 15:45:44 -0400 Subject: [PATCH 16/18] 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. 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/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} | 157 +++++--- .../FunctionListContextMenu.test.tsx | 2 +- .../components/LowerWingContextMenu.test.tsx | 119 ++++++ 7 files changed, 749 insertions(+), 65 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} (74%) create mode 100644 src/test/components/LowerWingContextMenu.test.tsx 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 74% rename from src/components/shared/FunctionListContextMenu.tsx rename to src/components/shared/WingContextMenu.tsx index 8625c16999..efa9e18310 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 { @@ -52,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; }; @@ -116,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', @@ -125,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', @@ -140,8 +141,11 @@ const MENU_ITEMS: ReadonlyArray = [ type StateProps = { readonly thread: Thread | null; readonly threadsKey: ThreadsKey | null; - readonly rightClickedFunctionIndex: IndexIntoFuncTable | null; - readonly callNodeTable: CallNodeTable | null; + readonly funcIndex: IndexIntoFuncTable | null; + // Call node tables in which to check for recursion. Multiple tables are + // useful for the self wing menu, where the focusSelf-filtered table may + // reveal recursion that the regular table hides. + readonly callNodeTables: ReadonlyArray; readonly implementation: ImplementationFilter; readonly displaySearchfox: boolean; }; @@ -152,9 +156,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 = () => { @@ -175,22 +183,10 @@ class FunctionListContextMenuImpl extends PureComponent { readonly thread: Thread; readonly threadsKey: ThreadsKey; readonly funcIndex: IndexIntoFuncTable; - readonly callNodeTable: CallNodeTable; } { - const { thread, threadsKey, rightClickedFunctionIndex, callNodeTable } = - this.props; - if ( - thread !== null && - threadsKey !== null && - rightClickedFunctionIndex !== null && - callNodeTable !== null - ) { - return { - thread, - threadsKey, - funcIndex: rightClickedFunctionIndex, - callNodeTable, - }; + const { thread, threadsKey, funcIndex } = this.props; + if (thread !== null && threadsKey !== null && funcIndex !== null) { + return { thread, threadsKey, funcIndex }; } return null; } @@ -323,7 +319,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); @@ -380,7 +376,7 @@ class FunctionListContextMenuImpl extends PureComponent { } renderContextMenuContents() { - const { displaySearchfox } = this.props; + const { displaySearchfox, callNodeTables } = this.props; const info = this._getRightClickedInfo(); if (info === null) { @@ -392,7 +388,7 @@ class FunctionListContextMenuImpl extends PureComponent { const ctx: MenuItemContext = { funcIndex: info.funcIndex, - callNodeTable: info.callNodeTable, + callNodeTables, nameForResource: this.getNameForSelectedResource(), }; @@ -432,7 +428,7 @@ class FunctionListContextMenuImpl extends PureComponent { return ( { } } +const dispatchToProps: DispatchProps = { + addTransformToStack, + addCollapseResourceTransformToStack, + setContextMenuVisibility, +}; + +// Connected variant used by the function list and self wing: the right-clicked +// function comes from the rightClickedFunction profile-view state. Recursion +// detection considers both the regular call node table and the self wing's +// call node table (where the focusSelf filter may surface recursion that the +// regular table hides). export const FunctionListContextMenu = explicitConnect< {}, StateProps, @@ -452,35 +459,83 @@ export const FunctionListContextMenu = explicitConnect< const rightClickedFunction = getProfileViewOptions(state).rightClickedFunction; - let thread = null; - let threadsKey = null; - let rightClickedFunctionIndex = null; - let callNodeTable = null; - - if (rightClickedFunction !== null) { - const selectors = getThreadSelectorsFromThreadsKey( - rightClickedFunction.threadsKey - ); - thread = selectors.getFilteredThread(state); - threadsKey = rightClickedFunction.threadsKey; - rightClickedFunctionIndex = rightClickedFunction.functionIndex; - // Use the non-inverted call node table for recursion detection. - callNodeTable = selectors.getCallNodeInfo(state).getCallNodeTable(); + if (rightClickedFunction === null) { + return { + thread: null, + threadsKey: null, + funcIndex: null, + callNodeTables: [], + implementation: getImplementationFilter(state), + displaySearchfox: getShouldDisplaySearchfox(state), + }; } + const selectors = getThreadSelectorsFromThreadsKey( + rightClickedFunction.threadsKey + ); + const callNodeTables: CallNodeTable[] = [ + selectors.getCallNodeInfo(state).getCallNodeTable(), + selectors.getSelfWingCallNodeInfo(state).getCallNodeTable(), + ]; + return { - thread, - threadsKey, - rightClickedFunctionIndex, - callNodeTable, + thread: selectors.getFilteredThread(state), + threadsKey: rightClickedFunction.threadsKey, + funcIndex: rightClickedFunction.functionIndex, + callNodeTables, implementation: getImplementationFilter(state), displaySearchfox: getShouldDisplaySearchfox(state), }; }, - mapDispatchToProps: { - addTransformToStack, - addCollapseResourceTransformToStack, - setContextMenuVisibility, + mapDispatchToProps: dispatchToProps, + component: (props) => ( + + ), +}); + +// Connected variant used by the lower wing: the right-clicked function comes +// from a right-clicked call node in the LOWER_WING area. Only the regular +// call node table is consulted for recursion. +export const LowerWingContextMenu = explicitConnect< + {}, + StateProps, + DispatchProps +>({ + mapStateToProps: (state: State) => { + const rightClickedCallNodeInfo = getRightClickedCallNodeInfo(state); + + if ( + rightClickedCallNodeInfo === null || + rightClickedCallNodeInfo.area !== 'LOWER_WING' + ) { + return { + thread: null, + threadsKey: null, + funcIndex: null, + callNodeTables: [], + implementation: getImplementationFilter(state), + displaySearchfox: getShouldDisplaySearchfox(state), + }; + } + + const selectors = getThreadSelectorsFromThreadsKey( + rightClickedCallNodeInfo.threadsKey + ); + const callNodeTables: CallNodeTable[] = [ + selectors.getCallNodeInfo(state).getCallNodeTable(), + ]; + + return { + thread: selectors.getFilteredThread(state), + threadsKey: rightClickedCallNodeInfo.threadsKey, + funcIndex: selectors.getLowerWingRightClickedFuncIndex(state), + callNodeTables, + implementation: getImplementationFilter(state), + displaySearchfox: getShouldDisplaySearchfox(state), + }; }, - component: FunctionListContextMenuImpl, + mapDispatchToProps: dispatchToProps, + component: (props) => ( + + ), }); diff --git a/src/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'); + }); + }); +}); From 601a083fa640d12defa5fe4dd8e41afdfaced10e Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 3 Sep 2026 15:45:44 -0400 Subject: [PATCH 17/18] 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". --- .../calltree/ProfileFunctionListView.tsx | 11 + src/components/calltree/SelfWing.tsx | 338 ++++++++++++++++++ src/selectors/per-thread/stack-sample.ts | 5 + 3 files changed, 354 insertions(+) create mode 100644 src/components/calltree/SelfWing.tsx diff --git a/src/components/calltree/ProfileFunctionListView.tsx b/src/components/calltree/ProfileFunctionListView.tsx index 024dde5877..83d6ea25d0 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 { StackSettings } from 'firefox-profiler/components/shared/StackSettings'; @@ -36,6 +37,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; @@ -71,6 +75,13 @@ 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/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index 803d522046..bb2cd457aa 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -1022,6 +1022,10 @@ export function getStackAndSampleSelectorsPerThread( const getSelfWingCtssSamples: Selector = _getSelfWingCtssSamples; + const getSelfWingCtssSampleCallNodes: Selector< + Array + > = _getSelfWingSampleIndexToCallNodeIndex; + const getSelfWingCtssSampleCategoriesAndSubcategories: Selector = createSelector( getSelfWingThread, @@ -1163,6 +1167,7 @@ export function getStackAndSampleSelectorsPerThread( getSelfWingFlameGraphTiming, getSelfWingCallNodeMaxDepthPlusOne, getSelfWingCtssSamples, + getSelfWingCtssSampleCallNodes, getSelfWingCtssSampleCategoriesAndSubcategories, getSourceViewLineTimings, getAssemblyViewAddressTimings, From 253a2d122ab6fcd42d7eeaa04a577bd378b2ce92 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 3 Sep 2026 15:45:38 -0400 Subject: [PATCH 18/18] Add flame graph views for the function list wings. Each wing 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. --- .../calltree/LowerWingFlameGraph.tsx | 267 +++++++++++++++++ .../calltree/ProfileFunctionListView.tsx | 4 + .../calltree/UpperWingFlameGraph.tsx | 281 ++++++++++++++++++ src/components/calltree/WingTreeView.tsx | 54 +++- src/components/calltree/WingViewToggle.css | 44 +++ src/components/calltree/WingViewToggle.tsx | 123 ++++++++ src/profile-logic/flame-graph.ts | 10 +- src/selectors/per-thread/stack-sample.ts | 2 + 8 files changed, 777 insertions(+), 8 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/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 83d6ea25d0..a3c2f0900b 100644 --- a/src/components/calltree/ProfileFunctionListView.tsx +++ b/src/components/calltree/ProfileFunctionListView.tsx @@ -9,6 +9,7 @@ import { FunctionList } from './FunctionList'; import { SelfWing } from './SelfWing'; import { UpperWing, LowerWing } from './WingTreeView'; import { DisclosureBox } from 'firefox-profiler/components/shared/DisclosureBox'; +import { WingViewToggle } from './WingViewToggle'; import { StackSettings } from 'firefox-profiler/components/shared/StackSettings'; import { TransformNavigator } from 'firefox-profiler/components/shared/TransformNavigator'; import { ResizableWithSplitter } from '../shared/ResizableWithSplitter'; @@ -65,6 +66,7 @@ class ProfileFunctionListViewImpl extends React.PureComponent { label="Descendants" isOpen={sectionsOpen.descendants} onToggle={this._onDescendantsToggle} + headerActions={} > @@ -72,6 +74,7 @@ class ProfileFunctionListViewImpl extends React.PureComponent { label="Ancestors" isOpen={sectionsOpen.ancestors} onToggle={this._onAncestorsToggle} + headerActions={} > @@ -79,6 +82,7 @@ class ProfileFunctionListViewImpl extends React.PureComponent { label="Self" isOpen={sectionsOpen.self} onToggle={this._onSelfToggle} + 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..44ab072baa --- /dev/null +++ b/src/components/calltree/WingViewToggle.tsx @@ -0,0 +1,123 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import * as React from 'react'; + +import explicitConnect from 'firefox-profiler/utils/connect'; +import { + getUpperWingView, + getLowerWingView, + getSelfWingView, +} from 'firefox-profiler/selectors/url-state'; +import { changeWingView } from 'firefox-profiler/actions/profile-view'; +import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; + +import type { State, WingViewType } from 'firefox-profiler/types'; +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +import './WingViewToggle.css'; + +type OwnProps = { + readonly wing: 'upper' | 'lower' | 'self'; +}; + +type StateProps = { + readonly view: WingViewType; +}; + +type DispatchProps = { + readonly changeWingView: typeof changeWingView; +}; + +type Props = ConnectedProps; + +class WingViewToggleImpl extends React.PureComponent { + _onFlameGraphClick = (event: React.MouseEvent) => { + event.stopPropagation(); + this.props.changeWingView(this.props.wing, 'flame-graph'); + }; + _onCallTreeClick = (event: React.MouseEvent) => { + event.stopPropagation(); + this.props.changeWingView(this.props.wing, 'call-tree'); + }; + + override render() { + const { view } = this.props; + return ( +
+ + +
+ ); + } +} + +export const WingViewToggle = explicitConnect< + OwnProps, + StateProps, + DispatchProps +>({ + mapStateToProps: (state: State, ownProps: OwnProps) => { + let view; + switch (ownProps.wing) { + case 'upper': + view = getUpperWingView(state); + break; + case 'lower': + view = getLowerWingView(state); + break; + case 'self': + view = getSelfWingView(state); + break; + default: + throw assertExhaustiveCheck(ownProps.wing, 'Unhandled wing.'); + } + return { view }; + }, + mapDispatchToProps: { + changeWingView, + }, + component: WingViewToggleImpl, +}); diff --git a/src/profile-logic/flame-graph.ts b/src/profile-logic/flame-graph.ts index 66ae2202c4..d4ab25025b 100644 --- a/src/profile-logic/flame-graph.ts +++ b/src/profile-logic/flame-graph.ts @@ -663,7 +663,7 @@ export type LowerWingFlameGraphTotals = { rootTotalSummary: number; // Equal to rootTotalSummary — the lower wing's flame graph root fills the // entire width. - flameGraphTotalForScaling: number; + flameGraphWidthTotal: number; }; /** @@ -949,8 +949,8 @@ export class LowerWingFlameGraphTiming implements FlameGraphTiming { this._growPerTableIdxArraysTo(table.length); const rowNodes = this._rowsCallNodes[nextDepth]; - const flameGraphTotalForScaling = this._rootTotalSummary; - if (flameGraphTotalForScaling === 0) { + const flameGraphWidthTotal = this._rootTotalSummary; + if (flameGraphWidthTotal === 0) { this._timingRows.push({ start: [], end: [], @@ -1017,8 +1017,8 @@ export class LowerWingFlameGraphTiming implements FlameGraphTiming { } startPerTableIdx[tableIdx] = currentStart; - const totalRelative = abs(totalVal / flameGraphTotalForScaling); - const selfRelativeVal = abs(selfVal / flameGraphTotalForScaling); + const totalRelative = abs(totalVal / flameGraphWidthTotal); + const selfRelativeVal = abs(selfVal / flameGraphWidthTotal); const currentEnd = currentStart + totalRelative; start.push(currentStart); diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index bb2cd457aa..a47c505495 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -1128,6 +1128,8 @@ export function getStackAndSampleSelectorsPerThread( getNonInvertedCallNodeInfo, getLowerWingCallNodeInfo, getUpperWingCallNodeInfo, + getPreviewFilteredCtssSampleUpperWingCallNodes: + _getPreviewFilteredCtssSampleIndexToUpperWingCallNodeIndex, getSourceViewStackLineInfo, getAssemblyViewNativeSymbolIndex, getAssemblyViewStackAddressInfo,