diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts
index 80c350cfd9..d53d90b1d4 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,
@@ -74,12 +75,16 @@ import type {
TableViewOptions,
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';
@@ -121,7 +126,7 @@ export function changeSelectedCallNode(
const isInverted = getInvertCallstack(getState());
dispatch({
type: 'CHANGE_SELECTED_CALL_NODE',
- isInverted,
+ area: isInverted ? 'INVERTED_TREE' : 'NON_INVERTED_TREE',
selectedCallNodePath,
optionalExpandedToCallNodePath,
threadsKey,
@@ -130,6 +135,53 @@ 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' }
+): ThunkAction {
+ return (dispatch, getState) => {
+ dispatch({
+ type: 'CHANGE_SELECTED_FUNCTION',
+ selectedFunctionIndex,
+ threadsKey,
+ context,
+ });
+ // Update window.history synchronously instead of waiting for the
+ // UrlManager's componentDidUpdate, which is deferred by React's render
+ // scheduling and would otherwise pushState a new entry.
+ replaceHistoryWithUrlState(getUrlState(getState()));
+ };
+}
+
/**
* This action is used when the user right clicks on a call node (in panels such
* as the call tree, the flame chart, or the stack chart). It's especially used
@@ -138,10 +190,38 @@ export function changeSelectedCallNode(
export function changeRightClickedCallNode(
threadsKey: ThreadsKey,
callNodePath: CallNodePath | null
+): ThunkAction {
+ return (dispatch, getState) => {
+ const isInverted = getInvertCallstack(getState());
+ dispatch({
+ type: 'CHANGE_RIGHT_CLICKED_CALL_NODE',
+ threadsKey,
+ area: isInverted ? 'INVERTED_TREE' : 'NON_INVERTED_TREE',
+ callNodePath,
+ });
+ };
+}
+
+export function changeRightClickedFunctionIndex(
+ threadsKey: ThreadsKey,
+ functionIndex: IndexIntoFuncTable | null
+): Action {
+ return {
+ type: 'CHANGE_RIGHT_CLICKED_FUNCTION',
+ threadsKey,
+ functionIndex,
+ };
+}
+
+export function changeWingRightClickedCallNode(
+ wing: WingName,
+ threadsKey: ThreadsKey,
+ callNodePath: CallNodePath | null
): Action {
return {
type: 'CHANGE_RIGHT_CLICKED_CALL_NODE',
threadsKey,
+ area: WING_AREAS[wing],
callNodePath,
};
}
@@ -208,6 +288,40 @@ export function selectSelfCallNode(
};
}
+/**
+ * Like selectSelfCallNode, but selects the function of the self call node
+ * instead. Used when the function list tab is active.
+ */
+export function selectSelfFunction(
+ threadsKey: ThreadsKey,
+ sampleIndex: IndexIntoSamplesTable | null
+): ThunkAction {
+ return (dispatch, getState) => {
+ if (sampleIndex === null || sampleIndex < 0) {
+ dispatch(changeSelectedFunctionIndex(threadsKey, null));
+ return;
+ }
+ const threadSelectors = getThreadSelectorsFromThreadsKey(threadsKey);
+ const sampleCallNodes =
+ threadSelectors.getSampleIndexToNonInvertedCallNodeIndexForFilteredThread(
+ getState()
+ );
+ if (sampleIndex >= sampleCallNodes.length) {
+ dispatch(changeSelectedFunctionIndex(threadsKey, null));
+ return;
+ }
+ const nonInvertedSelfCallNode = sampleCallNodes[sampleIndex];
+ if (nonInvertedSelfCallNode === null) {
+ dispatch(changeSelectedFunctionIndex(threadsKey, null));
+ return;
+ }
+ const callNodeInfo = threadSelectors.getCallNodeInfo(getState());
+ const funcIndex =
+ callNodeInfo.getCallNodeTable().func[nonInvertedSelfCallNode];
+ dispatch(changeSelectedFunctionIndex(threadsKey, funcIndex));
+ };
+}
+
/**
* This selects a set of thread from thread indexes.
* Please use it in tests only.
@@ -1546,12 +1660,26 @@ export function changeExpandedCallNodes(
const isInverted = getInvertCallstack(getState());
dispatch({
type: 'CHANGE_EXPANDED_CALL_NODES',
- isInverted,
+ area: isInverted ? 'INVERTED_TREE' : 'NON_INVERTED_TREE',
threadsKey,
expandedCallNodePaths,
});
};
}
+
+export function changeWingExpandedCallNodes(
+ wing: WingName,
+ threadsKey: ThreadsKey,
+ expandedCallNodePaths: Array
+): Action {
+ return {
+ type: 'CHANGE_EXPANDED_CALL_NODES',
+ area: WING_AREAS[wing],
+ threadsKey,
+ expandedCallNodePaths,
+ };
+}
+
export function changeSelectedMarker(
threadsKey: ThreadsKey,
selectedMarker: MarkerIndex | null,
@@ -1625,6 +1753,37 @@ export function changeMarkerTableSort(
};
}
+export function changeFunctionListSort(
+ sort: SingleColumnSortState[] | null
+): Action {
+ return {
+ type: 'CHANGE_FUNCTION_LIST_SORT',
+ sort,
+ };
+}
+
+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',
@@ -2033,49 +2192,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,
})
);
@@ -2097,8 +2296,7 @@ export function handleCallNodeTransformShortcut(
);
break;
case 'C': {
- const { funcTable } = unfilteredThread;
- const resourceIndex = funcTable.resource[funcIndex];
+ const resourceIndex = unfilteredThread.funcTable.resource[funcIndex];
dispatch(
addCollapseResourceTransformToStack(
threadsKey,
@@ -2131,7 +2329,7 @@ export function handleCallNodeTransformShortcut(
}
break;
}
- case 'c': {
+ case 'c':
dispatch(
addTransformToStack(threadsKey, {
type: 'collapse-function-subtree',
@@ -2139,17 +2337,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/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 85a0b35e88..7f6d57959c 100644
--- a/src/app-logic/url-handling.ts
+++ b/src/app-logic/url-handling.ts
@@ -42,6 +42,9 @@ import type {
IndexIntoFrameTable,
MarkerIndex,
SelectedMarkersPerThread,
+ SelectedFunctionsPerThread,
+ FunctionListSectionsOpenState,
+ WingViewsState,
} from 'firefox-profiler/types';
import {
decodeUintArrayFromUrlComponent,
@@ -120,6 +123,19 @@ export function getIsHistoryReplaceState(): boolean {
return _isReplaceState;
}
+/**
+ * Synchronously replace the current history entry to match the given UrlState.
+ *
+ * Use this from action thunks for high-frequency actions (e.g. arrow-key
+ * navigation) where deferring the URL update to UrlManager.componentDidUpdate
+ * would result in unwanted pushState calls and history-flooding. By updating
+ * window.history synchronously here, the URL already matches the new state by
+ * the time UrlManager's componentDidUpdate runs, so it becomes a no-op.
+ */
+export function replaceHistoryWithUrlState(urlState: UrlState): void {
+ window.history.replaceState(urlState, document.title, urlFromState(urlState));
+}
+
function getPathParts(urlState: UrlState): string[] {
const { dataSource } = urlState;
switch (dataSource) {
@@ -187,6 +203,10 @@ type CallTreeQuery = BaseQuery & {
invertFlameGraph: null | undefined;
hideIdleSamples: null | undefined;
ctSummary: string;
+ functionListSort?: string; // "total-desc~self-asc" — primary first
+ funcListSections?: string; // "descendants,self" — comma-separated open sections
+ wingViews?: string; // "upper:call-tree,lower:call-tree" — non-default wing views
+ selectedFunc?: number; // Selected function index for the current thread, e.g. 42
};
type MarkersQuery = BaseQuery & {
@@ -235,6 +255,12 @@ type Query = BaseQuery & {
marker?: MarkerIndex;
markerSort?: string;
+ // Function list specific
+ functionListSort?: string;
+ funcListSections?: string;
+ wingViews?: string;
+ selectedFunc?: number;
+
// Network specific
networkSearch?: string;
@@ -341,6 +367,7 @@ export function getQueryStringFromUrlState(urlState: UrlState): string {
: undefined;
/* fallsthrough */
case 'flame-graph':
+ case 'function-list':
case 'calltree': {
query = baseQuery as CallTreeQueryShape;
@@ -391,6 +418,25 @@ export function getQueryStringFromUrlState(urlState: UrlState): string {
query.bottomFullscreen = true;
}
}
+ if (selectedTab === 'function-list') {
+ 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;
}
case 'marker-table':
@@ -555,6 +601,19 @@ export function stateFromLocation(
}
}
+ // Parse the selected function for the current thread
+ const selectedFunctions: SelectedFunctionsPerThread = {};
+ if (
+ selectedThreadsKey !== null &&
+ query.selectedFunc !== undefined &&
+ query.selectedFunc !== null
+ ) {
+ const funcIndex = Number(query.selectedFunc);
+ if (Number.isInteger(funcIndex) && funcIndex >= 0) {
+ selectedFunctions[selectedThreadsKey] = funcIndex;
+ }
+ }
+
// tabID is used for the tab selector that we have in our full view.
let tabID = null;
if (query.tabID && Number.isInteger(Number(query.tabID))) {
@@ -647,7 +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),
},
};
}
@@ -694,6 +761,141 @@ 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();
+}
+
+// FunctionList section disclosure-box open/closed state. The URL stores a
+// comma-separated list of the open sections; the param is omitted when the
+// state matches the default (only "descendants" open). The value "none" is
+// used as a sentinel for the all-closed case so the param is non-empty.
+const FUNCTION_LIST_SECTION_NAMES: ReadonlyArray<
+ keyof FunctionListSectionsOpenState
+> = ['descendants', 'ancestors', 'self'];
+const FUNCTION_LIST_SECTIONS_OPEN_DEFAULT: FunctionListSectionsOpenState = {
+ descendants: true,
+ ancestors: false,
+ self: false,
+};
+
+function convertFunctionListSectionsOpenToString(
+ state: FunctionListSectionsOpenState
+): string | undefined {
+ const matchesDefault = FUNCTION_LIST_SECTION_NAMES.every(
+ (name) => state[name] === FUNCTION_LIST_SECTIONS_OPEN_DEFAULT[name]
+ );
+ if (matchesDefault) {
+ return undefined;
+ }
+ const open = FUNCTION_LIST_SECTION_NAMES.filter((name) => state[name]);
+ return open.length === 0 ? 'none' : open.join(',');
+}
+
+function convertFunctionListSectionsOpenFromString(
+ raw: string | null | void
+): FunctionListSectionsOpenState {
+ if (raw === undefined || raw === null) {
+ return { ...FUNCTION_LIST_SECTIONS_OPEN_DEFAULT };
+ }
+ const result: FunctionListSectionsOpenState = {
+ descendants: false,
+ ancestors: false,
+ self: false,
+ };
+ if (raw === 'none' || raw === '') {
+ return result;
+ }
+ for (const part of raw.split(',')) {
+ if (part === 'descendants' || part === 'ancestors' || part === 'self') {
+ result[part] = true;
+ }
+ }
+ return result;
+}
+
+// WingView visualization choice for the Descendants ("upper"), Ancestors
+// ("lower"), and Self wings of the FunctionList tab. The URL stores a
+// comma-separated list of "wing:view" pairs for wings whose view differs from
+// the default (flame-graph), e.g. "upper:call-tree". The param is omitted
+// when all wings use the default.
+const WING_VIEWS_DEFAULT: WingViewsState = {
+ upper: 'flame-graph',
+ lower: 'flame-graph',
+ self: 'flame-graph',
+};
+const WING_VIEW_NAMES: ReadonlyArray = [
+ 'upper',
+ 'lower',
+ 'self',
+];
+
+function convertWingViewsToString(state: WingViewsState): string | undefined {
+ const parts: string[] = [];
+ for (const wing of WING_VIEW_NAMES) {
+ if (state[wing] !== WING_VIEWS_DEFAULT[wing]) {
+ parts.push(`${wing}:${state[wing]}`);
+ }
+ }
+ return parts.length === 0 ? undefined : parts.join(',');
+}
+
+function convertWingViewsFromString(raw: string | null | void): WingViewsState {
+ const result: WingViewsState = { ...WING_VIEWS_DEFAULT };
+ if (raw === undefined || raw === null || raw === '') {
+ return result;
+ }
+ for (const part of raw.split(',')) {
+ const [wing, view] = part.split(':');
+ if (
+ (wing === 'upper' || wing === 'lower' || wing === 'self') &&
+ (view === 'flame-graph' || view === 'call-tree')
+ ) {
+ result[wing] = view;
+ }
+ }
+ return result;
+}
+
function convertGlobalTrackOrderFromString(
rawString: string | null | void
): TrackIndex[] {
diff --git a/src/components/app/Details.tsx b/src/components/app/Details.tsx
index 643e6c5929..0ac75ddc70 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/';
@@ -26,6 +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,
+ LowerWingContextMenu,
+} from 'firefox-profiler/components/shared/WingContextMenu';
import { MaybeMarkerContextMenu } from 'firefox-profiler/components/shared/MarkerContextMenu';
import { toValidTabSlug } from 'firefox-profiler/utils/types';
@@ -122,6 +127,7 @@ class ProfileViewerImpl extends PureComponent {
{
{
calltree: ,
+ 'function-list': ,
'flame-graph': ,
'stack-chart': ,
'marker-chart': ,
@@ -133,6 +139,8 @@ 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/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..09d8b2e371
--- /dev/null
+++ b/src/components/calltree/FunctionList.tsx
@@ -0,0 +1,272 @@
+/* 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,
+ handleFunctionTransformShortcut,
+} 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 { 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';
+
+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 callNodeInfo: CallNodeInfo;
+ 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 handleFunctionTransformShortcut: typeof handleFunctionTransformShortcut;
+ 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,
+ callNodeInfo,
+ handleFunctionTransformShortcut,
+ } = this.props;
+ const funcIndex =
+ rightClickedFunctionIndex !== null
+ ? rightClickedFunctionIndex
+ : selectedFunctionIndex;
+ if (funcIndex === null) {
+ return;
+ }
+ handleFunctionTransformShortcut(event, threadsKey, callNodeInfo, funcIndex);
+ };
+
+ _onEnterOrDoubleClick = (nodeId: IndexIntoFuncTable) => {
+ const { tree, updateBottomBoxContentsAndMaybeOpen } = this.props;
+ 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),
+ callNodeInfo: selectedThreadSelectors.getCallNodeInfo(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,
+ handleFunctionTransformShortcut,
+ onTableViewOptionsChange: (options: TableViewOptions) =>
+ changeTableViewOptions('calltree', options),
+ changeFunctionListSort,
+ },
+ component: FunctionListImpl,
+});
diff --git a/src/components/calltree/LowerWingFlameGraph.tsx b/src/components/calltree/LowerWingFlameGraph.tsx
new file mode 100644
index 0000000000..fc27795c8d
--- /dev/null
+++ b/src/components/calltree/LowerWingFlameGraph.tsx
@@ -0,0 +1,262 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import * as React from 'react';
+
+import { explicitConnectWithForwardRef } from 'firefox-profiler/utils/connect';
+import { FlameGraph } from 'firefox-profiler/components/flame-graph/FlameGraph';
+
+import {
+ getCategories,
+ getCommittedRange,
+ getPreviewSelection,
+ getScrollToSelectionGeneration,
+ getProfileInterval,
+ getInnerWindowIDToPageMap,
+ getProfileUsesMultipleStackTypes,
+} from 'firefox-profiler/selectors/profile';
+import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread';
+import { getSelectedThreadsKey } from 'firefox-profiler/selectors/url-state';
+import {
+ changeWingSelectedCallNode,
+ changeWingRightClickedCallNode,
+ handleCallNodeTransformShortcut,
+ updateBottomBoxContentsAndMaybeOpen,
+} from 'firefox-profiler/actions/profile-view';
+
+import type {
+ Thread,
+ CategoryList,
+ Milliseconds,
+ StartEndRange,
+ WeightType,
+ SamplesLikeTable,
+ PreviewSelection,
+ CallTreeSummaryStrategy,
+ IndexIntoCallNodeTable,
+ ThreadsKey,
+ InnerWindowID,
+ Page,
+ SampleCategoriesAndSubcategories,
+ SelectionContext,
+ CallNodePath,
+} from 'firefox-profiler/types';
+
+import type { FlameGraphTiming } from 'firefox-profiler/profile-logic/flame-graph';
+import type { CallNodeInfo } from 'firefox-profiler/profile-logic/call-node-info';
+
+import type {
+ CallTree,
+ CallTreeTimings,
+} from 'firefox-profiler/profile-logic/call-tree';
+
+import type { ConnectedProps } from 'firefox-profiler/utils/connect';
+
+type StateProps = {
+ readonly thread: Thread;
+ readonly weightType: WeightType;
+ readonly innerWindowIDToPageMap: Map | null;
+ readonly maxStackDepthPlusOne: number;
+ readonly timeRange: StartEndRange;
+ readonly previewSelection: PreviewSelection | null;
+ readonly flameGraphTiming: FlameGraphTiming;
+ readonly callTree: CallTree;
+ readonly callNodeInfo: CallNodeInfo;
+ readonly threadsKey: ThreadsKey;
+ readonly selectedCallNodeIndex: IndexIntoCallNodeTable | null;
+ readonly rightClickedCallNodeIndex: IndexIntoCallNodeTable | null;
+ readonly scrollToSelectionGeneration: number;
+ readonly categories: CategoryList;
+ readonly interval: Milliseconds;
+ readonly callTreeSummaryStrategy: CallTreeSummaryStrategy;
+ readonly ctssSamples: SamplesLikeTable;
+ readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories;
+ readonly tracedTiming: CallTreeTimings | null;
+ readonly displayStackType: boolean;
+};
+
+type DispatchProps = {
+ readonly changeSelectedCallNode: (
+ threadsKey: ThreadsKey,
+ path: CallNodePath,
+ context?: SelectionContext
+ ) => any;
+ readonly changeRightClickedCallNode: (
+ threadsKey: ThreadsKey,
+ path: CallNodePath | null
+ ) => any;
+ readonly handleCallNodeTransformShortcut: typeof handleCallNodeTransformShortcut;
+ readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen;
+};
+
+type Props = ConnectedProps<{}, StateProps, DispatchProps>;
+
+export interface LowerWingFlameGraphHandle {
+ focus(): void;
+}
+
+class LowerWingFlameGraphImpl
+ extends React.PureComponent
+ implements LowerWingFlameGraphHandle
+{
+ _flameGraph: React.RefObject = React.createRef();
+
+ // eslint-disable-next-line react/no-unused-class-component-methods -- called via LowerWingFlameGraphHandle ref from LowerWingImpl
+ focus() {
+ this._flameGraph.current?.focus();
+ }
+
+ _onSelectedCallNodeChange = (
+ callNodeIndex: IndexIntoCallNodeTable | null
+ ) => {
+ const { callNodeInfo, threadsKey, changeSelectedCallNode } = this.props;
+ const context: SelectionContext = { source: 'pointer' };
+ changeSelectedCallNode(
+ threadsKey,
+ callNodeInfo.getCallNodePathFromIndex(callNodeIndex),
+ context
+ );
+ };
+
+ _onRightClickedCallNodeChange = (
+ callNodeIndex: IndexIntoCallNodeTable | null
+ ) => {
+ const { callNodeInfo, threadsKey, changeRightClickedCallNode } = this.props;
+ changeRightClickedCallNode(
+ threadsKey,
+ callNodeInfo.getCallNodePathFromIndex(callNodeIndex)
+ );
+ };
+
+ _onCallNodeEnterOrDoubleClick = (
+ callNodeIndex: IndexIntoCallNodeTable | null
+ ) => {
+ if (callNodeIndex === null) {
+ return;
+ }
+ const { callTree, updateBottomBoxContentsAndMaybeOpen } = this.props;
+ const bottomBoxInfo = callTree.getBottomBoxInfoForCallNode(callNodeIndex);
+ updateBottomBoxContentsAndMaybeOpen('function-list', bottomBoxInfo);
+ };
+
+ _onKeyboardTransformShortcut = (
+ event: React.KeyboardEvent,
+ nodeIndex: IndexIntoCallNodeTable
+ ) => {
+ const { threadsKey, callNodeInfo, handleCallNodeTransformShortcut } =
+ this.props;
+ handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex);
+ };
+
+ override render() {
+ const {
+ thread,
+ threadsKey,
+ maxStackDepthPlusOne,
+ flameGraphTiming,
+ callTree,
+ callNodeInfo,
+ timeRange,
+ previewSelection,
+ rightClickedCallNodeIndex,
+ selectedCallNodeIndex,
+ scrollToSelectionGeneration,
+ callTreeSummaryStrategy,
+ categories,
+ interval,
+ innerWindowIDToPageMap,
+ weightType,
+ ctssSamples,
+ ctssSampleCategoriesAndSubcategories,
+ tracedTiming,
+ displayStackType,
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+export const LowerWingFlameGraph = explicitConnectWithForwardRef<
+ {},
+ StateProps,
+ DispatchProps,
+ LowerWingFlameGraphHandle
+>({
+ mapStateToProps: (state) => ({
+ thread: selectedThreadSelectors.getPreviewFilteredThread(state),
+ weightType: selectedThreadSelectors.getWeightTypeForCallTree(state),
+ maxStackDepthPlusOne:
+ selectedThreadSelectors.getLowerWingFlameGraphMaxDepthPlusOne(state),
+ flameGraphTiming:
+ selectedThreadSelectors.getLowerWingFlameGraphTiming(state),
+ callTree: selectedThreadSelectors.getLowerWingCallTree(state),
+ timeRange: getCommittedRange(state),
+ previewSelection: getPreviewSelection(state),
+ callNodeInfo: selectedThreadSelectors.getLowerWingCallNodeInfo(state),
+ categories: getCategories(state),
+ threadsKey: getSelectedThreadsKey(state),
+ selectedCallNodeIndex:
+ selectedThreadSelectors.getLowerWingSelectedCallNodeIndex(state),
+ rightClickedCallNodeIndex:
+ selectedThreadSelectors.getLowerWingRightClickedCallNodeIndex(state),
+ scrollToSelectionGeneration: getScrollToSelectionGeneration(state),
+ interval: getProfileInterval(state),
+ callTreeSummaryStrategy:
+ selectedThreadSelectors.getCallTreeSummaryStrategy(state),
+ innerWindowIDToPageMap: getInnerWindowIDToPageMap(state),
+ ctssSamples: selectedThreadSelectors.getPreviewFilteredCtssSamples(state),
+ ctssSampleCategoriesAndSubcategories:
+ selectedThreadSelectors.getPreviewFilteredCtssSampleCategoriesAndSubcategories(
+ state
+ ),
+ tracedTiming: selectedThreadSelectors.getTracedTiming(state),
+ displayStackType: getProfileUsesMultipleStackTypes(state),
+ }),
+ mapDispatchToProps: {
+ changeSelectedCallNode: (
+ threadsKey: ThreadsKey,
+ path: CallNodePath,
+ context?: SelectionContext
+ ) => changeWingSelectedCallNode('lower', threadsKey, path, context),
+ changeRightClickedCallNode: (
+ threadsKey: ThreadsKey,
+ path: CallNodePath | null
+ ) => changeWingRightClickedCallNode('lower', threadsKey, path),
+ handleCallNodeTransformShortcut,
+ updateBottomBoxContentsAndMaybeOpen,
+ },
+ component: LowerWingFlameGraphImpl,
+});
diff --git a/src/components/calltree/ProfileFunctionListView.tsx b/src/components/calltree/ProfileFunctionListView.tsx
new file mode 100644
index 0000000000..a3c2f0900b
--- /dev/null
+++ b/src/components/calltree/ProfileFunctionListView.tsx
@@ -0,0 +1,108 @@
+/* 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 { FunctionList } from './FunctionList';
+import { SelfWing } from './SelfWing';
+import { UpperWing, LowerWing } from './WingTreeView';
+import { DisclosureBox } from 'firefox-profiler/components/shared/DisclosureBox';
+import { WingViewToggle } from './WingViewToggle';
+import { StackSettings } from 'firefox-profiler/components/shared/StackSettings';
+import { TransformNavigator } from 'firefox-profiler/components/shared/TransformNavigator';
+import { ResizableWithSplitter } from '../shared/ResizableWithSplitter';
+import { getFunctionListSectionsOpen } from 'firefox-profiler/selectors/url-state';
+import { changeFunctionListSectionOpen } from 'firefox-profiler/actions/profile-view';
+
+import type { FunctionListSectionsOpenState } from 'firefox-profiler/types';
+import type { ConnectedProps } from 'firefox-profiler/utils/connect';
+
+import './Butterfly.css';
+
+type StateProps = {
+ readonly sectionsOpen: FunctionListSectionsOpenState;
+};
+
+type DispatchProps = {
+ readonly changeFunctionListSectionOpen: typeof changeFunctionListSectionOpen;
+};
+
+type Props = ConnectedProps<{}, StateProps, DispatchProps>;
+
+class ProfileFunctionListViewImpl extends React.PureComponent {
+ _onDescendantsToggle = (isOpen: boolean) => {
+ this.props.changeFunctionListSectionOpen('descendants', isOpen);
+ };
+ _onAncestorsToggle = (isOpen: boolean) => {
+ this.props.changeFunctionListSectionOpen('ancestors', isOpen);
+ };
+ _onSelfToggle = (isOpen: boolean) => {
+ this.props.changeFunctionListSectionOpen('self', isOpen);
+ };
+
+ override render() {
+ const { sectionsOpen } = this.props;
+ return (
+
+
+
+
+
+
+ }
+ >
+
+
+ }
+ >
+
+
+ }
+ >
+
+
+
+
+
+ );
+ }
+}
+
+export const ProfileFunctionListView = explicitConnect<
+ {},
+ StateProps,
+ DispatchProps
+>({
+ mapStateToProps: (state) => ({
+ sectionsOpen: getFunctionListSectionsOpen(state),
+ }),
+ mapDispatchToProps: {
+ changeFunctionListSectionOpen,
+ },
+ component: ProfileFunctionListViewImpl,
+});
diff --git a/src/components/calltree/SelfWing.tsx b/src/components/calltree/SelfWing.tsx
new file mode 100644
index 0000000000..07de24874f
--- /dev/null
+++ b/src/components/calltree/SelfWing.tsx
@@ -0,0 +1,333 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+// @flow
+
+import * as React from 'react';
+
+import explicitConnect from 'firefox-profiler/utils/connect';
+import { FlameGraph } from 'firefox-profiler/components/flame-graph/FlameGraph';
+import { TreeView } from 'firefox-profiler/components/shared/TreeView';
+import { CallTreeEmptyReasons } from './CallTreeEmptyReasons';
+import { nameColumn, libColumn, treeColumnsForWeightType } from './columns';
+
+import {
+ getCategories,
+ getCommittedRange,
+ getPreviewSelection,
+ getScrollToSelectionGeneration,
+ getProfileInterval,
+ getInnerWindowIDToPageMap,
+ getProfileUsesMultipleStackTypes,
+ getCurrentTableViewOptions,
+ getPreviewSelectionIsBeingModified,
+} from 'firefox-profiler/selectors/profile';
+import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread';
+import {
+ getSelectedThreadsKey,
+ getSearchStringsAsRegExp,
+ getSelfWingView,
+} from 'firefox-profiler/selectors/url-state';
+import {
+ updateBottomBoxContentsAndMaybeOpen,
+ changeRightClickedFunctionIndex,
+ changeTableViewOptions,
+} from 'firefox-profiler/actions/profile-view';
+
+import type {
+ Thread,
+ CategoryList,
+ Milliseconds,
+ StartEndRange,
+ WeightType,
+ SamplesLikeTable,
+ PreviewSelection,
+ CallTreeSummaryStrategy,
+ IndexIntoCallNodeTable,
+ ThreadsKey,
+ InnerWindowID,
+ Page,
+ SampleCategoriesAndSubcategories,
+ CallNodeDisplayData,
+ TableViewOptions,
+ WingViewType,
+} from 'firefox-profiler/types';
+
+import type { FlameGraphTiming } from 'firefox-profiler/profile-logic/flame-graph';
+import type { CallNodeInfo } from 'firefox-profiler/profile-logic/call-node-info';
+import type { CallTree } from 'firefox-profiler/profile-logic/call-tree';
+
+import type { ConnectedProps } from 'firefox-profiler/utils/connect';
+
+type StateProps = {
+ readonly thread: Thread;
+ readonly weightType: WeightType;
+ readonly innerWindowIDToPageMap: Map | null;
+ readonly maxStackDepthPlusOne: number;
+ readonly timeRange: StartEndRange;
+ readonly previewSelection: PreviewSelection | null;
+ readonly flameGraphTiming: FlameGraphTiming;
+ readonly callTree: CallTree;
+ readonly callNodeInfo: CallNodeInfo;
+ readonly threadsKey: ThreadsKey;
+ readonly scrollToSelectionGeneration: number;
+ readonly categories: CategoryList;
+ readonly interval: Milliseconds;
+ readonly callTreeSummaryStrategy: CallTreeSummaryStrategy;
+ readonly ctssSamples: SamplesLikeTable;
+ readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories;
+ readonly displayStackType: boolean;
+ readonly searchStringsRegExp: RegExp | null;
+ readonly disableOverscan: boolean;
+ readonly tableViewOptions: TableViewOptions;
+ readonly view: WingViewType;
+};
+
+type DispatchProps = {
+ readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen;
+ readonly changeRightClickedFunctionIndex: typeof changeRightClickedFunctionIndex;
+ readonly onTableViewOptionsChange: (options: TableViewOptions) => any;
+};
+
+type Props = ConnectedProps<{}, StateProps, DispatchProps>;
+
+type LocalState = {
+ selectedCallNodeIndex: IndexIntoCallNodeTable | null;
+ rightClickedCallNodeIndex: IndexIntoCallNodeTable | null;
+ expandedCallNodeIndexes: Array;
+};
+
+class SelfWingImpl extends React.PureComponent {
+ override state: LocalState = {
+ selectedCallNodeIndex: null,
+ rightClickedCallNodeIndex: null,
+ expandedCallNodeIndexes: [],
+ };
+
+ _treeView: TreeView | null = null;
+ _takeTreeViewRef = (treeView: TreeView | null) => {
+ this._treeView = treeView;
+ };
+
+ override componentDidUpdate(prevProps: Props, _prevState: LocalState) {
+ // Reset local selection when the call node info changes (e.g. different
+ // function selected) since old call node indices are no longer valid.
+ if (
+ prevProps.callNodeInfo !== this.props.callNodeInfo ||
+ prevProps.threadsKey !== this.props.threadsKey
+ ) {
+ this.setState({
+ selectedCallNodeIndex: null,
+ rightClickedCallNodeIndex: null,
+ expandedCallNodeIndexes: [],
+ });
+ }
+ }
+
+ _onSelectedCallNodeChange = (
+ callNodeIndex: IndexIntoCallNodeTable | null
+ ) => {
+ this.setState({ selectedCallNodeIndex: callNodeIndex });
+ };
+
+ _onRightClickedCallNodeChange = (
+ callNodeIndex: IndexIntoCallNodeTable | null
+ ) => {
+ this.setState({ rightClickedCallNodeIndex: callNodeIndex });
+ const { callNodeInfo, threadsKey, changeRightClickedFunctionIndex } =
+ this.props;
+ const funcIndex =
+ callNodeIndex !== null ? callNodeInfo.funcForNode(callNodeIndex) : null;
+ changeRightClickedFunctionIndex(threadsKey, funcIndex);
+ };
+
+ _onTreeViewSelectionChange = (
+ callNodeIndex: IndexIntoCallNodeTable,
+ context: { source: 'keyboard' | 'pointer' }
+ ) => {
+ this.setState({ selectedCallNodeIndex: callNodeIndex }, () => {
+ // Selection in this wing is local state, so the Redux-driven
+ // scrollToSelectionGeneration mechanism used by the other wings does not
+ // apply. Scroll directly when keyboard navigation moves the selection.
+ if (context.source === 'keyboard' && this._treeView) {
+ this._treeView.scrollSelectionIntoView();
+ }
+ });
+ };
+
+ _onTreeViewRightClickSelection = (callNodeIndex: IndexIntoCallNodeTable) => {
+ this._onRightClickedCallNodeChange(callNodeIndex);
+ };
+
+ _onTreeViewExpandedNodesChange = (
+ expandedCallNodeIndexes: Array
+ ) => {
+ this.setState({ expandedCallNodeIndexes });
+ };
+
+ _onCallNodeEnterOrDoubleClick = (
+ callNodeIndex: IndexIntoCallNodeTable | null
+ ) => {
+ if (callNodeIndex === null) {
+ return;
+ }
+ const { callTree, updateBottomBoxContentsAndMaybeOpen } = this.props;
+ const bottomBoxInfo = callTree.getBottomBoxInfoForCallNode(callNodeIndex);
+ updateBottomBoxContentsAndMaybeOpen('function-list', bottomBoxInfo);
+ };
+
+ // Transforms are disabled in the SelfWing because it operates on an ephemeral
+ // thread that is not part of the Redux transform stack.
+ _onKeyboardTransformShortcut = (
+ _event: React.KeyboardEvent,
+ _nodeIndex: IndexIntoCallNodeTable
+ ) => {};
+
+ _renderFlameGraph() {
+ const {
+ thread,
+ threadsKey,
+ maxStackDepthPlusOne,
+ flameGraphTiming,
+ callTree,
+ callNodeInfo,
+ timeRange,
+ previewSelection,
+ scrollToSelectionGeneration,
+ callTreeSummaryStrategy,
+ categories,
+ interval,
+ innerWindowIDToPageMap,
+ weightType,
+ ctssSamples,
+ ctssSampleCategoriesAndSubcategories,
+ displayStackType,
+ } = this.props;
+
+ const { selectedCallNodeIndex, rightClickedCallNodeIndex } = this.state;
+
+ return (
+
+ );
+ }
+
+ _renderCallTree() {
+ const {
+ callTree,
+ searchStringsRegExp,
+ disableOverscan,
+ maxStackDepthPlusOne,
+ weightType,
+ tableViewOptions,
+ onTableViewOptionsChange,
+ } = this.props;
+ const {
+ selectedCallNodeIndex,
+ rightClickedCallNodeIndex,
+ expandedCallNodeIndexes,
+ } = this.state;
+ if (callTree.getRoots().length === 0) {
+ return ;
+ }
+ return (
+
+ );
+ }
+
+ override render() {
+ if (this.props.view === 'call-tree') {
+ return this._renderCallTree();
+ }
+ return this._renderFlameGraph();
+ }
+}
+
+export const SelfWing = explicitConnect<{}, StateProps, DispatchProps>({
+ mapStateToProps: (state) => ({
+ thread: selectedThreadSelectors.getSelfWingThread(state),
+ weightType: selectedThreadSelectors.getWeightTypeForCallTree(state),
+ maxStackDepthPlusOne:
+ selectedThreadSelectors.getSelfWingCallNodeMaxDepthPlusOne(state),
+ flameGraphTiming:
+ selectedThreadSelectors.getSelfWingFlameGraphTiming(state),
+ callTree: selectedThreadSelectors.getSelfWingCallTree(state),
+ timeRange: getCommittedRange(state),
+ previewSelection: getPreviewSelection(state),
+ callNodeInfo: selectedThreadSelectors.getSelfWingCallNodeInfo(state),
+ categories: getCategories(state),
+ threadsKey: getSelectedThreadsKey(state),
+ scrollToSelectionGeneration: getScrollToSelectionGeneration(state),
+ interval: getProfileInterval(state),
+ callTreeSummaryStrategy:
+ selectedThreadSelectors.getCallTreeSummaryStrategy(state),
+ innerWindowIDToPageMap: getInnerWindowIDToPageMap(state),
+ ctssSamples: selectedThreadSelectors.getSelfWingCtssSamples(state),
+ ctssSampleCategoriesAndSubcategories:
+ selectedThreadSelectors.getSelfWingCtssSampleCategoriesAndSubcategories(
+ state
+ ),
+ displayStackType: getProfileUsesMultipleStackTypes(state),
+ searchStringsRegExp: getSearchStringsAsRegExp(state),
+ disableOverscan: getPreviewSelectionIsBeingModified(state),
+ tableViewOptions: getCurrentTableViewOptions(state),
+ view: getSelfWingView(state),
+ }),
+ mapDispatchToProps: {
+ updateBottomBoxContentsAndMaybeOpen,
+ changeRightClickedFunctionIndex,
+ onTableViewOptionsChange: (options: TableViewOptions) =>
+ changeTableViewOptions('calltree', options),
+ },
+ component: SelfWingImpl,
+});
diff --git a/src/components/calltree/UpperWingFlameGraph.tsx b/src/components/calltree/UpperWingFlameGraph.tsx
new file mode 100644
index 0000000000..01c5f3e8b5
--- /dev/null
+++ b/src/components/calltree/UpperWingFlameGraph.tsx
@@ -0,0 +1,274 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+// @flow
+
+import * as React from 'react';
+
+import { explicitConnectWithForwardRef } from 'firefox-profiler/utils/connect';
+import { FlameGraph } from 'firefox-profiler/components/flame-graph/FlameGraph';
+
+import {
+ getCategories,
+ getCommittedRange,
+ getPreviewSelection,
+ getScrollToSelectionGeneration,
+ getProfileInterval,
+ getInnerWindowIDToPageMap,
+ getProfileUsesMultipleStackTypes,
+} from 'firefox-profiler/selectors/profile';
+import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread';
+import { getSelectedThreadsKey } from 'firefox-profiler/selectors/url-state';
+import {
+ changeWingSelectedCallNode,
+ changeWingRightClickedCallNode,
+ changeRightClickedFunctionIndex,
+ handleCallNodeTransformShortcut,
+ updateBottomBoxContentsAndMaybeOpen,
+} from 'firefox-profiler/actions/profile-view';
+
+import type {
+ Thread,
+ CategoryList,
+ Milliseconds,
+ StartEndRange,
+ WeightType,
+ SamplesLikeTable,
+ PreviewSelection,
+ CallTreeSummaryStrategy,
+ IndexIntoCallNodeTable,
+ ThreadsKey,
+ InnerWindowID,
+ Page,
+ SampleCategoriesAndSubcategories,
+ SelectionContext,
+ CallNodePath,
+} from 'firefox-profiler/types';
+
+import type { FlameGraphTiming } from 'firefox-profiler/profile-logic/flame-graph';
+import type { CallNodeInfo } from 'firefox-profiler/profile-logic/call-node-info';
+
+import type {
+ CallTree,
+ CallTreeTimings,
+} from 'firefox-profiler/profile-logic/call-tree';
+
+import type { ConnectedProps } from 'firefox-profiler/utils/connect';
+
+type StateProps = {
+ readonly thread: Thread;
+ readonly weightType: WeightType;
+ readonly innerWindowIDToPageMap: Map | null;
+ readonly maxStackDepthPlusOne: number;
+ readonly timeRange: StartEndRange;
+ readonly previewSelection: PreviewSelection | null;
+ readonly flameGraphTiming: FlameGraphTiming;
+ readonly callTree: CallTree;
+ readonly callNodeInfo: CallNodeInfo;
+ readonly threadsKey: ThreadsKey;
+ readonly selectedCallNodeIndex: IndexIntoCallNodeTable | null;
+ readonly rightClickedCallNodeIndex: IndexIntoCallNodeTable | null;
+ readonly scrollToSelectionGeneration: number;
+ readonly categories: CategoryList;
+ readonly interval: Milliseconds;
+ readonly callTreeSummaryStrategy: CallTreeSummaryStrategy;
+ readonly ctssSamples: SamplesLikeTable;
+ readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories;
+ readonly tracedTiming: CallTreeTimings | null;
+ readonly displayStackType: boolean;
+};
+
+type DispatchProps = {
+ readonly changeSelectedCallNode: (
+ threadsKey: ThreadsKey,
+ path: CallNodePath,
+ context?: SelectionContext
+ ) => any;
+ readonly changeRightClickedCallNode: (
+ threadsKey: ThreadsKey,
+ path: CallNodePath | null
+ ) => any;
+ readonly changeRightClickedFunctionIndex: typeof changeRightClickedFunctionIndex;
+ readonly handleCallNodeTransformShortcut: typeof handleCallNodeTransformShortcut;
+ readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen;
+};
+
+type Props = ConnectedProps<{}, StateProps, DispatchProps>;
+
+export interface UpperWingFlameGraphHandle {
+ focus(): void;
+}
+
+class UpperWingFlameGraphImpl
+ extends React.PureComponent
+ implements UpperWingFlameGraphHandle
+{
+ _flameGraph: React.RefObject = React.createRef();
+
+ // eslint-disable-next-line react/no-unused-class-component-methods -- called via UpperWingFlameGraphHandle ref from UpperWingImpl
+ focus() {
+ this._flameGraph.current?.focus();
+ }
+
+ _onSelectedCallNodeChange = (
+ callNodeIndex: IndexIntoCallNodeTable | null
+ ) => {
+ const { callNodeInfo, threadsKey, changeSelectedCallNode } = this.props;
+ const context: SelectionContext = { source: 'pointer' };
+ changeSelectedCallNode(
+ threadsKey,
+ callNodeInfo.getCallNodePathFromIndex(callNodeIndex),
+ context
+ );
+ };
+
+ _onRightClickedCallNodeChange = (
+ callNodeIndex: IndexIntoCallNodeTable | null
+ ) => {
+ const {
+ callNodeInfo,
+ threadsKey,
+ changeRightClickedCallNode,
+ changeRightClickedFunctionIndex,
+ } = this.props;
+ changeRightClickedCallNode(
+ threadsKey,
+ callNodeInfo.getCallNodePathFromIndex(callNodeIndex)
+ );
+ const funcIndex =
+ callNodeIndex !== null ? callNodeInfo.funcForNode(callNodeIndex) : null;
+ changeRightClickedFunctionIndex(threadsKey, funcIndex);
+ };
+
+ _onCallNodeEnterOrDoubleClick = (
+ callNodeIndex: IndexIntoCallNodeTable | null
+ ) => {
+ if (callNodeIndex === null) {
+ return;
+ }
+ const { callTree, updateBottomBoxContentsAndMaybeOpen } = this.props;
+ const bottomBoxInfo = callTree.getBottomBoxInfoForCallNode(callNodeIndex);
+ updateBottomBoxContentsAndMaybeOpen('function-list', bottomBoxInfo);
+ };
+
+ _onKeyboardTransformShortcut = (
+ event: React.KeyboardEvent,
+ nodeIndex: IndexIntoCallNodeTable
+ ) => {
+ const { threadsKey, callNodeInfo, handleCallNodeTransformShortcut } =
+ this.props;
+ handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex);
+ };
+
+ override render() {
+ const {
+ thread,
+ threadsKey,
+ maxStackDepthPlusOne,
+ flameGraphTiming,
+ callTree,
+ callNodeInfo,
+ timeRange,
+ previewSelection,
+ rightClickedCallNodeIndex,
+ selectedCallNodeIndex,
+ scrollToSelectionGeneration,
+ callTreeSummaryStrategy,
+ categories,
+ interval,
+ innerWindowIDToPageMap,
+ weightType,
+ ctssSamples,
+ ctssSampleCategoriesAndSubcategories,
+ tracedTiming,
+ displayStackType,
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+export const UpperWingFlameGraph = explicitConnectWithForwardRef<
+ {},
+ StateProps,
+ DispatchProps,
+ UpperWingFlameGraphHandle
+>({
+ mapStateToProps: (state) => ({
+ thread: selectedThreadSelectors.getPreviewFilteredThread(state),
+ weightType: selectedThreadSelectors.getWeightTypeForCallTree(state),
+ maxStackDepthPlusOne:
+ selectedThreadSelectors.getFilteredCallNodeMaxDepthPlusOne(state),
+ flameGraphTiming:
+ selectedThreadSelectors.getUpperWingFlameGraphTiming(state),
+ callTree: selectedThreadSelectors.getUpperWingCallTree(state),
+ timeRange: getCommittedRange(state),
+ previewSelection: getPreviewSelection(state),
+ callNodeInfo: selectedThreadSelectors.getUpperWingCallNodeInfo(state),
+ categories: getCategories(state),
+ threadsKey: getSelectedThreadsKey(state),
+ selectedCallNodeIndex:
+ selectedThreadSelectors.getUpperWingSelectedCallNodeIndex(state),
+ rightClickedCallNodeIndex:
+ selectedThreadSelectors.getUpperWingRightClickedCallNodeIndex(state),
+ scrollToSelectionGeneration: getScrollToSelectionGeneration(state),
+ interval: getProfileInterval(state),
+ callTreeSummaryStrategy:
+ selectedThreadSelectors.getCallTreeSummaryStrategy(state),
+ innerWindowIDToPageMap: getInnerWindowIDToPageMap(state),
+ ctssSamples: selectedThreadSelectors.getPreviewFilteredCtssSamples(state),
+ ctssSampleCategoriesAndSubcategories:
+ selectedThreadSelectors.getPreviewFilteredCtssSampleCategoriesAndSubcategories(
+ state
+ ),
+ tracedTiming: selectedThreadSelectors.getTracedTiming(state),
+ displayStackType: getProfileUsesMultipleStackTypes(state),
+ }),
+ mapDispatchToProps: {
+ changeSelectedCallNode: (
+ threadsKey: ThreadsKey,
+ path: CallNodePath,
+ context?: SelectionContext
+ ) => changeWingSelectedCallNode('upper', threadsKey, path, context),
+ changeRightClickedCallNode: (
+ threadsKey: ThreadsKey,
+ path: CallNodePath | null
+ ) => changeWingRightClickedCallNode('upper', threadsKey, path),
+ changeRightClickedFunctionIndex,
+ handleCallNodeTransformShortcut,
+ updateBottomBoxContentsAndMaybeOpen,
+ },
+ component: UpperWingFlameGraphImpl,
+});
diff --git a/src/components/calltree/WingTreeView.tsx b/src/components/calltree/WingTreeView.tsx
new file mode 100644
index 0000000000..e6141e5bad
--- /dev/null
+++ b/src/components/calltree/WingTreeView.tsx
@@ -0,0 +1,426 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import * as React from 'react';
+import { PureComponent } from 'react';
+import explicitConnect from 'firefox-profiler/utils/connect';
+import { TreeView } from 'firefox-profiler/components/shared/TreeView';
+import { CallTreeEmptyReasons } from './CallTreeEmptyReasons';
+import {
+ UpperWingFlameGraph,
+ type UpperWingFlameGraphHandle,
+} from './UpperWingFlameGraph';
+import {
+ LowerWingFlameGraph,
+ type LowerWingFlameGraphHandle,
+} from './LowerWingFlameGraph';
+import { nameColumn, libColumn, treeColumnsForWeightType } from './columns';
+import {
+ getSearchStringsAsRegExp,
+ getSelectedThreadsKey,
+ getUpperWingView,
+ getLowerWingView,
+} from 'firefox-profiler/selectors/url-state';
+import {
+ getScrollToSelectionGeneration,
+ getCategories,
+ getCurrentTableViewOptions,
+ getPreviewSelectionIsBeingModified,
+} from 'firefox-profiler/selectors/profile';
+import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread';
+import {
+ changeWingSelectedCallNode,
+ changeWingRightClickedCallNode,
+ changeWingExpandedCallNodes,
+ addTransformToStack,
+ handleCallNodeTransformShortcut,
+ changeTableViewOptions,
+ updateBottomBoxContentsAndMaybeOpen,
+} from 'firefox-profiler/actions/profile-view';
+import type {
+ State,
+ ThreadsKey,
+ CategoryList,
+ IndexIntoCallNodeTable,
+ CallNodeDisplayData,
+ WeightType,
+ TableViewOptions,
+ SelectionContext,
+ WingViewType,
+ CallNodePath,
+ WingName,
+} from 'firefox-profiler/types';
+import type { CallTree as CallTreeType } from 'firefox-profiler/profile-logic/call-tree';
+import type { CallNodeInfo } from 'firefox-profiler/profile-logic/call-node-info';
+
+import type { ConnectedProps } from 'firefox-profiler/utils/connect';
+
+import './CallTree.css';
+
+// Structural interface satisfied by both wing flame graph components' ref
+// handles. Used so the shared impl can call focus() on either.
+type WingFlameGraphHandle = { focus(): void };
+
+type StateProps = {
+ readonly threadsKey: ThreadsKey;
+ readonly scrollToSelectionGeneration: number;
+ readonly tree: CallTreeType;
+ readonly callNodeInfo: CallNodeInfo;
+ readonly categories: CategoryList;
+ readonly selectedCallNodeIndex: IndexIntoCallNodeTable | null;
+ readonly rightClickedCallNodeIndex: IndexIntoCallNodeTable | null;
+ readonly expandedCallNodeIndexes: Array;
+ readonly searchStringsRegExp: RegExp | null;
+ readonly disableOverscan: boolean;
+ readonly callNodeMaxDepthPlusOne: number;
+ readonly weightType: WeightType;
+ readonly tableViewOptions: TableViewOptions;
+ readonly view: WingViewType;
+};
+
+type DispatchProps = {
+ readonly changeSelectedCallNode: (
+ threadsKey: ThreadsKey,
+ path: CallNodePath,
+ context?: SelectionContext
+ ) => any;
+ readonly changeRightClickedCallNode: (
+ threadsKey: ThreadsKey,
+ path: CallNodePath | null
+ ) => any;
+ readonly changeExpandedCallNodes: (
+ threadsKey: ThreadsKey,
+ paths: Array
+ ) => any;
+ readonly addTransformToStack: typeof addTransformToStack;
+ readonly handleCallNodeTransformShortcut: typeof handleCallNodeTransformShortcut;
+ readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen;
+ readonly onTableViewOptionsChange: (options: TableViewOptions) => any;
+};
+
+// Wing-specific bits that vary between Upper and Lower wing. Injected by
+// each wrapper below.
+type WingConfigProps = {
+ readonly contextMenuId: string;
+ readonly renderFlameGraph: (
+ ref: React.RefObject
+ ) => React.ReactNode;
+};
+
+type Props = ConnectedProps<{}, StateProps, DispatchProps> & WingConfigProps;
+
+class WingTreeViewImpl extends PureComponent {
+ _treeView: TreeView | null = null;
+ _takeTreeViewRef = (treeView: TreeView | null) => {
+ this._treeView = treeView;
+ };
+ _flameGraphRef: React.RefObject =
+ React.createRef();
+
+ override componentDidMount() {
+ this.focus();
+ this.maybeProcureInterestingInitialSelection();
+
+ if (this.props.selectedCallNodeIndex !== null && this._treeView) {
+ this._treeView.scrollSelectionIntoView();
+ }
+ }
+
+ override componentDidUpdate(prevProps: Props) {
+ this.maybeProcureInterestingInitialSelection();
+
+ if (
+ this.props.selectedCallNodeIndex !== null &&
+ this.props.scrollToSelectionGeneration >
+ prevProps.scrollToSelectionGeneration &&
+ this._treeView
+ ) {
+ this._treeView.scrollSelectionIntoView();
+ }
+ }
+
+ focus() {
+ if (this.props.view === 'flame-graph') {
+ this._flameGraphRef.current?.focus();
+ return;
+ }
+ if (this._treeView) {
+ this._treeView.focus();
+ }
+ }
+
+ _onSelectedCallNodeChange = (
+ newSelectedCallNode: IndexIntoCallNodeTable,
+ context: SelectionContext
+ ) => {
+ const { callNodeInfo, threadsKey, changeSelectedCallNode } = this.props;
+ changeSelectedCallNode(
+ threadsKey,
+ callNodeInfo.getCallNodePathFromIndex(newSelectedCallNode),
+ context
+ );
+ };
+
+ _onRightClickSelection = (newSelectedCallNode: IndexIntoCallNodeTable) => {
+ const { callNodeInfo, threadsKey, changeRightClickedCallNode } = this.props;
+ changeRightClickedCallNode(
+ threadsKey,
+ callNodeInfo.getCallNodePathFromIndex(newSelectedCallNode)
+ );
+ };
+
+ _onExpandedCallNodesChange = (
+ newExpandedCallNodeIndexes: Array
+ ) => {
+ const { callNodeInfo, threadsKey, changeExpandedCallNodes } = this.props;
+ changeExpandedCallNodes(
+ threadsKey,
+ newExpandedCallNodeIndexes.map((callNodeIndex) =>
+ callNodeInfo.getCallNodePathFromIndex(callNodeIndex)
+ )
+ );
+ };
+
+ _onKeyDown = (event: React.KeyboardEvent) => {
+ const {
+ selectedCallNodeIndex,
+ rightClickedCallNodeIndex,
+ callNodeInfo,
+ handleCallNodeTransformShortcut,
+ threadsKey,
+ } = this.props;
+ const nodeIndex =
+ rightClickedCallNodeIndex !== null
+ ? rightClickedCallNodeIndex
+ : selectedCallNodeIndex;
+ if (nodeIndex === null) {
+ return;
+ }
+ handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex);
+ };
+
+ _onEnterOrDoubleClick = (nodeId: IndexIntoCallNodeTable) => {
+ const { tree, updateBottomBoxContentsAndMaybeOpen } = this.props;
+ const bottomBoxInfo = tree.getBottomBoxInfoForCallNode(nodeId);
+ updateBottomBoxContentsAndMaybeOpen('function-list', bottomBoxInfo);
+ };
+
+ maybeProcureInterestingInitialSelection() {
+ // Expand the heaviest callstack up to a certain depth and select the frame
+ // at that depth.
+ const {
+ tree,
+ expandedCallNodeIndexes,
+ selectedCallNodeIndex,
+ callNodeInfo,
+ categories,
+ } = this.props;
+
+ if (selectedCallNodeIndex !== null || expandedCallNodeIndexes.length > 0) {
+ // Let's not change some existing state.
+ return;
+ }
+
+ const idleCategoryIndex = categories.findIndex(
+ (category) => category.name === 'Idle'
+ );
+
+ const newExpandedCallNodeIndexes = expandedCallNodeIndexes.slice();
+ const maxInterestingDepth = 17; // scientifically determined
+ let currentCallNodeIndex = tree.getRoots()[0];
+ if (currentCallNodeIndex === undefined) {
+ // This tree is empty.
+ return;
+ }
+ newExpandedCallNodeIndexes.push(currentCallNodeIndex);
+ for (let i = 0; i < maxInterestingDepth; i++) {
+ const children = tree.getChildren(currentCallNodeIndex);
+ if (children.length === 0) {
+ break;
+ }
+
+ // Let's find if there's a non idle children.
+ const firstNonIdleNode = children.find(
+ (nodeIndex) =>
+ callNodeInfo.categoryForNode(nodeIndex) !== idleCategoryIndex
+ );
+
+ // If there's a non idle children, use it; otherwise use the first
+ // children (that will be idle).
+ currentCallNodeIndex =
+ firstNonIdleNode !== undefined ? firstNonIdleNode : children[0];
+ newExpandedCallNodeIndexes.push(currentCallNodeIndex);
+ }
+ this._onExpandedCallNodesChange(newExpandedCallNodeIndexes);
+
+ const categoryIndex = callNodeInfo.categoryForNode(currentCallNodeIndex);
+ if (categoryIndex !== idleCategoryIndex) {
+ // If we selected the call node with a "idle" category, we'd have a
+ // completely dimmed activity graph because idle stacks are not drawn in
+ // this graph. Because this isn't probably what the average user wants we
+ // do it only when the category is something different.
+ this._onSelectedCallNodeChange(currentCallNodeIndex, { source: 'auto' });
+ }
+ }
+
+ _renderCallTree() {
+ const {
+ tree,
+ selectedCallNodeIndex,
+ rightClickedCallNodeIndex,
+ expandedCallNodeIndexes,
+ searchStringsRegExp,
+ disableOverscan,
+ callNodeMaxDepthPlusOne,
+ weightType,
+ tableViewOptions,
+ onTableViewOptionsChange,
+ contextMenuId,
+ } = this.props;
+ if (tree.getRoots().length === 0) {
+ return ;
+ }
+ return (
+
+ );
+ }
+
+ override render() {
+ if (this.props.view === 'call-tree') {
+ return this._renderCallTree();
+ }
+ return this.props.renderFlameGraph(this._flameGraphRef);
+ }
+}
+
+function makeMapDispatchToProps(wing: WingName) {
+ return {
+ changeSelectedCallNode: (
+ threadsKey: ThreadsKey,
+ path: CallNodePath,
+ context?: SelectionContext
+ ) => changeWingSelectedCallNode(wing, threadsKey, path, context),
+ changeRightClickedCallNode: (
+ threadsKey: ThreadsKey,
+ path: CallNodePath | null
+ ) => changeWingRightClickedCallNode(wing, threadsKey, path),
+ changeExpandedCallNodes: (
+ threadsKey: ThreadsKey,
+ paths: Array
+ ) => changeWingExpandedCallNodes(wing, threadsKey, paths),
+ addTransformToStack,
+ handleCallNodeTransformShortcut,
+ updateBottomBoxContentsAndMaybeOpen,
+ onTableViewOptionsChange: (options: TableViewOptions) =>
+ changeTableViewOptions('calltree', options),
+ };
+}
+
+const renderUpperWingFlameGraph = (
+ ref: React.RefObject
+) => } />;
+
+const renderLowerWingFlameGraph = (
+ ref: React.RefObject
+) => } />;
+
+function UpperWingComponent(
+ props: ConnectedProps<{}, StateProps, DispatchProps>
+) {
+ return (
+
+ );
+}
+
+function LowerWingComponent(
+ props: ConnectedProps<{}, StateProps, DispatchProps>
+) {
+ return (
+
+ );
+}
+
+export const UpperWing = explicitConnect<{}, StateProps, DispatchProps>({
+ mapStateToProps: (state: State): StateProps => ({
+ threadsKey: getSelectedThreadsKey(state),
+ scrollToSelectionGeneration: getScrollToSelectionGeneration(state),
+ tree: selectedThreadSelectors.getUpperWingCallTree(state),
+ callNodeInfo: selectedThreadSelectors.getUpperWingCallNodeInfo(state),
+ categories: getCategories(state),
+ selectedCallNodeIndex:
+ selectedThreadSelectors.getUpperWingSelectedCallNodeIndex(state),
+ rightClickedCallNodeIndex:
+ selectedThreadSelectors.getUpperWingRightClickedCallNodeIndex(state),
+ expandedCallNodeIndexes:
+ selectedThreadSelectors.getUpperWingExpandedCallNodeIndexes(state),
+ searchStringsRegExp: getSearchStringsAsRegExp(state),
+ disableOverscan: getPreviewSelectionIsBeingModified(state),
+ // Use the filtered call node max depth, rather than the preview filtered
+ // call node max depth so that the width of the TreeView component is stable
+ // across preview selections.
+ callNodeMaxDepthPlusOne:
+ selectedThreadSelectors.getFilteredCallNodeMaxDepthPlusOne(state),
+ weightType: selectedThreadSelectors.getWeightTypeForCallTree(state),
+ tableViewOptions: getCurrentTableViewOptions(state),
+ view: getUpperWingView(state),
+ }),
+ mapDispatchToProps: makeMapDispatchToProps('upper'),
+ component: UpperWingComponent,
+});
+
+export const LowerWing = explicitConnect<{}, StateProps, DispatchProps>({
+ mapStateToProps: (state: State): StateProps => ({
+ threadsKey: getSelectedThreadsKey(state),
+ scrollToSelectionGeneration: getScrollToSelectionGeneration(state),
+ tree: selectedThreadSelectors.getLowerWingCallTree(state),
+ callNodeInfo: selectedThreadSelectors.getLowerWingCallNodeInfo(state),
+ categories: getCategories(state),
+ selectedCallNodeIndex:
+ selectedThreadSelectors.getLowerWingSelectedCallNodeIndex(state),
+ rightClickedCallNodeIndex:
+ selectedThreadSelectors.getLowerWingRightClickedCallNodeIndex(state),
+ expandedCallNodeIndexes:
+ selectedThreadSelectors.getLowerWingExpandedCallNodeIndexes(state),
+ searchStringsRegExp: getSearchStringsAsRegExp(state),
+ disableOverscan: getPreviewSelectionIsBeingModified(state),
+ callNodeMaxDepthPlusOne:
+ selectedThreadSelectors.getFilteredCallNodeMaxDepthPlusOne(state),
+ weightType: selectedThreadSelectors.getWeightTypeForCallTree(state),
+ tableViewOptions: getCurrentTableViewOptions(state),
+ view: getLowerWingView(state),
+ }),
+ mapDispatchToProps: makeMapDispatchToProps('lower'),
+ component: LowerWingComponent,
+});
diff --git a/src/components/calltree/WingViewToggle.css b/src/components/calltree/WingViewToggle.css
new file mode 100644
index 0000000000..0743c3bfec
--- /dev/null
+++ b/src/components/calltree/WingViewToggle.css
@@ -0,0 +1,44 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.wingViewToggle {
+ display: flex;
+ align-items: center;
+ gap: 1px;
+}
+
+.wingViewToggleButton {
+ display: flex;
+ width: 18px;
+ height: 18px;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ border: none;
+ border-radius: 2px;
+ background: transparent;
+ color: var(--panel-foreground-color);
+ cursor: pointer;
+}
+
+.wingViewToggleButton:hover {
+ background: var(--clickable-ghost-hover-background-color);
+}
+
+.wingViewToggleButton:active {
+ background: var(--clickable-ghost-active-background-color);
+}
+
+.wingViewToggleButton.selected {
+ background: var(--clickable-ghost-active-background-color);
+}
+
+.wingViewToggleButton svg {
+ fill: currentcolor;
+ opacity: 0.55;
+}
+
+.wingViewToggleButton.selected svg {
+ opacity: 1;
+}
diff --git a/src/components/calltree/WingViewToggle.tsx b/src/components/calltree/WingViewToggle.tsx
new file mode 100644
index 0000000000..44ab072baa
--- /dev/null
+++ b/src/components/calltree/WingViewToggle.tsx
@@ -0,0 +1,123 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import * as React from 'react';
+
+import explicitConnect from 'firefox-profiler/utils/connect';
+import {
+ getUpperWingView,
+ getLowerWingView,
+ getSelfWingView,
+} from 'firefox-profiler/selectors/url-state';
+import { changeWingView } from 'firefox-profiler/actions/profile-view';
+import { assertExhaustiveCheck } from 'firefox-profiler/utils/types';
+
+import type { State, WingViewType } from 'firefox-profiler/types';
+import type { ConnectedProps } from 'firefox-profiler/utils/connect';
+
+import './WingViewToggle.css';
+
+type OwnProps = {
+ readonly wing: 'upper' | 'lower' | 'self';
+};
+
+type StateProps = {
+ readonly view: WingViewType;
+};
+
+type DispatchProps = {
+ readonly changeWingView: typeof changeWingView;
+};
+
+type Props = ConnectedProps;
+
+class WingViewToggleImpl extends React.PureComponent {
+ _onFlameGraphClick = (event: React.MouseEvent) => {
+ event.stopPropagation();
+ this.props.changeWingView(this.props.wing, 'flame-graph');
+ };
+ _onCallTreeClick = (event: React.MouseEvent) => {
+ event.stopPropagation();
+ this.props.changeWingView(this.props.wing, 'call-tree');
+ };
+
+ override render() {
+ const { view } = this.props;
+ return (
+
+
+
+
+ );
+ }
+}
+
+export const WingViewToggle = explicitConnect<
+ OwnProps,
+ StateProps,
+ DispatchProps
+>({
+ mapStateToProps: (state: State, ownProps: OwnProps) => {
+ let view;
+ switch (ownProps.wing) {
+ case 'upper':
+ view = getUpperWingView(state);
+ break;
+ case 'lower':
+ view = getLowerWingView(state);
+ break;
+ case 'self':
+ view = getSelfWingView(state);
+ break;
+ default:
+ throw assertExhaustiveCheck(ownProps.wing, 'Unhandled wing.');
+ }
+ return { view };
+ },
+ mapDispatchToProps: {
+ changeWingView,
+ },
+ component: WingViewToggleImpl,
+});
diff --git a/src/components/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/flame-graph/Canvas.tsx b/src/components/flame-graph/Canvas.tsx
index 655f51ede9..c13669f601 100644
--- a/src/components/flame-graph/Canvas.tsx
+++ b/src/components/flame-graph/Canvas.tsx
@@ -392,10 +392,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/components/shared/ContextMenu.css b/src/components/shared/ContextMenu.css
index 756fcd0b12..25931c5f39 100644
--- a/src/components/shared/ContextMenu.css
+++ b/src/components/shared/ContextMenu.css
@@ -141,8 +141,8 @@
}
.react-contextmenu-item--selected.checked:not(
- .react-contextmenu-item--disabled
-)::before {
+ .react-contextmenu-item--disabled
+ )::before {
/* Invert the colors of the checkmark when selected */
border-color: var(--internal-active-foreground-color);
}
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}
+
+ );
+}
diff --git a/src/components/shared/FilterNavigatorBar.css b/src/components/shared/FilterNavigatorBar.css
index 5a8cb08484..d961dc98ac 100644
--- a/src/components/shared/FilterNavigatorBar.css
+++ b/src/components/shared/FilterNavigatorBar.css
@@ -127,16 +127,16 @@
.filterNavigatorBarItem:not(.filterNavigatorBarLeafItem):hover,
.filterNavigatorBarItem:has(
- .profileFilterNavigator--tab-selector.button
-):hover {
+ .profileFilterNavigator--tab-selector.button
+ ):hover {
background-color: var(--internal-hover-background-color);
color: var(--internal-hover-color);
}
.filterNavigatorBarItem:not(
- .filterNavigatorBarRootItem,
- .filterNavigatorBarLeafItem
-):hover::before,
+ .filterNavigatorBarRootItem,
+ .filterNavigatorBarLeafItem
+ ):hover::before,
.filterNavigatorBarItem:not(.filterNavigatorBarLeafItem):hover::after {
border-color: var(--internal-hover-background-color);
}
@@ -146,9 +146,9 @@
}
.filterNavigatorBarItem:not(
- .filterNavigatorBarRootItem,
- .filterNavigatorBarLeafItem
-):active:hover::before,
+ .filterNavigatorBarRootItem,
+ .filterNavigatorBarLeafItem
+ ):active:hover::before,
.filterNavigatorBarItem:not(.filterNavigatorBarLeafItem):active:hover::after {
border-color: var(--internal-active-background-color);
}
diff --git a/src/components/shared/TabSelectorMenu.css b/src/components/shared/TabSelectorMenu.css
index 6fd4907d21..831cf44fd8 100644
--- a/src/components/shared/TabSelectorMenu.css
+++ b/src/components/shared/TabSelectorMenu.css
@@ -15,8 +15,8 @@
}
.react-contextmenu-item.tabSelectorMenuItem.checked:not(
- .react-contextmenu-item--disabled
-)::before {
+ .react-contextmenu-item--disabled
+ )::before {
/* Move the checkmark to inline-start instead of right, as it's logically better. */
inset-inline: 8px 0;
}
diff --git a/src/components/shared/WingContextMenu.tsx b/src/components/shared/WingContextMenu.tsx
new file mode 100644
index 0000000000..5b8075b07c
--- /dev/null
+++ b/src/components/shared/WingContextMenu.tsx
@@ -0,0 +1,540 @@
+/* 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 { getRightClickedCallNodeInfo } from 'firefox-profiler/selectors/right-clicked-call-node';
+import { oneLine } from 'common-tags';
+
+import {
+ convertToTransformType,
+ assertExhaustiveCheck,
+} from 'firefox-profiler/utils/types';
+
+import type {
+ TransformType,
+ ImplementationFilter,
+ IndexIntoFuncTable,
+ Thread,
+ ThreadsKey,
+ CallNodeTable,
+ State,
+} from 'firefox-profiler/types';
+
+import type { ConnectedProps } from 'firefox-profiler/utils/connect';
+
+import './CallNodeContextMenu.css';
+
+// Context provided to data-table menu items so they can compute visibility,
+// label, and l10n vars from the current right-clicked function.
+type MenuItemContext = {
+ readonly funcIndex: IndexIntoFuncTable;
+ readonly callNodeTables: ReadonlyArray;
+ readonly nameForResource: string | null;
+};
+
+// A descriptor for a transform menu item. The data table below drives the
+// rendering of the menu, instead of repeating identical JSX three times.
+type TransformMenuItem = {
+ readonly transform: TransformType;
+ readonly shortcut: string;
+ readonly icon: string;
+ readonly l10nId: string;
+ readonly content: (ctx: MenuItemContext) => string;
+ readonly visible?: (ctx: MenuItemContext) => boolean;
+ readonly l10nVars?: (ctx: MenuItemContext) => Record;
+ readonly l10nElems?: Record;
+};
+
+const MENU_ITEMS: ReadonlyArray = [
+ {
+ transform: 'merge-function',
+ shortcut: 'm',
+ icon: 'Merge',
+ l10nId: 'CallNodeContextMenu--transform-merge-function',
+ content: () => 'Merge function',
+ },
+ {
+ transform: 'focus-function',
+ shortcut: 'f',
+ icon: 'Focus',
+ l10nId: 'CallNodeContextMenu--transform-focus-function',
+ content: () => 'Focus on function',
+ },
+ {
+ transform: 'focus-self',
+ shortcut: 'S',
+ icon: 'FocusSelf',
+ l10nId: 'CallNodeContextMenu--transform-focus-self',
+ content: () => 'Focus on self only',
+ },
+ {
+ transform: 'collapse-function-subtree',
+ shortcut: 'c',
+ icon: 'Collapse',
+ l10nId: 'CallNodeContextMenu--transform-collapse-function-subtree',
+ content: () => 'Collapse function',
+ },
+ {
+ transform: 'collapse-resource',
+ shortcut: 'C',
+ icon: 'Collapse',
+ l10nId: 'CallNodeContextMenu--transform-collapse-resource',
+ content: ({ nameForResource }) => `Collapse ${nameForResource}`,
+ visible: ({ nameForResource }) => nameForResource !== null,
+ l10nVars: ({ nameForResource }) => ({
+ nameForResource: nameForResource ?? '',
+ }),
+ l10nElems: { strong: },
+ },
+ {
+ transform: 'collapse-recursion',
+ shortcut: 'r',
+ icon: 'Collapse',
+ l10nId: 'CallNodeContextMenu--transform-collapse-recursion',
+ content: () => 'Collapse recursion',
+ visible: ({ funcIndex, callNodeTables }) =>
+ callNodeTables.some((t) => funcHasRecursiveCall(t, funcIndex)),
+ },
+ {
+ transform: 'collapse-direct-recursion',
+ shortcut: 'R',
+ icon: 'Collapse',
+ l10nId: 'CallNodeContextMenu--transform-collapse-direct-recursion-only',
+ content: () => 'Collapse direct recursion only',
+ visible: ({ funcIndex, callNodeTables }) =>
+ callNodeTables.some((t) => funcHasDirectRecursiveCall(t, funcIndex)),
+ },
+ {
+ transform: 'drop-function',
+ shortcut: 'd',
+ icon: 'Drop',
+ l10nId: 'CallNodeContextMenu--transform-drop-function',
+ content: () => 'Drop samples with this function',
+ },
+];
+
+type StateProps = {
+ readonly thread: Thread | null;
+ readonly threadsKey: ThreadsKey | null;
+ readonly 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;
+};
+
+type DispatchProps = {
+ readonly addTransformToStack: typeof addTransformToStack;
+ readonly addCollapseResourceTransformToStack: typeof addCollapseResourceTransformToStack;
+ readonly setContextMenuVisibility: typeof setContextMenuVisibility;
+};
+
+// 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 WingContextMenuImpl 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;
+ } {
+ const { thread, threadsKey, funcIndex } = this.props;
+ if (thread !== null && threadsKey !== null && funcIndex !== null) {
+ return { thread, threadsKey, funcIndex };
+ }
+ return null;
+ }
+
+ _getFunctionName(): string {
+ const info = this._getRightClickedInfo();
+ if (info === null) {
+ throw new Error(
+ "The context menu assumes there is a right-clicked function and there wasn't one."
+ );
+ }
+ const {
+ thread: { stringTable, funcTable },
+ funcIndex,
+ } = info;
+ const isJS = funcTable.isJS[funcIndex];
+ const functionCall = stringTable.getString(funcTable.name[funcIndex]);
+ return isJS ? functionCall : getFunctionName(functionCall);
+ }
+
+ lookupFunctionOnSearchfox(): void {
+ window.open(
+ `https://searchfox.org/mozilla-central/search?q=${encodeURIComponent(
+ this._getFunctionName()
+ )}`,
+ '_blank'
+ );
+ }
+
+ copyFunctionName(): void {
+ copy(this._getFunctionName());
+ }
+
+ getNameForSelectedResource(): string | null {
+ const info = this._getRightClickedInfo();
+ if (info === null) {
+ throw new Error(
+ "The context menu assumes there is a right-clicked function and there wasn't one."
+ );
+ }
+ const {
+ thread: { funcTable, stringTable, resourceTable, sources },
+ funcIndex,
+ } = info;
+ const isJS = funcTable.isJS[funcIndex];
+ if (isJS) {
+ const sourceIndex = funcTable.source[funcIndex];
+ if (sourceIndex === null) {
+ return null;
+ }
+ return stringTable.getString(sources.filename[sourceIndex]);
+ }
+ const resourceIndex = funcTable.resource[funcIndex];
+ if (resourceIndex === -1) {
+ return null;
+ }
+ return stringTable.getString(resourceTable.name[resourceIndex]);
+ }
+
+ addTransformToStack(type: TransformType): void {
+ const {
+ addTransformToStack,
+ addCollapseResourceTransformToStack,
+ implementation,
+ } = this.props;
+ const info = this._getRightClickedInfo();
+ if (info === null) {
+ throw new Error(
+ "The context menu assumes there is a right-clicked function and there wasn't one."
+ );
+ }
+ const { threadsKey, thread, funcIndex } = info;
+
+ switch (type) {
+ case 'focus-function':
+ addTransformToStack(threadsKey, {
+ type: 'focus-function',
+ funcIndex,
+ });
+ break;
+ case 'focus-self':
+ addTransformToStack(threadsKey, {
+ type: 'focus-self',
+ funcIndex,
+ implementation,
+ });
+ break;
+ case 'merge-function':
+ addTransformToStack(threadsKey, {
+ type: 'merge-function',
+ funcIndex,
+ });
+ break;
+ case 'drop-function':
+ addTransformToStack(threadsKey, {
+ type: 'drop-function',
+ funcIndex,
+ });
+ break;
+ case 'collapse-resource': {
+ const resourceIndex = thread.funcTable.resource[funcIndex];
+ addCollapseResourceTransformToStack(
+ threadsKey,
+ resourceIndex,
+ implementation
+ );
+ break;
+ }
+ case 'collapse-direct-recursion':
+ addTransformToStack(threadsKey, {
+ type: 'collapse-direct-recursion',
+ funcIndex,
+ implementation,
+ });
+ break;
+ case 'collapse-recursion':
+ addTransformToStack(threadsKey, {
+ type: 'collapse-recursion',
+ funcIndex,
+ });
+ break;
+ case 'collapse-function-subtree':
+ addTransformToStack(threadsKey, {
+ type: 'collapse-function-subtree',
+ funcIndex,
+ });
+ break;
+ case 'focus-subtree':
+ case 'merge-call-node':
+ case 'focus-category':
+ case 'filter-samples':
+ throw new Error(
+ `The transform "${type}" is not supported in the wing 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 (
+
+ );
+ }
+
+ renderContextMenuContents() {
+ const { displaySearchfox, callNodeTables } = 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,
+ callNodeTables,
+ nameForResource: this.getNameForSelectedResource(),
+ };
+
+ const renderedItems = MENU_ITEMS.filter(
+ (item) => !item.visible || item.visible(ctx)
+ ).map((item) => this.renderTransformMenuItem(item, ctx));
+
+ return (
+ <>
+ {renderedItems}
+
+
+
+ {displaySearchfox ? (
+
+ ) : null}
+
+ >
+ );
+ }
+
+ override render() {
+ if (this._getRightClickedInfo() === null) {
+ return null;
+ }
+
+ return (
+
+ {this.renderContextMenuContents()}
+
+ );
+ }
+}
+
+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,
+ DispatchProps
+>({
+ mapStateToProps: (state: State) => {
+ const rightClickedFunction =
+ getProfileViewOptions(state).rightClickedFunction;
+
+ 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: selectors.getFilteredThread(state),
+ threadsKey: rightClickedFunction.threadsKey,
+ funcIndex: rightClickedFunction.functionIndex,
+ callNodeTables,
+ implementation: getImplementationFilter(state),
+ displaySearchfox: getShouldDisplaySearchfox(state),
+ };
+ },
+ mapDispatchToProps: dispatchToProps,
+ component: (props) => (
+
+ ),
+});
+
+// Connected variant used by the lower wing: the right-clicked function comes
+// from a right-clicked call node in the LOWER_WING area. 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),
+ };
+ },
+ mapDispatchToProps: dispatchToProps,
+ component: (props) => (
+
+ ),
+});
diff --git a/src/components/sidebar/index.tsx b/src/components/sidebar/index.tsx
index a8115372a3..e4a6901069 100644
--- a/src/components/sidebar/index.tsx
+++ b/src/components/sidebar/index.tsx
@@ -15,6 +15,7 @@ export function selectSidebar(
): React.ComponentType<{}> | null {
return {
calltree: CallTreeSidebar,
+ 'function-list': CallTreeSidebar,
'flame-graph': CallTreeSidebar,
'stack-chart': null,
'marker-chart': null,
diff --git a/src/components/sidebar/sidebar.css b/src/components/sidebar/sidebar.css
index 8306a920db..76a44470e2 100644
--- a/src/components/sidebar/sidebar.css
+++ b/src/components/sidebar/sidebar.css
@@ -26,7 +26,8 @@
align-items: center;
gap: 2px 5px;
grid-template-columns:
- /* Label */ 1fr
+ /* Label */
+ 1fr
/* Value */ min-content
/* Percentage */ min-content;
}
diff --git a/src/components/timeline/TrackThread.tsx b/src/components/timeline/TrackThread.tsx
index b0ee5338a6..7b713077b9 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,
@@ -37,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';
@@ -99,6 +101,7 @@ type DispatchProps = {
readonly changeSelectedCallNode: typeof changeSelectedCallNode;
readonly focusCallTree: typeof focusCallTree;
readonly selectSelfCallNode: typeof selectSelfCallNode;
+ readonly selectSelfFunction: typeof selectSelfFunction;
readonly reportTrackThreadHeight: typeof reportTrackThreadHeight;
};
@@ -122,6 +125,7 @@ class TimelineTrackThreadImpl extends PureComponent {
const {
threadsKey,
selectSelfCallNode,
+ selectSelfFunction,
focusCallTree,
selectedThreadIndexes,
callTreeVisible,
@@ -129,6 +133,7 @@ class TimelineTrackThreadImpl extends PureComponent {
// Sample clicking only works for one thread. See issue #2709
if (selectedThreadIndexes.size === 1) {
+ selectSelfFunction(threadsKey, sampleIndex);
selectSelfCallNode(threadsKey, sampleIndex);
if (sampleIndex !== null && callTreeVisible) {
@@ -349,7 +354,9 @@ export const TimelineTrackThread = explicitConnect<
hasFileIoMarkers:
selectors.getTimelineFileIoMarkerIndexes(state).length !== 0,
sampleSelectedStates:
- selectors.getSampleSelectedStatesInFilteredThread(state),
+ getSelectedTab(state) === 'function-list'
+ ? selectors.getSampleSelectedStatesForFunctionListTab(state)
+ : selectors.getSampleSelectedStatesInFilteredThread(state),
treeOrderSampleComparator:
selectors.getTreeOrderComparatorInFilteredThread(state),
selectedThreadIndexes,
@@ -365,6 +372,7 @@ export const TimelineTrackThread = explicitConnect<
changeSelectedCallNode,
focusCallTree,
selectSelfCallNode,
+ selectSelfFunction,
reportTrackThreadHeight,
},
component: withSize(TimelineTrackThreadImpl),
diff --git a/src/profile-logic/bottom-box.ts b/src/profile-logic/bottom-box.ts
index cbd935ed8f..45ab325fef 100644
--- a/src/profile-logic/bottom-box.ts
+++ b/src/profile-logic/bottom-box.ts
@@ -8,12 +8,14 @@ import type {
Thread,
IndexIntoStackTable,
IndexIntoCallNodeTable,
+ IndexIntoFuncTable,
BottomBoxInfo,
SamplesLikeTable,
} from 'firefox-profiler/types';
import type { CallNodeInfo } from './call-node-info';
import {
getCallNodeFramePerStack,
+ getFunctionFramePerStack,
getNativeSymbolInfo,
getNativeSymbolsForCallNode,
getOriginalPositionForFrame,
@@ -202,3 +204,109 @@ export function getBottomBoxInfoForStackFrame(
instructionAddress !== -1 ? instructionAddress : null,
};
}
+
+/**
+ * Calculate the BottomBoxInfo for a function, i.e. information about which
+ * things should be shown in the profiler UI's "bottom box" when a function is
+ * double-clicked in the function list.
+ *
+ * Unlike getBottomBoxInfoForCallNode, this considers all stacks where the
+ * function appears anywhere (not just as the self function), using the
+ * innermost (leaf-most) frame when the function appears multiple times in one
+ * stack due to recursion.
+ */
+export function getBottomBoxInfoForFunction(
+ funcIndex: IndexIntoFuncTable,
+ thread: Thread,
+ samples: SamplesLikeTable
+): BottomBoxInfo {
+ const {
+ stackTable,
+ frameTable,
+ funcTable,
+ stringTable,
+ resourceTable,
+ nativeSymbols,
+ } = thread;
+
+ const { source: sourceIndex, line: funcLine } = getOriginalPositionForFrame(
+ null,
+ funcIndex,
+ frameTable,
+ funcTable,
+ thread.sourceLocationTable
+ );
+ const resource = funcTable.resource[funcIndex];
+ const libIndex =
+ resource !== -1 && resourceTable.type[resource] === ResourceType.Library
+ ? resourceTable.lib[resource]
+ : null;
+
+ const funcFramePerStack = getFunctionFramePerStack(
+ funcIndex,
+ stackTable,
+ frameTable
+ );
+
+ const nativeSymbolsForFunc = getNativeSymbolsForCallNode(
+ funcFramePerStack,
+ frameTable
+ );
+ let initialNativeSymbol = null;
+ const nativeSymbolTimings = getTotalNativeSymbolTimingsForCallNode(
+ samples,
+ funcFramePerStack,
+ frameTable
+ );
+ const hottestNativeSymbol = mapGetKeyWithMaxValue(nativeSymbolTimings);
+ if (hottestNativeSymbol !== undefined) {
+ nativeSymbolsForFunc.add(hottestNativeSymbol);
+ initialNativeSymbol = hottestNativeSymbol;
+ }
+ const nativeSymbolsForFuncArr = [...nativeSymbolsForFunc];
+ nativeSymbolsForFuncArr.sort((a, b) => a - b);
+ if (nativeSymbolsForFuncArr.length !== 0 && initialNativeSymbol === null) {
+ initialNativeSymbol = nativeSymbolsForFuncArr[0];
+ }
+
+ const nativeSymbolInfosForFunc = nativeSymbolsForFuncArr.map(
+ (nativeSymbolIndex) =>
+ getNativeSymbolInfo(
+ nativeSymbolIndex,
+ nativeSymbols,
+ frameTable,
+ stringTable
+ )
+ );
+
+ const lineTimings = getTotalLineTimingsForCallNode(
+ samples,
+ funcFramePerStack,
+ frameTable,
+ funcTable,
+ funcLine,
+ thread.sourceLocationTable
+ );
+ const hottestLine = mapGetKeyWithMaxValue(lineTimings);
+ const addressTimings = getTotalAddressTimingsForCallNode(
+ samples,
+ funcFramePerStack,
+ frameTable,
+ initialNativeSymbol
+ );
+ const hottestInstructionAddress = mapGetKeyWithMaxValue(addressTimings);
+
+ return {
+ libIndex,
+ sourceIndex,
+ nativeSymbols: nativeSymbolInfosForFunc,
+ initialNativeSymbol:
+ initialNativeSymbol !== null
+ ? nativeSymbolsForFuncArr.indexOf(initialNativeSymbol)
+ : null,
+ scrollToLineNumber: hottestLine,
+ scrollToInstructionAddress: hottestInstructionAddress,
+ highlightedLineNumber: null,
+ highlightedInstructionAddress: null,
+ };
+}
diff --git a/src/profile-logic/call-node-info.ts b/src/profile-logic/call-node-info.ts
index 7cf3a8daf3..ef0a056292 100644
--- a/src/profile-logic/call-node-info.ts
+++ b/src/profile-logic/call-node-info.ts
@@ -581,8 +581,60 @@ type ChildrenInfo = {
export type SuffixOrderIndex = number;
/**
- * The CallNodeInfo implementation for the inverted tree, with additional
- * functionality for the inverted call tree.
+ * The interface for the inverted-mode call node info, which extends
+ * CallNodeInfo with the additional functionality that consumers of inverted
+ * call trees need (suffix order, root identification, function count).
+ *
+ * Two implementations exist:
+ * - LazyInvertedCallNodeInfo, which has one root per func and materializes
+ * non-root nodes on demand. Used for the regular "invert call stack" mode
+ * and the function list tab, where any func can be a displayed root.
+ * - LowerWingCallNodeInfo, which is built eagerly from the entry points of
+ * a single selected function. Used for the lower wing.
+ */
+export interface CallNodeInfoInverted extends CallNodeInfo {
+ // Get a mapping SuffixOrderIndex -> IndexIntoCallNodeTable.
+ // The contents may be mutated by the implementation as inverted nodes get
+ // materialized on demand. Callers should not hold on to this array across
+ // calls that may create new inverted nodes (getChildren, getCallNodeIndexFromPath).
+ getSuffixOrderedCallNodes(): Uint32Array;
+
+ // The inverse of getSuffixOrderedCallNodes: IndexIntoCallNodeTable -> SuffixOrderIndex.
+ // Same staleness caveat as above.
+ getSuffixOrderIndexes(): Uint32Array;
+
+ // The [start, exclusiveEnd] suffix order index range for this inverted node.
+ getSuffixOrderIndexRangeForCallNode(
+ nodeHandle: IndexIntoCallNodeTable
+ ): [SuffixOrderIndex, SuffixOrderIndex];
+
+ // The number of functions in the func table this CNI was built from.
+ // (Sizes per-func scratch buffers in callers; not directly tied to the
+ // number of inverted-tree roots — see getRootCount.)
+ getFuncCount(): number;
+
+ // The number of roots in the inverted tree. Root call node handles are
+ // 0..getRootCount()-1. For the full inverted tree this equals funcCount
+ // (one root per func); for a lower-wing CNI it is 1.
+ getRootCount(): number;
+
+ // Returns the inverted root call node handle for `funcIndex`, or -1 if no
+ // root in this CNI corresponds to that function (e.g. a lower-wing CNI only
+ // has a root for its selected func).
+ getRootNodeForFunc(
+ funcIndex: IndexIntoFuncTable
+ ): IndexIntoCallNodeTable | -1;
+
+ // True if the given node is a root of the inverted tree.
+ isRoot(nodeHandle: IndexIntoCallNodeTable): boolean;
+
+ // Materialize and return the children of this inverted node. Sorted by func.
+ getChildren(nodeIndex: IndexIntoCallNodeTable): IndexIntoCallNodeTable[];
+}
+
+/**
+ * The lazy CallNodeInfoInverted implementation. It has one root per func and
+ * materializes non-root nodes on demand.
*
* # The Suffix Order
*
@@ -902,7 +954,7 @@ export type SuffixOrderIndex = number;
* deep nodes. They're not needed anymore. So _takeDeepNodesForInvertedNode
* nulls out the stored deepNodes for an inverted node when it's called.
*/
-export class CallNodeInfoInverted implements CallNodeInfo {
+export class LazyInvertedCallNodeInfo implements CallNodeInfoInverted {
// The non-inverted call node table.
_callNodeTable: CallNodeTable;
@@ -1020,13 +1072,23 @@ export class CallNodeInfoInverted implements CallNodeInfo {
return this._suffixOrderIndexes;
}
- // Get the number of functions. There is one root per function.
- // So this is also the number of roots at the same time.
- // The inverted call node index for a root is the same as the function index.
+ // Get the number of functions. There is one root per function in this
+ // (full inverted) CNI, so it also equals getRootCount().
+ // For roots, the inverted call node handle equals the function index.
getFuncCount(): number {
return this._rootCount;
}
+ getRootCount(): number {
+ return this._rootCount;
+ }
+
+ // For the full inverted tree, each func has its own root and the root
+ // handle equals the func index.
+ getRootNodeForFunc(funcIndex: IndexIntoFuncTable): InvertedCallNodeHandle {
+ return funcIndex;
+ }
+
// Returns whether the given node is a root node.
isRoot(nodeHandle: InvertedCallNodeHandle): boolean {
return nodeHandle < this._rootCount;
@@ -1716,3 +1778,679 @@ export class CallNodeInfoInverted implements CallNodeInfo {
];
}
}
+
+// The lower wing's inverted call node table. Index 0 is the selected-func
+// root; indices 1..length-1 are non-root nodes. The public InvertedCallNodeHandle
+// IS the table index (no translation): handle 0 is the root, handles
+// 1..length-1 are non-roots.
+//
+// Layout note: rows are appended in BFS-by-inverted-depth order, with each
+// parent's children emitted contiguously, sorted by func index. Index 0 is the
+// root (depth 0); indices 1..length-1 have monotonically non-decreasing depth.
+//
+// The columns are plain `number[]` (not typed arrays). The CNI grows them by
+// `push()` as the BFS extends; `getLowerWingTableUpToDepth(k)` returns a
+// snapshot object that aliases the same backing arrays (no copy). Consumers
+// must iterate `[0, length)` only — entries beyond `length` may be appended
+// later as the BFS expands further, and `children[i]` for `i >= _nextK` is the
+// shared empty placeholder.
+export type LowerWingTable = {
+ prefix: number[]; // -> InvertedCallNodeHandle
+ func: number[]; // -> IndexIntoFuncTable
+ category: number[];
+ subcategory: number[];
+ innerWindowID: number[];
+ sourceFramesInlinedIntoSymbol: number[];
+ depth: number[];
+ suffixOrderIndexRangeStart: number[];
+ suffixOrderIndexRangeEnd: number[];
+ children: Array; // Sorted by func
+ length: number;
+};
+
+/**
+ * Lazy, depth-incremental CallNodeInfoInverted for the lower wing.
+ *
+ * Mirrors LazyInvertedCallNodeInfo's role but only for the single selected
+ * function: the lower wing's "root" is the inverted node for `selectedFuncIndex`,
+ * and every other logical root is empty. Construction is cheap: only Pass 1
+ * (entry-point collection) runs upfront. The rest of the BFS is resumable and
+ * runs on demand via `getLowerWingTableUpToDepth(k)` or any accessor that needs
+ * to read a node's children.
+ *
+ * Why lazy:
+ * - For deeply-nested selections (e.g. an allocator called from everywhere),
+ * the full inverted subtree can have thousands of rows. The flame graph
+ * only renders the rows currently in the viewport, so building deeper than
+ * necessary is wasted work.
+ *
+ * Handle scheme:
+ * - There is exactly one root, with handle === 0. Its func is `selectedFuncIndex`
+ * (or 0 when no selection — the row is then a placeholder with no children).
+ * - Non-root handles are 1..length-1 and equal their table index. Handles
+ * other than 0 only exist after the BFS has expanded to materialize them.
+ * - `getRootNodeForFunc(f)` returns 0 iff f === selectedFuncIndex, else -1.
+ * - `getSuffixOrderedCallNodes()` and `getSuffixOrderIndexes()` are kept in
+ * sync as the BFS refines partitions.
+ */
+export class LowerWingCallNodeInfo implements CallNodeInfoInverted {
+ _callNodeTable: CallNodeTable;
+ _stackIndexToNonInvertedCallNodeIndex: Int32Array;
+ _defaultCategory: IndexIntoCategoryList;
+ // Number of functions in the originating func table. Used to size per-func
+ // scratch buffers in `_extendToDepth`; unrelated to the number of roots
+ // (which is always 1 — see getRootCount).
+ _funcCount: number;
+ _selectedFuncIndex: IndexIntoFuncTable | null;
+
+ // Suffix-ordered entry points: maps suffix order index -> non-inverted call
+ // node index. Length === number of entry points. Permuted in-place as the
+ // BFS refines partitions, so consumers that hold the reference across an
+ // extension see updated positions. The set of entries in any node's
+ // [soStart, soEnd) range stays invariant.
+ _selfNodes: Uint32Array;
+
+ // Inverse mapping: non-inverted call node -> suffix order index. Length ===
+ // non-inverted call node count. Non-entry-point nodes get 0xFFFFFFFF. Kept
+ // in sync with _selfNodes during every partition step.
+ _suffixOrderIndexes: Uint32Array;
+
+ // Per-suffix-position "deep" cursor advanced by the BFS. `_deepNodes[i]` is
+ // the non-inverted ancestor of `_selfNodes[i]` at the inverted depth that
+ // has currently been expanded for that suffix-order slot — i.e. how far up
+ // the original (non-inverted) call chain we've walked for that entry. The
+ // BFS steps it via `prefixCol[deepNodes[i]]` once per row. -1 marks an
+ // entry whose chain bottomed out (no more callers to expand).
+ _deepNodes: Int32Array;
+
+ // Column-store of the growing inverted call node table. Index 0 is the
+ // selected-func root; indices 1..length-1 are non-roots. `_tChildren`
+ // always has the same length as the other columns — entries for not-yet-
+ // processed nodes hold the shared empty placeholder, so the snapshot is
+ // safe to index at any valid table index.
+ _tPrefix: number[];
+ _tFunc: number[];
+ _tCategory: number[];
+ _tSubcategory: number[];
+ _tInnerWindowID: number[];
+ _tInlinedInto: number[];
+ _tDepth: number[];
+ _tSoStart: number[];
+ _tSoEnd: number[];
+ _tChildren: Array;
+
+ // BFS cursor. Nodes with table index < _nextK have been "processed" (their
+ // children at depth+1 have been emitted and `_tChildren[idx]` is final).
+ // Nodes with index >= _nextK exist in the table but their children entries
+ // still hold the placeholder.
+ _nextK: number;
+
+ // Pre-allocated scratch buffers reused across every BFS iteration inside
+ // `_extendToDepth`. None of these carry state across iterations — each
+ // iteration writes the slice it needs before reading it back.
+ _scratchSelf: Uint32Array;
+ _scratchDeep: Int32Array;
+ _countPerFunc: Int32Array;
+ _funcToChildIdx: Int32Array;
+ _startPerChild: Uint32Array;
+ _writePerChild: Uint32Array;
+
+ // Cached snapshot wrapper. Reused as long as the table length hasn't grown
+ // since it was built.
+ _cachedTable: LowerWingTable | null;
+ _cachedTableLength: number;
+
+ constructor(
+ callNodeTable: CallNodeTable,
+ stackIndexToNonInvertedCallNodeIndex: Int32Array,
+ defaultCategory: IndexIntoCategoryList,
+ funcCount: number,
+ selectedFuncIndex: IndexIntoFuncTable | null
+ ) {
+ this._callNodeTable = callNodeTable;
+ this._stackIndexToNonInvertedCallNodeIndex =
+ stackIndexToNonInvertedCallNodeIndex;
+ this._defaultCategory = defaultCategory;
+ this._funcCount = funcCount;
+ this._selectedFuncIndex = selectedFuncIndex;
+
+ // Pass 1: collect root-most non-inverted nodes whose func is the selected
+ // one. The subtree skip-ahead prevents nested re-entries from being
+ // counted twice (an `f` called by `f` only contributes once).
+ const callNodeCount = callNodeTable.length;
+ const funcCol = callNodeTable.func;
+ const entries: number[] = [];
+ if (selectedFuncIndex !== null) {
+ const subtreeEndCol = callNodeTable.subtreeRangeEnd;
+ for (let i = 0; i < callNodeCount; i++) {
+ if (funcCol[i] !== selectedFuncIndex) {
+ continue;
+ }
+ entries.push(i);
+ i = subtreeEndCol[i] - 1;
+ }
+ }
+
+ const N = entries.length;
+ this._selfNodes = new Uint32Array(N);
+ this._deepNodes = new Int32Array(N);
+ this._suffixOrderIndexes = new Uint32Array(callNodeCount);
+ this._suffixOrderIndexes.fill(0xffffffff);
+
+ // Resolve root attributes. With entries, scan them for category/subcategory/
+ // inlined conflicts (same rule as a non-root). Without entries, fall back
+ // to the same defaults the old `_emptyLowerWingTree` used so accessor
+ // results don't change for empty trees.
+ let rootCategory: number;
+ let rootSubcategory: number;
+ let rootInnerWindowID: number;
+ let rootInlined: number;
+ let rootFunc: number;
+ if (N === 0) {
+ rootCategory = defaultCategory;
+ rootSubcategory = 0;
+ rootInnerWindowID = 0;
+ rootInlined = -2;
+ rootFunc = selectedFuncIndex ?? 0;
+ } else {
+ const categoryCol = callNodeTable.category;
+ const subcategoryCol = callNodeTable.subcategory;
+ const innerWindowIDCol = callNodeTable.innerWindowID;
+ const inlinedCol = callNodeTable.sourceFramesInlinedIntoSymbol;
+ const e0 = entries[0];
+ rootCategory = categoryCol[e0];
+ rootSubcategory = subcategoryCol[e0];
+ rootInnerWindowID = innerWindowIDCol[e0];
+ rootInlined = inlinedCol[e0];
+ this._selfNodes[0] = e0;
+ this._deepNodes[0] = e0;
+ this._suffixOrderIndexes[e0] = 0;
+ for (let k = 1; k < N; k++) {
+ const e = entries[k];
+ this._selfNodes[k] = e;
+ this._deepNodes[k] = e;
+ this._suffixOrderIndexes[e] = k;
+ if (categoryCol[e] !== rootCategory) {
+ rootCategory = defaultCategory;
+ rootSubcategory = 0;
+ } else if (subcategoryCol[e] !== rootSubcategory) {
+ rootSubcategory = 0;
+ }
+ if (inlinedCol[e] !== rootInlined) {
+ rootInlined = -1;
+ }
+ }
+ rootFunc = selectedFuncIndex as number;
+ }
+
+ // Seed the table with the root row. `_tChildren[0]` starts as the empty
+ // placeholder and gets overwritten by `_processNode(0)` when the root is
+ // expanded.
+ this._tPrefix = [-1];
+ this._tFunc = [rootFunc];
+ this._tCategory = [rootCategory];
+ this._tSubcategory = [rootSubcategory];
+ this._tInnerWindowID = [rootInnerWindowID];
+ this._tInlinedInto = [rootInlined];
+ this._tDepth = [0];
+ this._tSoStart = [0];
+ this._tSoEnd = [N];
+ this._tChildren = [EMPTY_LOWER_WING_CHILDREN];
+
+ this._scratchSelf = new Uint32Array(N);
+ this._scratchDeep = new Int32Array(N);
+ this._countPerFunc = new Int32Array(funcCount);
+ this._funcToChildIdx = new Int32Array(funcCount);
+ this._startPerChild = new Uint32Array(funcCount);
+ this._writePerChild = new Uint32Array(funcCount);
+
+ this._nextK = 0;
+ this._cachedTable = null;
+ this._cachedTableLength = -1;
+ }
+
+ isInverted(): boolean {
+ return true;
+ }
+
+ asInverted(): CallNodeInfoInverted | null {
+ return this;
+ }
+
+ getCallNodeTable(): CallNodeTable {
+ return this._callNodeTable;
+ }
+
+ getStackIndexToNonInvertedCallNodeIndex(): Int32Array {
+ return this._stackIndexToNonInvertedCallNodeIndex;
+ }
+
+ getSuffixOrderedCallNodes(): Uint32Array {
+ return this._selfNodes;
+ }
+
+ getSuffixOrderIndexes(): Uint32Array {
+ return this._suffixOrderIndexes;
+ }
+
+ getFuncCount(): number {
+ return this._funcCount;
+ }
+
+ getRootCount(): number {
+ return 1;
+ }
+
+ // Only the selected func has a root in this CNI. Other funcs return -1.
+ getRootNodeForFunc(
+ funcIndex: IndexIntoFuncTable
+ ): InvertedCallNodeHandle | -1 {
+ return funcIndex === this._selectedFuncIndex ? 0 : -1;
+ }
+
+ isRoot(nodeHandle: InvertedCallNodeHandle): boolean {
+ return nodeHandle === 0;
+ }
+
+ getSuffixOrderIndexRangeForCallNode(
+ nodeHandle: InvertedCallNodeHandle
+ ): [SuffixOrderIndex, SuffixOrderIndex] {
+ return [this._tSoStart[nodeHandle], this._tSoEnd[nodeHandle]];
+ }
+
+ getChildren(nodeIndex: InvertedCallNodeHandle): InvertedCallNodeHandle[] {
+ // Extend the BFS far enough for this node to have its children
+ // materialized (depth 0 for the root, depth d for a node at depth d).
+ this._extendToDepth(this._tDepth[nodeIndex]);
+ return this._tChildren[nodeIndex];
+ }
+
+ getCallNodePathFromIndex(
+ callNodeHandle: InvertedCallNodeHandle | null
+ ): CallNodePath {
+ if (callNodeHandle === null || callNodeHandle === -1) {
+ return [];
+ }
+ const callNodePath: number[] = [];
+ let current = callNodeHandle;
+ while (current !== 0) {
+ callNodePath.push(this._tFunc[current]);
+ current = this._tPrefix[current];
+ }
+ callNodePath.push(this._tFunc[0]);
+ callNodePath.reverse();
+ return callNodePath;
+ }
+
+ getCallNodeIndexFromPath(
+ callNodePath: CallNodePath
+ ): InvertedCallNodeHandle | null {
+ if (callNodePath.length === 0) {
+ return null;
+ }
+ // The only root in this CNI is for the selected func. Any path that doesn't
+ // start there has no match.
+ if (callNodePath[0] !== this._selectedFuncIndex) {
+ return null;
+ }
+ let handle: InvertedCallNodeHandle = 0;
+ for (let i = 1; i < callNodePath.length; i++) {
+ const next = this.getCallNodeIndexFromParentAndFunc(
+ handle,
+ callNodePath[i]
+ );
+ if (next === null) {
+ return null;
+ }
+ handle = next;
+ }
+ return handle;
+ }
+
+ getCallNodeIndexFromParentAndFunc(
+ parent: InvertedCallNodeHandle | -1,
+ func: IndexIntoFuncTable
+ ): InvertedCallNodeHandle | null {
+ if (parent === -1) {
+ return this.getRootNodeForFunc(func) === -1 ? null : 0;
+ }
+ const children = this.getChildren(parent);
+ // Children are sorted by func; bisect to find a match.
+ const index = bisectionRightByKey(children, func, (node) =>
+ this.funcForNode(node)
+ );
+ if (index === 0) {
+ return null;
+ }
+ const candidate = children[index - 1];
+ return this.funcForNode(candidate) === func ? candidate : null;
+ }
+
+ prefixForNode(
+ callNodeHandle: InvertedCallNodeHandle
+ ): InvertedCallNodeHandle | -1 {
+ return this._tPrefix[callNodeHandle];
+ }
+
+ funcForNode(callNodeHandle: InvertedCallNodeHandle): IndexIntoFuncTable {
+ return this._tFunc[callNodeHandle];
+ }
+
+ categoryForNode(
+ callNodeHandle: InvertedCallNodeHandle
+ ): IndexIntoCategoryList {
+ return this._tCategory[callNodeHandle];
+ }
+
+ subcategoryForNode(
+ callNodeHandle: InvertedCallNodeHandle
+ ): IndexIntoSubcategoryListForCategory {
+ return this._tSubcategory[callNodeHandle];
+ }
+
+ innerWindowIDForNode(
+ callNodeHandle: InvertedCallNodeHandle
+ ): IndexIntoCategoryList {
+ return this._tInnerWindowID[callNodeHandle];
+ }
+
+ depthForNode(callNodeHandle: InvertedCallNodeHandle): number {
+ return this._tDepth[callNodeHandle];
+ }
+
+ sourceFramesInlinedIntoSymbolForNode(
+ callNodeHandle: InvertedCallNodeHandle
+ ): IndexIntoNativeSymbolTable | -1 | -2 {
+ return this._tInlinedInto[callNodeHandle] as
+ | IndexIntoNativeSymbolTable
+ | -1
+ | -2;
+ }
+
+ // Returns a snapshot of the lower-wing inverted call node table, 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
+ // 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();
+ }
+
+ 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
+ // 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;
+ }
+}
+
+/**
+ * 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 29189178db..9ec17ed143 100644
--- a/src/profile-logic/call-tree.ts
+++ b/src/profile-logic/call-tree.ts
@@ -38,8 +38,14 @@ 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 { getBottomBoxInfoForCallNode } from './bottom-box';
+import type {
+ ColumnSortState,
+ SortableColumn,
+} from '../components/shared/TreeView';
+import {
+ getBottomBoxInfoForCallNode,
+ getBottomBoxInfoForFunction,
+} from './bottom-box';
type CallNodeChildren = IndexIntoCallNodeTable[];
@@ -48,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 };
@@ -60,9 +67,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 = {
@@ -242,8 +255,8 @@ class CallTreeInternalInverted implements CallTreeInternal {
_callNodeTable: CallNodeTable;
_callNodeSelf: Float64Array;
_rootNodes: IndexIntoCallNodeTable[];
- _totalPerRootFunc: Float64Array;
- _hasChildrenPerRootFunc: Uint8Array;
+ _totalPerRootNode: Float64Array;
+ _hasChildrenPerRootNode: Uint8Array;
_totalAndHasChildrenPerNonRootNode: Map<
IndexIntoCallNodeTable,
TotalAndHasChildren
@@ -256,10 +269,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;
}
@@ -269,7 +282,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;
}
@@ -295,14 +308,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;
@@ -404,6 +417,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 [];
}
@@ -411,7 +430,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;
}
@@ -515,7 +555,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);
@@ -553,6 +593,7 @@ export class CallTree {
self
);
const totalPercent = `${formatPercent(totalRelative)}`;
+ const selfPercent = `${formatPercent(selfRelative)}`;
let ariaLabel;
let totalWithUnit;
@@ -603,6 +644,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.
@@ -647,6 +689,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.
*
@@ -707,7 +757,11 @@ export function computeCallNodeSelfAndSummary(
rootTotalSummary += abs(callNodeSelf[callNodeIndex]);
}
- return { callNodeSelf, rootTotalSummary };
+ return {
+ callNodeSelf,
+ rootTotalSummary,
+ flameGraphWidthTotal: rootTotalSummary,
+ };
}
export function getSelfAndTotalForCallNode(
@@ -736,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(
@@ -806,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) {
@@ -821,28 +875,91 @@ 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,
+ };
+}
+
+// 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.
+export function computeLowerWingCallNodeSelf(
+ callNodeSelf: Float64Array,
+ callNodeTable: CallNodeTable,
+ selectedFuncIndex: IndexIntoFuncTable
+): Float64Array {
+ const callNodeCount = callNodeTable.length;
+ const funcCol = callNodeTable.func;
+ const subtreeEndCol = callNodeTable.subtreeRangeEnd;
+ const mappedSelf = new Float64Array(callNodeCount);
+ for (let i = 0; i < callNodeCount; i++) {
+ if (funcCol[i] !== selectedFuncIndex) {
+ continue;
+ }
+
+ // Call node i is the root of a subtree for the selected function.
+ const subtreeEnd = subtreeEndCol[i];
+ let subtreeTotal = 0;
+ for (let j = i; j < subtreeEnd; j++) {
+ subtreeTotal += callNodeSelf[j];
+ }
+ mappedSelf[i] = subtreeTotal;
+ // Skip nested re-entries of selectedFuncIndex; their time is already
+ // included in the outer subtree total above.
+ i = subtreeEnd - 1;
+ }
+ return mappedSelf;
+}
+
+export function computeLowerWingTimings(
+ callNodeInfo: CallNodeInfoInverted,
+ { callNodeSelf, rootTotalSummary }: CallNodeSelfAndSummary,
+ selectedFuncIndex: IndexIntoFuncTable | null
+): CallTreeTimings {
+ const callNodeTable = callNodeInfo.getCallNodeTable();
+ const mappedSelf =
+ selectedFuncIndex !== null
+ ? computeLowerWingCallNodeSelf(
+ callNodeSelf,
+ callNodeTable,
+ selectedFuncIndex
+ )
+ : new Float64Array(callNodeSelf.length);
+
+ return {
+ type: 'INVERTED',
+ timings: computeCallTreeTimingsInverted(callNodeInfo, {
+ callNodeSelf: mappedSelf,
+ rootTotalSummary,
+ flameGraphWidthTotal: rootTotalSummary,
+ }),
};
}
@@ -878,7 +995,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);
@@ -916,6 +1034,7 @@ export function computeCallTreeTimingsNonInverted(
total: callNodeTotal,
callNodeHasChildren,
rootTotalSummary,
+ flameGraphWidthTotal,
};
}
@@ -1318,5 +1437,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 24f8347c5a..56f373de14 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';
@@ -104,8 +111,23 @@ 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];
+ 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;
@@ -142,8 +164,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);
@@ -611,3 +633,404 @@ export function getInvertedFlameGraphTiming(
stringTable
);
}
+/**
+ * Per-LowerWingTable-index total and self values used to drive the lower wing
+ * flame graph. Both arrays have length equal to the lower-wing table length.
+ * Index 0 is the root (the selected function).
+ */
+export type LowerWingFlameGraphTotals = {
+ totalPerTableIdx: Float64Array;
+ selfPerTableIdx: Float64Array;
+ rootTotalSummary: number;
+ // Equal to rootTotalSummary — the lower wing's flame graph root fills the
+ // entire width.
+ flameGraphWidthTotal: number;
+};
+
+/**
+ * Lazy, depth-incremental FlameGraphTiming for the lower wing.
+ *
+ * Wraps a `LowerWingCallNodeInfo` and produces a `FlameGraphTimingRow` for any
+ * depth on demand. Internal state:
+ *
+ * - `_rowsCallNodes[d]` is the row of call-node handles for depth `d`, sorted
+ * in flame-graph display order. Lazily built one depth at a time from the
+ * previous row's children — same algorithm as the old
+ * `computeLowerWingFlameGraphRows`, just sliced.
+ * - `_timingRows[d]` is the per-cell start/end/self/etc. for depth `d`.
+ * - `_prefixSums` is the running prefix sum over `lowerWing.getSuffixOrderedCallNodes()`
+ * in its *current* (partition-refined) order. We recompute whenever the
+ * underlying table has grown since the last recompute — extending the BFS
+ * permutes the suffix order within nodes' fixed `[soStart, soEnd)` ranges,
+ * which invalidates per-position prefix sums but leaves per-range sums
+ * unchanged. Detection is just `lowerWing.getLowerWingTableUpToDepth(-1).length`.
+ * - `_startPerTableIdx` carries cells' left edges across rows so children can
+ * be aligned under their parent. Grown as the table grows.
+ *
+ * `numRows` is the cheap upper bound from `computeLowerWingMaxDepthPlusOne`,
+ * so the Canvas can size the scroll area without forcing any tree build.
+ *
+ * Self-time correctness: row D's self values depend on the totals of nodes at
+ * depth D+1. `getRow(D)` extends the lower-wing CNI to depth D, which processes
+ * nodes at depth D and emits their depth-D+1 children — so children's
+ * `[soStart, soEnd)` ranges are set and their totals are computable. (For the
+ * deepest row, nodes have no children and self = total, which is also correct.)
+ */
+export class LowerWingFlameGraphTiming implements FlameGraphTiming {
+ _lowerWing: LowerWingCallNodeInfo;
+ _callNodeSelfAndSummary: CallNodeSelfAndSummary;
+ _callNodeTable: CallNodeTable;
+ _selectedFuncIndex: IndexIntoFuncTable | null;
+ _funcTable: FuncTable;
+ _stringTable: StringTable;
+
+ _rowCount: number;
+ _timingRows: FlameGraphTimingRow[];
+
+ // Lazy timing state.
+ _mappedSelf: Float64Array | null;
+ _prefixSums: Float64Array | null;
+ _prefixSumsForTableLength: number;
+ _rootTotalSummary: number;
+ _tooltipRatioMultiplier: number;
+
+ // Lazy row state. `_rowsCallNodes[d]` is dense for `d < _rowsCallNodes.length`.
+ _rowsCallNodes: IndexIntoCallNodeTable[][];
+
+ // Persistent left-edge cache (table-index keyed) used by row alignment.
+ _startPerTableIdx: Float32Array;
+
+ // Persistent total cache (table-index keyed). Populated for each node while
+ // iterating its parent's children to compute `childSum`, then read back when
+ // the node itself is emitted in the next row — avoids recomputing the same
+ // prefix-sums subtraction twice. Row-0 roots are the only nodes never seen
+ // as a child, so they compute their total directly.
+ _totalPerTableIdx: Float64Array;
+
+ constructor(
+ lowerWing: LowerWingCallNodeInfo,
+ callNodeSelfAndSummary: CallNodeSelfAndSummary,
+ callNodeTable: CallNodeTable,
+ selectedFuncIndex: IndexIntoFuncTable | null,
+ funcTable: FuncTable,
+ stringTable: StringTable
+ ) {
+ this._lowerWing = lowerWing;
+ this._callNodeSelfAndSummary = callNodeSelfAndSummary;
+ this._callNodeTable = callNodeTable;
+ this._selectedFuncIndex = selectedFuncIndex;
+ this._funcTable = funcTable;
+ this._stringTable = stringTable;
+
+ this._rowCount = computeLowerWingMaxDepthPlusOne(
+ callNodeTable,
+ selectedFuncIndex
+ );
+ this._timingRows = [];
+
+ this._mappedSelf = null;
+ this._prefixSums = null;
+ this._prefixSumsForTableLength = -1;
+ this._rootTotalSummary = 0;
+ // tooltipRatioMultiplier for the lower wing is flameGraphWidthTotal /
+ // rootTotalSummary, both equal to total[0]. So 1 when there's a selection
+ // with sample weight, 0 otherwise. We resolve this lazily inside
+ // `_ensurePrefixSums` since both values come from the prefix-sums pass.
+ 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 {
+ const row = this.getRow(depth);
+ if (indexInRow < 0 || indexInRow >= row.length) {
+ throw new Error(
+ `Out-of-bounds call to getRatioOfRootTotalSummary: For depth ${depth}, ${indexInRow} is outside 0..${row.length}`
+ );
+ }
+
+ const ratioOfFullWidth = row.end[indexInRow] - row.start[indexInRow];
+ return ratioOfFullWidth;
+ }
+
+ _ensureRowCallNodes(depth: number): void {
+ if (this._selectedFuncIndex === null) {
+ while (this._rowsCallNodes.length <= depth) {
+ this._rowsCallNodes.push([]);
+ }
+ return;
+ }
+ if (this._rowsCallNodes.length === 0) {
+ // Row 0: only the inverted root, whose handle is 0 in the lower wing.
+ this._rowsCallNodes.push([0]);
+ }
+ while (this._rowsCallNodes.length <= depth) {
+ this._buildNextRowCallNodes();
+ }
+ }
+
+ // Build `_rowsCallNodes[next]` from the previous row's children. The
+ // lower-wing table stores children sorted by func index, so we re-sort by
+ // name via bisect-insert here (mirrors the old `computeLowerWingFlameGraphRows`).
+ _buildNextRowCallNodes(): void {
+ const nextDepth = this._rowsCallNodes.length;
+ // Need parents (at nextDepth - 1) processed, i.e. CNI extended to
+ // nextDepth - 1 so their children at nextDepth are populated.
+ const table = this._lowerWing.getLowerWingTableUpToDepth(nextDepth - 1);
+ const funcNameCol = this._funcTable.name;
+ const stringTable = this._stringTable;
+ const tableFunc = table.func;
+ const tableChildren = table.children;
+
+ const parentRow = this._rowsCallNodes[nextDepth - 1];
+ const childRow: IndexIntoCallNodeTable[] = [];
+
+ // Handle === table index for the lower-wing CNI, so no translation.
+ for (let p = 0; p < parentRow.length; p++) {
+ const parentHandle = parentRow[p];
+ const children = tableChildren[parentHandle];
+ if (children.length === 0) {
+ continue;
+ }
+ const groupStart = childRow.length;
+ for (let c = 0; c < children.length; c++) {
+ const childHandle = children[c];
+ const childFunc = tableFunc[childHandle];
+ const childName = stringTable.getString(funcNameCol[childFunc]);
+ const groupEnd = childRow.length;
+ if (groupStart === groupEnd) {
+ childRow.push(childHandle);
+ } else {
+ const insertionIndex = bisectionRightByStrKey(
+ childRow,
+ childName,
+ (handle) => stringTable.getString(funcNameCol[tableFunc[handle]]),
+ groupStart,
+ groupEnd
+ );
+ childRow.splice(insertionIndex, 0, childHandle);
+ }
+ }
+ }
+
+ this._rowsCallNodes.push(childRow);
+ }
+
+ // Compute (or refresh) `_prefixSums` if the underlying CNI table has grown
+ // since the last computation. Also resolves `_rootTotalSummary` and
+ // `_tooltipRatioMultiplier` on the first run — those don't change as the
+ // tree grows because the root's `[soStart, soEnd)` covers the full entry
+ // set and that set is invariant.
+ _ensurePrefixSums(): void {
+ if (this._selectedFuncIndex === null) {
+ if (this._prefixSums === null) {
+ this._prefixSums = new Float64Array(1);
+ // _rootTotalSummary and _tooltipRatioMultiplier stay at their default 0.
+ this._prefixSumsForTableLength =
+ this._lowerWing.getLowerWingTableUpToDepth(-1).length;
+ }
+ return;
+ }
+
+ const tableLength = this._lowerWing.getLowerWingTableUpToDepth(-1).length;
+ if (
+ this._prefixSums !== null &&
+ this._prefixSumsForTableLength === tableLength
+ ) {
+ return;
+ }
+
+ if (this._mappedSelf === null) {
+ this._mappedSelf = computeLowerWingCallNodeSelf(
+ this._callNodeSelfAndSummary.callNodeSelf,
+ this._callNodeTable,
+ this._selectedFuncIndex
+ );
+ }
+ const mappedSelf = this._mappedSelf;
+ const suffixOrdered = this._lowerWing.getSuffixOrderedCallNodes();
+ const N = suffixOrdered.length;
+ const prefixSums = new Float64Array(N + 1);
+ for (let k = 0; k < N; k++) {
+ prefixSums[k + 1] = prefixSums[k] + mappedSelf[suffixOrdered[k]];
+ }
+ this._prefixSums = prefixSums;
+ this._prefixSumsForTableLength = tableLength;
+ // total[0] = root's range = all entries. Compute once; invariant across
+ // further extensions.
+ if (this._rootTotalSummary === 0 && N > 0) {
+ this._rootTotalSummary = prefixSums[N];
+ this._tooltipRatioMultiplier = this._rootTotalSummary === 0 ? 0 : 1;
+ }
+ }
+
+ _growPerTableIdxArraysTo(minLength: number): void {
+ if (this._startPerTableIdx.length >= minLength) {
+ return;
+ }
+ let newCap = this._startPerTableIdx.length;
+ while (newCap < minLength) {
+ newCap *= 2;
+ }
+ const nextStart = new Float32Array(newCap);
+ nextStart.set(this._startPerTableIdx);
+ this._startPerTableIdx = nextStart;
+ const nextTotal = new Float64Array(newCap);
+ nextTotal.set(this._totalPerTableIdx);
+ this._totalPerTableIdx = nextTotal;
+ }
+
+ _buildNextTimingRow(): void {
+ const nextDepth = this._timingRows.length;
+
+ // Extend the CNI to nextDepth so depth-nextDepth nodes are processed —
+ // this populates their `_tChildren` and adds the depth-(nextDepth+1)
+ // children whose totals feed into self-time at nextDepth.
+ const table = this._lowerWing.getLowerWingTableUpToDepth(nextDepth);
+
+ this._ensureRowCallNodes(nextDepth);
+ this._ensurePrefixSums();
+ this._growPerTableIdxArraysTo(table.length);
+
+ const rowNodes = this._rowsCallNodes[nextDepth];
+ const flameGraphWidthTotal = this._rootTotalSummary;
+ if (flameGraphWidthTotal === 0) {
+ this._timingRows.push({
+ start: [],
+ end: [],
+ selfRelative: [],
+ callNode: [],
+ length: 0,
+ });
+ return;
+ }
+
+ const tablePrefix = table.prefix;
+ const tableSoStart = table.suffixOrderIndexRangeStart;
+ const tableSoEnd = table.suffixOrderIndexRangeEnd;
+ const tableChildren = table.children;
+ const prefixSums = this._prefixSums as Float64Array;
+ const startPerTableIdx = this._startPerTableIdx;
+ const totalPerTableIdx = this._totalPerTableIdx;
+
+ // Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1858310
+ const abs = Math.abs;
+
+ const start: UnitIntervalOfProfileRange[] = [];
+ const end: UnitIntervalOfProfileRange[] = [];
+ const selfRelative: number[] = [];
+ const timingCallNodes: IndexIntoCallNodeTable[] = [];
+
+ let currentStart = 0;
+ let previousPrefixIdx = -1;
+ // Handle === table index for the lower-wing CNI.
+ for (let i = 0; i < rowNodes.length; i++) {
+ const tableIdx = rowNodes[i];
+ // Row-0 roots are never seen as a child, so their total isn't cached;
+ // compute directly. Deeper rows read the total cached by their parent's
+ // childSum loop on the previous row.
+ const totalVal =
+ nextDepth === 0
+ ? prefixSums[tableSoEnd[tableIdx]] -
+ prefixSums[tableSoStart[tableIdx]]
+ : totalPerTableIdx[tableIdx];
+ if (totalVal === 0) {
+ continue;
+ }
+
+ // self = total − Σ children's totals. Children at depth nextDepth+1
+ // are guaranteed to exist (CNI was extended to nextDepth), so each
+ // child's [soStart, soEnd) is final and its total is well-defined.
+ // Cache each child's total so the next row can read it directly.
+ const children = tableChildren[tableIdx];
+ let childSum = 0;
+ for (let c = 0; c < children.length; c++) {
+ const childIdx = children[c];
+ const childTotal =
+ prefixSums[tableSoEnd[childIdx]] - prefixSums[tableSoStart[childIdx]];
+ totalPerTableIdx[childIdx] = childTotal;
+ childSum += childTotal;
+ }
+ const selfVal = totalVal - childSum;
+
+ const parentIdx = tablePrefix[tableIdx];
+
+ if (parentIdx !== previousPrefixIdx) {
+ currentStart = parentIdx === -1 ? 0 : startPerTableIdx[parentIdx];
+ previousPrefixIdx = parentIdx;
+ }
+ startPerTableIdx[tableIdx] = currentStart;
+
+ const totalRelative = abs(totalVal / flameGraphWidthTotal);
+ const selfRelativeVal = abs(selfVal / flameGraphWidthTotal);
+
+ const currentEnd = currentStart + totalRelative;
+ start.push(currentStart);
+ end.push(currentEnd);
+ selfRelative.push(selfRelativeVal);
+ timingCallNodes.push(tableIdx);
+
+ currentStart = currentEnd;
+ }
+
+ this._timingRows.push({
+ start,
+ end,
+ selfRelative,
+ callNode: timingCallNodes,
+ length: timingCallNodes.length,
+ });
+ }
+}
+
+/**
+ * Construct the (lazy) flame-graph timing for the lower wing.
+ *
+ * The returned object is the unit of work the Canvas talks to: it asks for a
+ * row by depth and the timing object extends the lower-wing CNI, refreshes
+ * prefix sums, and computes only the rows that have been asked for.
+ */
+export function createLowerWingFlameGraphTiming(
+ lowerWing: LowerWingCallNodeInfo,
+ callNodeSelfAndSummary: CallNodeSelfAndSummary,
+ callNodeTable: CallNodeTable,
+ selectedFuncIndex: IndexIntoFuncTable | null,
+ funcTable: FuncTable,
+ stringTable: StringTable
+): LowerWingFlameGraphTiming {
+ return new LowerWingFlameGraphTiming(
+ lowerWing,
+ callNodeSelfAndSummary,
+ callNodeTable,
+ selectedFuncIndex,
+ funcTable,
+ stringTable
+ );
+}
diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts
index fd210b62ca..0e3659a7c7 100644
--- a/src/profile-logic/profile-data.ts
+++ b/src/profile-logic/profile-data.ts
@@ -15,8 +15,12 @@ import {
} from './data-structures';
import {
CallNodeInfoNonInverted,
- CallNodeInfoInverted,
+ LazyInvertedCallNodeInfo,
+ LowerWingCallNodeInfo,
+ computeLowerWingMaxDepthPlusOne,
} from './call-node-info';
+
+export { computeLowerWingMaxDepthPlusOne };
import { computeThreadCPUPercent } from './cpu';
import {
INSTANT,
@@ -110,7 +114,11 @@ import type {
SourceLocationTable,
} from 'firefox-profiler/types';
import { SelectedState, ResourceType } from 'firefox-profiler/types';
-import type { CallNodeInfo, SuffixOrderIndex } from './call-node-info';
+import type {
+ CallNodeInfo,
+ CallNodeInfoInverted,
+ SuffixOrderIndex,
+} from './call-node-info';
import {
toFloat64Array,
toInt32Array,
@@ -194,7 +202,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(
@@ -241,7 +252,6 @@ type CallNodeTableHierarchy = {
// there are no call nodes.
firstRoot: IndexIntoCallNodeTable;
length: number;
- stackIndexToCallNodeIndex: Int32Array;
};
/**
@@ -306,7 +316,7 @@ type CallNodeTableExtraColumns = {
function _computeCallNodeTableHierarchy(
stackTable: StackTable,
frameTable: FrameTable
-): CallNodeTableHierarchy {
+): CallNodeTableHierarchy & { stackIndexToCallNodeIndex: Int32Array } {
const stackIndexToCallNodeIndex = new Int32Array(stackTable.length);
// The callNodeTable components.
@@ -457,16 +467,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);
@@ -560,6 +564,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.
*
@@ -649,6 +763,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.
*/
@@ -657,7 +846,7 @@ export function getInvertedCallNodeInfo(
defaultCategory: IndexIntoCategoryList,
funcCount: number
): CallNodeInfoInverted {
- return new CallNodeInfoInverted(
+ return new LazyInvertedCallNodeInfo(
callNodeInfo.getCallNodeTable(),
callNodeInfo.getStackIndexToNonInvertedCallNodeIndex(),
defaultCategory,
@@ -665,6 +854,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
+): LowerWingCallNodeInfo {
+ 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
@@ -958,6 +1169,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.
@@ -1146,6 +1398,65 @@ export function getSampleSelectedStates(
);
}
+/**
+ * Go through the samples, and determine their current state.
+ *
+ * For samples that are neither 'FILTERED_OUT_*' nor 'SELECTED',
+ * this function uses 'UNSELECTED_ORDERED_AFTER_SELECTED'. It uses the same
+ * ordering as the function compareCallNodes in getTreeOrderComparator.
+ */
+export function getSamplesSelectedStatesForFunction(
+ sampleCallNodes: Array,
+ selectedFunctionIndex: IndexIntoFuncTable | null,
+ callNodeTable: CallNodeTable
+): Uint8Array {
+ if (selectedFunctionIndex === null) {
+ return _getSampleSelectedStatesForNoSelection(sampleCallNodes);
+ }
+
+ const sampleCount = sampleCallNodes.length;
+
+ // Go through each call node, and label it as containing the function or not.
+ // callNodeContainsFunc is a callNodeIndex => bool map, implemented as a U8 typed
+ // array for better performance. 0 means false, 1 means true.
+ const callNodeCount = callNodeTable.length;
+ const callNodeContainsFunc = new Uint8Array(callNodeCount);
+ for (let callNodeIndex = 0; callNodeIndex < callNodeCount; callNodeIndex++) {
+ const prefix = callNodeTable.prefix[callNodeIndex];
+ const funcIndex = callNodeTable.func[callNodeIndex];
+ if (
+ funcIndex === selectedFunctionIndex ||
+ // The parent of this stack contained the function.
+ (prefix !== -1 && callNodeContainsFunc[prefix] === 1)
+ ) {
+ callNodeContainsFunc[callNodeIndex] = 1;
+ }
+ }
+
+ // Go through each sample, and label its state.
+ const samplesSelectedStates = new Uint8Array(sampleCount);
+ for (
+ let sampleIndex = 0;
+ sampleIndex < sampleCallNodes.length;
+ sampleIndex++
+ ) {
+ let sampleSelectedState: SelectedState = SelectedState.Selected;
+ const callNodeIndex = sampleCallNodes[sampleIndex];
+ if (callNodeIndex !== null) {
+ if (callNodeContainsFunc[callNodeIndex] === 1) {
+ sampleSelectedState = SelectedState.Selected;
+ } else {
+ sampleSelectedState = SelectedState.UnselectedOrderedBeforeSelected;
+ }
+ } else {
+ // This sample was filtered out.
+ sampleSelectedState = SelectedState.FilteredOutByTransform;
+ }
+ samplesSelectedStates[sampleIndex] = sampleSelectedState;
+ }
+ return samplesSelectedStates;
+}
+
/**
* This function returns the function index for a specific call node path. This
* is the last element of this path, or the leaf element of the path.
@@ -1448,6 +1759,41 @@ export function computeCallNodeFuncIsDuplicate(
return nodeFuncIsDuplicateBitSet;
}
+/**
+ * This function returns the timings for a specific function.
+ *
+ * Note that the unfilteredThread should be the original thread before any filtering
+ * (by range or other) happens. Also sampleIndexOffset needs to be properly
+ * specified and is the offset to be applied on thread's indexes to access
+ * the same samples in unfilteredThread.
+ */
+export function getTimingsForFunction(
+ _funcIndex: IndexIntoFuncTable | null,
+ _interval: Milliseconds,
+ _thread: Thread,
+ _unfilteredThread: Thread,
+ _sampleIndexOffset: number,
+ _categories: CategoryList,
+ _samples: SamplesLikeTable,
+ _unfilteredSamples: SamplesLikeTable,
+ _displayImplementation: boolean
+): TimingsForPath {
+ // TODO
+ return {
+ forPath: {
+ selfTime: {
+ value: 0,
+ breakdownByCategory: null,
+ },
+ totalTime: {
+ value: 0,
+ breakdownByCategory: null,
+ },
+ },
+ rootTime: 1,
+ };
+}
+
// This function computes the time range for a thread, using both its samples
// and markers data. It's memoized and exported below, because it's called both
// here in getTimeRangeIncludingAllThreads, and in selectors when dealing with
@@ -4851,3 +5197,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/reducers/profile-view.ts b/src/reducers/profile-view.ts
index b2a58f78c0..0f9bffb48e 100644
--- a/src/reducers/profile-view.ts
+++ b/src/reducers/profile-view.ts
@@ -30,6 +30,8 @@ import type {
ThreadsKey,
Milliseconds,
TableViewOptions,
+ RightClickedFunction,
+ CallNodeArea,
} from 'firefox-profiler/types';
import {
applyFuncSubstitutionToCallPath,
@@ -183,11 +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(),
+ selectedCallNodePaths: _emptySelectedCallNodePaths,
+ expandedCallNodePaths: _emptyExpandedCallNodePaths(),
selectedNetworkMarker: null,
lastSeenTransformCount: 0,
};
@@ -229,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
+ ),
+ },
};
});
@@ -254,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.
@@ -275,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;
@@ -301,19 +318,52 @@ 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 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, {
+ selectedCallNodePaths: {
+ ...threadState.selectedCallNodePaths,
+ LOWER_WING: wingPath,
+ UPPER_WING: wingPath,
+ },
+ expandedCallNodePaths: {
+ ...threadState.expandedCallNodePaths,
+ LOWER_WING: wingExpanded,
+ UPPER_WING: wingExpanded,
+ },
+ });
}
case 'CHANGE_INVERT_CALLSTACK': {
const {
@@ -333,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;
@@ -394,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,
});
}
@@ -422,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,
});
}
@@ -439,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;
@@ -449,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,
};
}
@@ -517,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:
@@ -636,6 +744,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') {
@@ -758,6 +867,7 @@ const rightClickedCallNode: Reducer = (
if (action.callNodePath !== null) {
return {
threadsKey: action.threadsKey,
+ area: action.area,
callNodePath: action.callNodePath,
};
}
@@ -781,6 +891,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 +1040,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 0c8a92be9a..748c0627ec 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';
@@ -206,6 +209,54 @@ const markerTableSort: Reducer = (
}
};
+const functionListSort: Reducer = (
+ state = null,
+ action
+) => {
+ switch (action.type) {
+ case 'CHANGE_FUNCTION_LIST_SORT':
+ return action.sort;
+ default:
+ return state;
+ }
+};
+
+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':
@@ -788,6 +839,26 @@ const selectedMarkers: Reducer = (
}
};
+const selectedFunctions: Reducer = (
+ state = {},
+ action
+): SelectedFunctionsPerThread => {
+ switch (action.type) {
+ case 'CHANGE_SELECTED_FUNCTION': {
+ const { threadsKey, selectedFunctionIndex } = action;
+ if (state[threadsKey] === selectedFunctionIndex) {
+ return state;
+ }
+ return {
+ ...state,
+ [threadsKey]: selectedFunctionIndex,
+ };
+ }
+ default:
+ return state;
+ }
+};
+
/**
* These values are specific to an individual profile.
*/
@@ -818,7 +889,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 97c5e38283..7df3fa2e08 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';
@@ -42,10 +43,16 @@ import type {
CallNodeSelfAndSummary,
State,
CallNodeTableBitSet,
+ IndexIntoFuncTable,
+ IndexIntoStackTable,
+ SamplesLikeTable,
+ SampleCategoriesAndSubcategories,
+ CallNodeArea,
} from 'firefox-profiler/types';
import type {
CallNodeInfo,
CallNodeInfoInverted,
+ LowerWingCallNodeInfo,
} from 'firefox-profiler/profile-logic/call-node-info';
import type { ThreadSelectorsPerThread } from './thread';
@@ -211,42 +218,120 @@ export function getStackAndSampleSelectorsPerThread(
}
);
+ 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) => threadViewOptions.selectedCallNodePaths.LOWER_WING
+ );
+
+ const getUpperWingSelectedCallNodePath: Selector =
+ createSelector(
+ threadSelectors.getViewOptions,
+ (threadViewOptions) => threadViewOptions.selectedCallNodePaths.UPPER_WING
+ );
+
const getSelectedCallNodePath: Selector = createSelector(
threadSelectors.getViewOptions,
UrlState.getInvertCallstack,
(threadViewOptions, invertCallStack): CallNodePath =>
invertCallStack
- ? threadViewOptions.selectedInvertedCallNodePath
- : threadViewOptions.selectedNonInvertedCallNodePath
+ ? threadViewOptions.selectedCallNodePaths.INVERTED_TREE
+ : threadViewOptions.selectedCallNodePaths.NON_INVERTED_TREE
);
- const getSelectedCallNodeIndex: Selector =
- createSelector(
- getCallNodeInfo,
- getSelectedCallNodePath,
- (callNodeInfo, callNodePath) => {
- return callNodeInfo.getCallNodeIndexFromPath(callNodePath);
- }
- );
+ const getSelectedCallNodeIndex = makeCallNodeIndexFromPathSelector(
+ getCallNodeInfo,
+ getSelectedCallNodePath
+ );
+ const getLowerWingSelectedCallNodeIndex = makeCallNodeIndexFromPathSelector(
+ getLowerWingCallNodeInfo,
+ getLowerWingSelectedCallNodePath
+ );
+ const getUpperWingSelectedCallNodeIndex = makeCallNodeIndexFromPathSelector(
+ getUpperWingCallNodeInfo,
+ getUpperWingSelectedCallNodePath
+ );
const getExpandedCallNodePaths: Selector = createSelector(
threadSelectors.getViewOptions,
UrlState.getInvertCallstack,
(threadViewOptions, invertCallStack) =>
invertCallStack
- ? threadViewOptions.expandedInvertedCallNodePaths
- : threadViewOptions.expandedNonInvertedCallNodePaths
+ ? threadViewOptions.expandedCallNodePaths.INVERTED_TREE
+ : threadViewOptions.expandedCallNodePaths.NON_INVERTED_TREE
);
- const getExpandedCallNodeIndexes: Selector<
- Array
- > = createSelector(
+ const getLowerWingExpandedCallNodePaths: Selector = createSelector(
+ threadSelectors.getViewOptions,
+ (threadViewOptions) => threadViewOptions.expandedCallNodePaths.LOWER_WING
+ );
+
+ const getUpperWingExpandedCallNodePaths: Selector = createSelector(
+ threadSelectors.getViewOptions,
+ (threadViewOptions) => threadViewOptions.expandedCallNodePaths.UPPER_WING
+ );
+
+ const getExpandedCallNodeIndexes = makeExpandedIndexesSelector(
getCallNodeInfo,
- getExpandedCallNodePaths,
- (callNodeInfo, callNodePaths) =>
- Array.from(callNodePaths).map((path) =>
- callNodeInfo.getCallNodeIndexFromPath(path)
- )
+ getExpandedCallNodePaths
+ );
+ const getLowerWingExpandedCallNodeIndexes = makeExpandedIndexesSelector(
+ getLowerWingCallNodeInfo,
+ getLowerWingExpandedCallNodePaths
+ );
+ const getUpperWingExpandedCallNodeIndexes = makeExpandedIndexesSelector(
+ getUpperWingCallNodeInfo,
+ getUpperWingExpandedCallNodePaths
);
const _getSampleIndexToNonInvertedCallNodeIndexForPreviewFilteredCtssThread: Selector<
@@ -268,6 +353,27 @@ export function getStackAndSampleSelectorsPerThread(
ProfileData.getSampleIndexToCallNodeIndex
);
+ const _getPreviewFilteredCtssSampleIndexToUpperWingCallNodeIndex: Selector<
+ Array
+ > = createSelector(
+ (state: State) =>
+ threadSelectors.getPreviewFilteredCtssSamples(state).stack,
+ (state: State) =>
+ getUpperWingCallNodeInfo(state).getStackIndexToNonInvertedCallNodeIndex(),
+ (sampleStacks, stackIndexToCallNodeIndex) => {
+ return sampleStacks.map((stackIndex: IndexIntoStackTable | null) => {
+ if (stackIndex === null) {
+ return null;
+ }
+ const callNodeIndex = stackIndexToCallNodeIndex[stackIndex];
+ if (callNodeIndex === -1) {
+ return null;
+ }
+ return callNodeIndex;
+ });
+ }
+ );
+
const getSampleIndexToNonInvertedCallNodeIndexForFilteredThread: Selector<
Array
> = createSelector(
@@ -295,6 +401,19 @@ export function getStackAndSampleSelectorsPerThread(
}
);
+ const getSampleSelectedStatesForFunctionListTab: Selector =
+ createSelector(
+ getSampleIndexToNonInvertedCallNodeIndexForFilteredThread,
+ _getCallNodeTable,
+ getSelectedFunctionIndex,
+ (sampleCallNodes, callNodeTable, selectedFunctionIndex) =>
+ ProfileData.getSamplesSelectedStatesForFunction(
+ sampleCallNodes,
+ selectedFunctionIndex,
+ callNodeTable
+ )
+ );
+
const getTreeOrderComparatorInFilteredThread: Selector<
(
sampleIndexA: IndexIntoSamplesTable,
@@ -371,12 +490,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,
@@ -447,6 +611,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,
@@ -578,6 +762,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,
@@ -586,11 +973,80 @@ 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,
+ (profileViewOptions) => {
+ const rightClickedFunctionInfo =
+ profileViewOptions.rightClickedFunction;
+ if (
+ rightClickedFunctionInfo !== null &&
+ threadsKey === rightClickedFunctionInfo.threadsKey
+ ) {
+ return rightClickedFunctionInfo.functionIndex;
+ }
return null;
}
@@ -601,20 +1057,44 @@ export function getStackAndSampleSelectorsPerThread(
getWeightTypeForCallTree,
getCallNodeInfo,
getNonInvertedCallNodeInfo,
+ getLowerWingCallNodeInfo,
+ getUpperWingCallNodeInfo,
getSourceViewStackLineInfo,
getAssemblyViewNativeSymbolIndex,
getAssemblyViewStackAddressInfo,
getSelectedCallNodePath,
getSelectedCallNodeIndex,
+ getLowerWingSelectedCallNodePath,
+ getLowerWingSelectedCallNodeIndex,
+ getUpperWingSelectedCallNodePath,
+ getUpperWingSelectedCallNodeIndex,
+ getSelectedFunctionIndex,
getExpandedCallNodePaths,
getExpandedCallNodeIndexes,
+ getLowerWingExpandedCallNodePaths,
+ getLowerWingExpandedCallNodeIndexes,
+ getUpperWingExpandedCallNodePaths,
+ getUpperWingExpandedCallNodeIndexes,
getSampleIndexToNonInvertedCallNodeIndexForFilteredThread,
getSampleSelectedStatesInFilteredThread,
+ getSampleSelectedStatesForFunctionListTab,
getTreeOrderComparatorInFilteredThread,
getCallTree,
getNonInvertedCallTree,
getFunctionListTree,
getFunctionListTimings,
+ getLowerWingCallTree,
+ getUpperWingCallTree,
+ getUpperWingFlameGraphTiming,
+ getLowerWingFlameGraphTiming,
+ getLowerWingFlameGraphMaxDepthPlusOne,
+ getSelfWingThread,
+ getSelfWingCallNodeInfo,
+ getSelfWingCallTree,
+ getSelfWingFlameGraphTiming,
+ getSelfWingCallNodeMaxDepthPlusOne,
+ getSelfWingCtssSamples,
+ getSelfWingCtssSampleCategoriesAndSubcategories,
getSourceViewLineTimings,
getAssemblyViewAddressTimings,
getTracedTiming,
@@ -626,5 +1106,9 @@ export function getStackAndSampleSelectorsPerThread(
getNonInvertedFilteredCallNodeMaxDepthPlusOne,
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 48f75a438c..2dc0b2abf4 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';
@@ -121,6 +125,20 @@ export const getMarkersSearchString: Selector = (state) =>
export const getMarkerTableSort: Selector = (
state
) => getProfileSpecificState(state).markerTableSort;
+export const getFunctionListSort: Selector = (
+ state
+) => getProfileSpecificState(state).functionListSort;
+export const getFunctionListSectionsOpen: Selector<
+ FunctionListSectionsOpenState
+> = (state) => getProfileSpecificState(state).functionListSectionsOpen;
+export const getWingViews: Selector = (state) =>
+ getProfileSpecificState(state).wingViews;
+export const getUpperWingView: Selector = (state) =>
+ getWingViews(state).upper;
+export const getLowerWingView: Selector = (state) =>
+ getWingViews(state).lower;
+export const getSelfWingView: Selector = (state) =>
+ getWingViews(state).self;
export const getNetworkSearchString: Selector = (state) =>
getProfileSpecificState(state).networkSearchString;
export const getSelectedTab: Selector = (state) =>
@@ -257,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/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/components/FunctionListContextMenu.test.tsx b/src/test/components/FunctionListContextMenu.test.tsx
new file mode 100644
index 0000000000..019b0a8936
--- /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/WingContextMenu';
+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/LowerWingContextMenu.test.tsx b/src/test/components/LowerWingContextMenu.test.tsx
new file mode 100644
index 0000000000..244db89b31
--- /dev/null
+++ b/src/test/components/LowerWingContextMenu.test.tsx
@@ -0,0 +1,119 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Provider } from 'react-redux';
+
+import { render, screen } from 'firefox-profiler/test/fixtures/testing-library';
+import { LowerWingContextMenu } from '../../components/shared/WingContextMenu';
+import { storeWithProfile } from '../fixtures/stores';
+import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile';
+import { fireFullClick } from '../fixtures/utils';
+import {
+ changeWingRightClickedCallNode,
+ changeSelectedFunctionIndex,
+ setContextMenuVisibility,
+} from '../../actions/profile-view';
+import { selectedThreadSelectors } from '../../selectors/per-thread';
+import { ensureExists } from '../../utils/types';
+
+describe('LowerWingContextMenu', function () {
+ // Samples: A->B->C, A->E->C
+ // When C is selected, the lower wing (inverted) tree shows:
+ // C (root/self function)
+ // B (caller of C)
+ // A
+ // E (caller of C)
+ // A
+ //
+ // Right-clicking B (an inverted child = caller) should give a context menu
+ // for B, not C.
+ function createStore() {
+ const {
+ profile,
+ funcNamesDictPerThread: [{ B, C }],
+ } = getProfileFromTextSamples(`
+ A A
+ B E
+ C C
+ `);
+ const store = storeWithProfile(profile);
+
+ // The lower wing only exists when a function is selected. Select C so the
+ // lower wing tree is built with C as the root.
+ const threadsKey = 0;
+ store.dispatch(changeSelectedFunctionIndex(threadsKey, C));
+ // The inverted call node path for B-as-caller-of-C is [C, B].
+ store.dispatch(changeWingRightClickedCallNode('lower', threadsKey, [C, B]));
+ return store;
+ }
+
+ function setup(store = createStore()) {
+ store.dispatch(setContextMenuVisibility(true));
+ const renderResult = render(
+
+
+
+ );
+ return { ...renderResult, getState: store.getState };
+ }
+
+ describe('basic rendering', function () {
+ it('does not render when no node is right-clicked', () => {
+ const store = storeWithProfile(getProfileFromTextSamples('A').profile);
+ store.dispatch(setContextMenuVisibility(true));
+ const { container } = render(
+
+
+
+ );
+ expect(container.querySelector('.react-contextmenu')).toBeNull();
+ });
+
+ it('renders a context menu when a node is right-clicked', () => {
+ const { container } = setup();
+ expect(
+ ensureExists(
+ container.querySelector('.react-contextmenu'),
+ `Couldn't find the context menu root component .react-contextmenu`
+ ).children.length > 1
+ ).toBeTruthy();
+ });
+
+ it('does not include call-node-specific transforms', () => {
+ setup();
+ expect(screen.queryByText(/Merge node only/)).not.toBeInTheDocument();
+ expect(
+ screen.queryByText(/Focus on subtree only/)
+ ).not.toBeInTheDocument();
+ expect(screen.queryByText(/Expand all/)).not.toBeInTheDocument();
+ expect(screen.queryByText(/Copy stack/)).not.toBeInTheDocument();
+ });
+ });
+
+ describe('clicking on transforms', function () {
+ it('applies transforms to function B, not to the selected function C', function () {
+ const { getState } = setup();
+ fireFullClick(screen.getByText(/Merge function/));
+ const transform =
+ selectedThreadSelectors.getTransformStack(getState())[0];
+ const {
+ funcNamesDictPerThread: [{ B }],
+ } = getProfileFromTextSamples(`
+ A A
+ B E
+ C C
+ `);
+ // The transform should target B (the right-clicked caller), not C (the root).
+ expect(transform).toMatchObject({ type: 'merge-function', funcIndex: B });
+ });
+
+ it('adds a focus-function transform for the right-clicked node', function () {
+ const { getState } = setup();
+ fireFullClick(screen.getByText(/Focus on function/));
+ expect(
+ selectedThreadSelectors.getTransformStack(getState())[0].type
+ ).toBe('focus-function');
+ });
+ });
+});
diff --git a/src/test/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,
+ };
+ });
+ });
+ }
+});
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`] = `
+
+
+
+`;
diff --git a/src/test/fixtures/utils.ts b/src/test/fixtures/utils.ts
index b57a898e8b..c766fc00d2 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,
@@ -22,6 +24,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';
@@ -281,6 +284,111 @@ 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
+ * 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/store/__snapshots__/profile-view.test.ts.snap b/src/test/store/__snapshots__/profile-view.test.ts.snap
index 409c516cca..02a99ab3e0 100644
--- a/src/test/store/__snapshots__/profile-view.test.ts.snap
+++ b/src/test/store/__snapshots__/profile-view.test.ts.snap
@@ -2350,6 +2350,7 @@ CallTree {
0,
0,
],
+ "flameGraphWidthTotal": 2,
"rootTotalSummary": 2,
"self": Float64Array [
0,
@@ -4420,26 +4421,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,
- "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/bottom-box.test.ts b/src/test/store/bottom-box.test.ts
index 32a6e1c1a4..600c43db74 100644
--- a/src/test/store/bottom-box.test.ts
+++ b/src/test/store/bottom-box.test.ts
@@ -8,7 +8,10 @@ import * as UrlStateSelectors from '../../selectors/url-state';
import * as ProfileSelectors from '../../selectors/profile';
import { selectedThreadSelectors } from '../../selectors/per-thread';
import { emptyAddressTimings } from '../../profile-logic/address-timings';
-import { getBottomBoxInfoForCallNode } from '../../profile-logic/bottom-box';
+import {
+ getBottomBoxInfoForCallNode,
+ getBottomBoxInfoForFunction,
+} from '../../profile-logic/bottom-box';
import {
changeSelectedCallNode,
updateBottomBoxContentsAndMaybeOpen,
@@ -373,3 +376,81 @@ describe('bottom box', function () {
// - A test with multiple threads: Open the assembly view for a symbol, switch
// to a different thread, check timings
});
+
+describe('getBottomBoxInfoForFunction', function () {
+ it('uses the innermost frame when a function appears multiple times due to recursion (A->B->A->B)', function () {
+ // Stack: A[line:20] -> B[line:30] -> A[line:21] -> B[line:31]
+ // B appears at line 30 (outer) and line 31 (inner/leaf).
+ // Double-clicking B in the function list should use line 31 (innermost).
+ const { derivedThreads, funcNamesDictPerThread } =
+ getProfileFromTextSamples(`
+ A[file:a.js][line:20]
+ B[file:b.js][line:30]
+ A[file:a.js][line:21]
+ B[file:b.js][line:31]
+ `);
+ const [thread] = derivedThreads;
+ const [{ B }] = funcNamesDictPerThread;
+
+ const bottomBoxInfo = getBottomBoxInfoForFunction(
+ B,
+ thread,
+ thread.samples
+ );
+
+ // scrollToLineNumber should be 31 (the innermost B), not 30 (the outer B).
+ expect(bottomBoxInfo.scrollToLineNumber).toBe(31);
+ });
+
+ it('uses the innermost frame when a function appears multiple times due to recursion via another function (A->B->C->B->D)', function () {
+ // Stack: A[line:20] -> B[line:30] -> C[line:40] -> B[line:31] -> D[line:50]
+ // B appears at line 30 (outer) and line 31 (inner), with C and D in between.
+ // Double-clicking B in the function list should use line 31 (innermost B).
+ const { derivedThreads, funcNamesDictPerThread } =
+ getProfileFromTextSamples(`
+ A[file:a.js][line:20]
+ B[file:b.js][line:30]
+ C[file:c.js][line:40]
+ B[file:b.js][line:31]
+ D[file:d.js][line:50]
+ `);
+ const [thread] = derivedThreads;
+ const [{ B }] = funcNamesDictPerThread;
+
+ const bottomBoxInfo = getBottomBoxInfoForFunction(
+ B,
+ thread,
+ thread.samples
+ );
+
+ // scrollToLineNumber should be 31 (the innermost B), not 30 (the outer B).
+ expect(bottomBoxInfo.scrollToLineNumber).toBe(31);
+ });
+
+ it('opens the source view when double-clicking a function in the function list', function () {
+ const { profile, derivedThreads, funcNamesDictPerThread } =
+ getProfileFromTextSamples(`
+ A[file:a.js][line:20]
+ B[file:b.js][line:30]
+ `);
+ const { dispatch, getState } = storeWithProfile(profile);
+ const [thread] = derivedThreads;
+ const [{ B }] = funcNamesDictPerThread;
+
+ dispatch(changeSelectedTab('function-list'));
+
+ const bottomBoxInfo = getBottomBoxInfoForFunction(
+ B,
+ thread,
+ thread.samples
+ );
+ dispatch(
+ updateBottomBoxContentsAndMaybeOpen('function-list', bottomBoxInfo)
+ );
+
+ expect(UrlStateSelectors.getIsBottomBoxOpen(getState())).toBeTrue();
+ expect(UrlStateSelectors.getSourceViewScrollToLineNumber(getState())).toBe(
+ 30
+ );
+ });
+});
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/profile-view.test.ts b/src/test/store/profile-view.test.ts
index 127d1bca69..3e364e6772 100644
--- a/src/test/store/profile-view.test.ts
+++ b/src/test/store/profile-view.test.ts
@@ -3626,6 +3626,7 @@ describe('right clicked call node info', () => {
expect(getRightClickedCallNodeInfo(getState())).toEqual({
threadsKey: 0,
+ area: 'NON_INVERTED_TREE',
callNodePath: [0, 1],
});
});
@@ -3637,6 +3638,7 @@ describe('right clicked call node info', () => {
expect(getRightClickedCallNodeInfo(getState())).toEqual({
threadsKey: 0,
+ area: 'NON_INVERTED_TREE',
callNodePath: [0, 1],
});
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-data.test.ts b/src/test/unit/profile-data.test.ts
index 206e8a07b6..228d6d7272 100644
--- a/src/test/unit/profile-data.test.ts
+++ b/src/test/unit/profile-data.test.ts
@@ -19,6 +19,7 @@ import {
getSampleIndexToCallNodeIndex,
getTreeOrderComparator,
getSampleSelectedStates,
+ getSamplesSelectedStatesForFunction,
extractProfileFilterPageData,
findAddressProofForFile,
calculateFunctionSizeLowerBound,
@@ -1234,6 +1235,149 @@ describe('getSampleSelectedStates', function () {
});
});
+describe('getSamplesSelectedStatesForFunction', function () {
+ function setup(textSamples: string) {
+ const {
+ derivedThreads,
+ funcNamesDictPerThread: [funcNamesDict],
+ } = getProfileFromTextSamples(textSamples);
+ const [thread] = derivedThreads;
+ const callNodeInfo = getCallNodeInfo(
+ thread.stackTable,
+ thread.frameTable,
+ 0
+ );
+ const sampleCallNodes = getSampleIndexToCallNodeIndex(
+ thread.samples.stack,
+ callNodeInfo.getStackIndexToNonInvertedCallNodeIndex()
+ );
+ return {
+ callNodeTable: callNodeInfo.getCallNodeTable(),
+ sampleCallNodes,
+ funcNamesDict,
+ };
+ }
+
+ it('marks all non-filtered samples as selected when nothing is selected', function () {
+ const { callNodeTable, sampleCallNodes } = setup(`
+ A A A
+ B C
+ `);
+ expect(
+ Array.from(
+ getSamplesSelectedStatesForFunction(
+ sampleCallNodes,
+ null,
+ callNodeTable
+ )
+ )
+ ).toEqual([
+ SelectedState.Selected,
+ SelectedState.Selected,
+ SelectedState.Selected,
+ ]);
+ });
+
+ it('marks samples as selected when their call stack contains the selected function', function () {
+ // 0 1 2 3 4
+ // A A A A A
+ // B D B D D
+ // C E F G
+ const {
+ callNodeTable,
+ sampleCallNodes,
+ funcNamesDict: { B, D },
+ } = setup(`
+ A A A A A
+ B D B D D
+ C E F G
+ `);
+
+ // Selecting function B: samples 0, 2 have B in their stack
+ expect(
+ Array.from(
+ getSamplesSelectedStatesForFunction(sampleCallNodes, B, callNodeTable)
+ )
+ ).toEqual([
+ SelectedState.Selected,
+ SelectedState.UnselectedOrderedBeforeSelected,
+ SelectedState.Selected,
+ SelectedState.UnselectedOrderedBeforeSelected,
+ SelectedState.UnselectedOrderedBeforeSelected,
+ ]);
+
+ // Selecting function D: samples 1, 3, 4 have D in their stack
+ expect(
+ Array.from(
+ getSamplesSelectedStatesForFunction(sampleCallNodes, D, callNodeTable)
+ )
+ ).toEqual([
+ SelectedState.UnselectedOrderedBeforeSelected,
+ SelectedState.Selected,
+ SelectedState.UnselectedOrderedBeforeSelected,
+ SelectedState.Selected,
+ SelectedState.Selected,
+ ]);
+ });
+
+ it('marks filtered-out samples as FilteredOutByTransform', function () {
+ const {
+ callNodeTable,
+ sampleCallNodes,
+ funcNamesDict: { B },
+ } = setup(`
+ A A A
+ B C
+ `);
+ // Sample 2 has no stack (null), treated as filtered out.
+ // Manually null out sample 2's call node.
+ sampleCallNodes[2] = null;
+
+ expect(
+ Array.from(
+ getSamplesSelectedStatesForFunction(sampleCallNodes, B, callNodeTable)
+ )
+ ).toEqual([
+ SelectedState.Selected,
+ SelectedState.UnselectedOrderedBeforeSelected,
+ SelectedState.FilteredOutByTransform,
+ ]);
+ });
+
+ it('selects samples whose ancestor call node contains the function, not just the leaf', function () {
+ // 0 1
+ // A A
+ // B C
+ // D
+ // Selecting A should match all samples (A is an ancestor of everything).
+ const {
+ callNodeTable,
+ sampleCallNodes,
+ funcNamesDict: { A, B },
+ } = setup(`
+ A A
+ B C
+ D
+ `);
+
+ expect(
+ Array.from(
+ getSamplesSelectedStatesForFunction(sampleCallNodes, A, callNodeTable)
+ )
+ ).toEqual([SelectedState.Selected, SelectedState.Selected]);
+
+ // Selecting B only matches sample 0 (B is in the path A->B->D).
+ expect(
+ Array.from(
+ getSamplesSelectedStatesForFunction(sampleCallNodes, B, callNodeTable)
+ )
+ ).toEqual([
+ SelectedState.Selected,
+ SelectedState.UnselectedOrderedBeforeSelected,
+ ]);
+ });
+});
+
describe('extractProfileFilterPageData', function () {
const pages = {
mozilla: {
diff --git a/src/test/unit/profile-tree.test.ts b/src/test/unit/profile-tree.test.ts
index 90f8a59a91..234560f63c 100644
--- a/src/test/unit/profile-tree.test.ts
+++ b/src/test/unit/profile-tree.test.ts
@@ -11,11 +11,20 @@ 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,
+ computeLowerWingMaxDepthPlusOne,
getCallNodeInfo,
getInvertedCallNodeInfo,
+ getLowerWingCallNodeInfo,
getOriginAnnotationForFunc,
getOriginalPositionForFrame,
filterRawThreadSamplesToRange,
@@ -25,6 +34,8 @@ import { ResourceType } from 'firefox-profiler/types';
import {
callTreeFromProfile,
functionListTreeFromProfile,
+ upperWingTreeFromProfile,
+ lowerWingTreeFromProfile,
formatTree,
formatTreeIncludeCategories,
addSourceToTable,
@@ -85,6 +96,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]),
@@ -138,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.
*/
@@ -331,6 +580,7 @@ describe('unfiltered call tree', function () {
name: 'A',
self: '—',
selfWithUnit: '—',
+ selfPercent: '0%',
total: '3',
totalWithUnit: '3 samples',
totalPercent: '100%',
@@ -347,6 +597,7 @@ describe('unfiltered call tree', function () {
name: 'I',
self: '1',
selfWithUnit: '1 sample',
+ selfPercent: '33%',
total: '1',
totalWithUnit: '1 sample',
totalPercent: '33%',
@@ -578,6 +829,177 @@ 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 = `
+ 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([
diff --git a/src/test/url-handling.test.ts b/src/test/url-handling.test.ts
index 8855e4094f..4c433962ae 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 {
@@ -551,6 +552,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 9b495473f3..58fb75df0b 100644
--- a/src/types/actions.ts
+++ b/src/types/actions.ts
@@ -18,6 +18,7 @@ import type {
RawFrameTable,
SourceLocationTable,
SourceTable,
+ IndexIntoFuncTable,
} from './profile';
import type {
Thread,
@@ -46,6 +47,7 @@ import type {
ApiQueryError,
TableViewOptions,
DecodedInstruction,
+ CallNodeArea,
} from './state';
import type { CssPixels, StartEndRange, Milliseconds } from './units';
import type { BrowserConnectionStatus } from '../app-logic/browser-connection';
@@ -186,12 +188,18 @@ type ProfileAction =
}
| {
readonly type: 'CHANGE_SELECTED_CALL_NODE';
- readonly isInverted: boolean;
+ readonly area: CallNodeArea;
readonly threadsKey: ThreadsKey;
readonly selectedCallNodePath: CallNodePath;
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;
@@ -200,15 +208,21 @@ type ProfileAction =
| {
readonly type: 'CHANGE_RIGHT_CLICKED_CALL_NODE';
readonly threadsKey: ThreadsKey;
+ readonly area: CallNodeArea;
readonly callNodePath: CallNodePath | null;
}
+ | {
+ readonly type: 'CHANGE_RIGHT_CLICKED_FUNCTION';
+ readonly threadsKey: ThreadsKey;
+ readonly functionIndex: IndexIntoFuncTable | null;
+ }
| {
readonly type: 'FOCUS_CALL_TREE';
}
| {
readonly type: 'CHANGE_EXPANDED_CALL_NODES';
readonly threadsKey: ThreadsKey;
- readonly isInverted: boolean;
+ readonly area: CallNodeArea;
readonly expandedCallNodePaths: Array;
}
| {
@@ -564,6 +578,20 @@ 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_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/profile-derived.ts b/src/types/profile-derived.ts
index e5da9bd510..3d56d3bf4a 100644
--- a/src/types/profile-derived.ts
+++ b/src/types/profile-derived.ts
@@ -591,6 +591,7 @@ export type CallNodeDisplayData = Readonly<{
totalPercent: string;
self: string;
selfWithUnit: string;
+ selfPercent: string;
name: string;
lib: string;
isFrameLabel: boolean;
@@ -803,6 +804,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;
};
/**
diff --git a/src/types/state.ts b/src/types/state.ts
index 7ea92a0ba9..ebcdbda96c 100644
--- a/src/types/state.ts
+++ b/src/types/state.ts
@@ -23,6 +23,7 @@ import type {
TabID,
IndexIntoLibs,
IndexIntoSourceTable,
+ IndexIntoFuncTable,
} from './profile';
import type {
@@ -56,11 +57,10 @@ export type SourceMapSymbolicationStatus =
| 'INACTIVE'
| 'FETCHING'
| 'SYMBOLICATING';
+
export type ThreadViewOptions = {
- readonly selectedNonInvertedCallNodePath: CallNodePath;
- readonly selectedInvertedCallNodePath: CallNodePath;
- readonly expandedNonInvertedCallNodePaths: PathSet;
- readonly expandedInvertedCallNodePaths: PathSet;
+ readonly selectedCallNodePaths: Record;
+ readonly expandedCallNodePaths: Record;
readonly selectedNetworkMarker: MarkerIndex | null;
// Track the number of transforms to detect when they change via browser
// navigation. This helps us know when to reset paths that may be invalid
@@ -78,11 +78,28 @@ 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;
};
+export type RightClickedFunction = {
+ readonly threadsKey: ThreadsKey;
+ readonly functionIndex: IndexIntoFuncTable;
+};
+
export type MarkerReference = {
readonly threadsKey: ThreadsKey;
readonly markerIndex: MarkerIndex;
@@ -92,6 +109,10 @@ export type SelectedMarkersPerThread = {
[key: ThreadsKey]: MarkerIndex | null;
};
+export type SelectedFunctionsPerThread = {
+ [key: ThreadsKey]: IndexIntoFuncTable | null;
+};
+
/**
* Profile view state
*/
@@ -108,6 +129,7 @@ export type ProfileViewState = {
lastNonShiftClick: LastNonShiftClickInformation | null;
rightClickedTrack: TrackReference | null;
rightClickedCallNode: RightClickedCallNode | null;
+ rightClickedFunction: RightClickedFunction | null;
rightClickedMarker: MarkerReference | null;
hoveredMarker: MarkerReference | null;
mouseTimePosition: Milliseconds | null;
@@ -385,7 +407,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 = {
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':