diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl
index 9475a20d4c..433dd95056 100644
--- a/locales/en-US/app.ftl
+++ b/locales/en-US/app.ftl
@@ -139,6 +139,14 @@ CallNodeContextMenu--transform-collapse-resource =
.title =
Collapsing a resource will flatten out all the calls to that
resource into a single collapsed call node.
+# This is used as the context menu item to apply the "Collapse source" transform.
+# Variables:
+# $nameForSource (String) - Name of the source file to collapse.
+CallNodeContextMenu--transform-collapse-source =
+ Collapse source { $nameForSource }
+ .title =
+ Collapsing a source file will flatten out all the calls from that
+ source file into a single collapsed call node.
CallNodeContextMenu--transform-collapse-recursion = Collapse recursion
.title =
Collapsing recursion removes calls that repeatedly recurse into
@@ -1155,6 +1163,11 @@ TransformNavigator--complete = Complete “{ $item }”
# $item (String) - Name of the resource that collapsed. E.g.: libxul.so.
TransformNavigator--collapse-resource = Collapse: { $item }
+# "Collapse source" transform.
+# Variables:
+# $item (String) - Name of the source file that was collapsed. E.g.: foo.js.
+TransformNavigator--collapse-source = Collapse source: { $item }
+
# "Focus subtree" transform.
# See: https://profiler.firefox.com/docs/#/./guide-filtering-call-trees?id=focus
# Variables:
diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts
index a7bf7ddc39..8a9c0123f4 100644
--- a/src/actions/profile-view.ts
+++ b/src/actions/profile-view.ts
@@ -16,6 +16,7 @@ import {
getThreads,
getLastNonShiftClick,
getReservedFunctionsForResources,
+ getReservedFunctionsForSources,
} from 'firefox-profiler/selectors/profile';
import {
getThreadSelectors,
@@ -63,6 +64,7 @@ import type {
CallNodePath,
IndexIntoCallNodeTable,
IndexIntoResourceTable,
+ IndexIntoSourceTable,
TrackIndex,
MarkerIndex,
Transform,
@@ -1847,6 +1849,29 @@ export function addCollapseResourceTransformToStack(
};
}
+export function addCollapseSourceTransformToStack(
+ threadsKey: ThreadsKey,
+ sourceIndex: IndexIntoSourceTable,
+ implementation: ImplementationFilter
+): ThunkAction {
+ return (dispatch, getState) => {
+ const reservedFunctionsForSources =
+ getReservedFunctionsForSources(getState());
+ const collapsedFuncIndex = ensureExists(
+ ensureExists(reservedFunctionsForSources).get(sourceIndex)
+ );
+
+ dispatch(
+ addTransformToStack(threadsKey, {
+ type: 'collapse-source',
+ sourceIndex,
+ collapsedFuncIndex,
+ implementation,
+ })
+ );
+ };
+}
+
export function popTransformsFromStack(
firstPoppedFilterIndex: number
): ThunkAction {
@@ -2103,6 +2128,20 @@ export function handleCallNodeTransformShortcut(
})
);
break;
+ case 'X': {
+ const { funcTable } = unfilteredThread;
+ const sourceIndex = funcTable.source[funcIndex];
+ if (sourceIndex !== null) {
+ dispatch(
+ addCollapseSourceTransformToStack(
+ threadsKey,
+ sourceIndex,
+ implementation
+ )
+ );
+ }
+ break;
+ }
default:
// This did not match a call tree transform.
}
diff --git a/src/app-logic/url-handling.ts b/src/app-logic/url-handling.ts
index eecd06a2be..b2383c87cf 100644
--- a/src/app-logic/url-handling.ts
+++ b/src/app-logic/url-handling.ts
@@ -53,7 +53,7 @@ import { StringTable } from 'firefox-profiler/utils/string-table';
import type { ProfileUpgradeInfo } from 'firefox-profiler/profile-logic/processed-profile-versioning';
import type { ProfileAndProfileUpgradeInfo } from 'firefox-profiler/actions/receive-profile';
-export const CURRENT_URL_VERSION = 15;
+export const CURRENT_URL_VERSION = 16;
/**
* This static piece of state might look like an anti-pattern, but it's a relatively
@@ -1351,6 +1351,11 @@ const _upgraders: {
.map(mapIndexesInTransform)
.join('~');
},
+ [16]: (_processedLocation: ProcessedLocationBeforeUpgrade) => {
+ // Version 16 introduced the 'collapse-source' transform ('cs' short key).
+ // No URL structure changes are needed; this version bump ensures older
+ // versions of the profiler do not silently ignore the new transform.
+ },
};
for (let destVersion = 1; destVersion <= CURRENT_URL_VERSION; destVersion++) {
diff --git a/src/components/shared/CallNodeContextMenu.tsx b/src/components/shared/CallNodeContextMenu.tsx
index 1a360f01c6..4e06e17c68 100644
--- a/src/components/shared/CallNodeContextMenu.tsx
+++ b/src/components/shared/CallNodeContextMenu.tsx
@@ -21,6 +21,7 @@ import copy from 'copy-to-clipboard';
import {
addTransformToStack,
addCollapseResourceTransformToStack,
+ addCollapseSourceTransformToStack,
expandAllCallNodeDescendants,
updateBottomBoxContentsAndMaybeOpen,
setContextMenuVisibility,
@@ -80,6 +81,7 @@ type StateProps = {
type DispatchProps = {
readonly addTransformToStack: typeof addTransformToStack;
readonly addCollapseResourceTransformToStack: typeof addCollapseResourceTransformToStack;
+ readonly addCollapseSourceTransformToStack: typeof addCollapseSourceTransformToStack;
readonly expandAllCallNodeDescendants: typeof expandAllCallNodeDescendants;
readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen;
readonly setContextMenuVisibility: typeof setContextMenuVisibility;
@@ -326,6 +328,7 @@ class CallNodeContextMenuImpl extends React.PureComponent {
const {
addTransformToStack,
addCollapseResourceTransformToStack,
+ addCollapseSourceTransformToStack,
implementation,
inverted,
} = this.props;
@@ -393,6 +396,21 @@ class CallNodeContextMenuImpl extends React.PureComponent {
);
break;
}
+ case 'collapse-source': {
+ const { funcTable } = thread;
+ const sourceIndex = funcTable.source[selectedFunc];
+ if (sourceIndex === null) {
+ throw new Error(
+ 'collapse-source was triggered on a function without a source'
+ );
+ }
+ addCollapseSourceTransformToStack(
+ threadsKey,
+ sourceIndex,
+ implementation
+ );
+ break;
+ }
case 'collapse-direct-recursion': {
addTransformToStack(threadsKey, {
type: 'collapse-direct-recursion',
@@ -539,6 +557,36 @@ class CallNodeContextMenuImpl extends React.PureComponent {
return stringTable.getString(resNameStringIndex);
}
+ getNameForSelectedSource(): string | null {
+ const rightClickedCallNodeInfo = this.getRightClickedCallNodeInfo();
+
+ if (rightClickedCallNodeInfo === null) {
+ throw new Error(
+ "The context menu assumes there is a selected call node and there wasn't one."
+ );
+ }
+
+ const {
+ callNodeInfo,
+ callNodeIndex,
+ thread: { funcTable, stringTable, sources },
+ } = rightClickedCallNodeInfo;
+
+ const funcIndex = callNodeInfo.funcForNode(callNodeIndex);
+ if (funcIndex === undefined) {
+ return null;
+ }
+ if (!funcTable.isJS[funcIndex]) {
+ return null;
+ }
+ const sourceIndex = funcTable.source[funcIndex];
+ if (sourceIndex === null) {
+ return null;
+ }
+ const fileNameIndex = sources.filename[sourceIndex];
+ return stringTable.getString(fileNameIndex);
+ }
+
getRightClickedCallNodeInfo(): null | {
readonly thread: Thread;
readonly threadsKey: ThreadsKey;
@@ -602,6 +650,7 @@ class CallNodeContextMenuImpl extends React.PureComponent {
const hasCategory = categoryIndex !== -1;
// This could be the C++ library, or the JS filename.
const nameForResource = this.getNameForSelectedResource();
+ const nameForSource = this.getNameForSelectedSource();
const categoryName: string = hasCategory
? categories[categoryIndex].name
: '';
@@ -740,6 +789,22 @@ class CallNodeContextMenuImpl extends React.PureComponent {
})
: null}
+ {nameForSource
+ ? this.renderTransformMenuItem({
+ l10nId: 'CallNodeContextMenu--transform-collapse-source',
+ l10nProps: {
+ vars: { nameForSource: nameForSource },
+ elems: { strong: },
+ },
+ shortcut: 'X',
+ icon: 'Collapse',
+ onClick: this._handleClick,
+ transform: 'collapse-source',
+ title: '',
+ content: `Collapse source ${nameForSource}`,
+ })
+ : null}
+
{funcHasRecursiveCall(callNodeTable, funcIndex)
? this.renderTransformMenuItem({
l10nId: 'CallNodeContextMenu--transform-collapse-recursion',
@@ -951,6 +1016,7 @@ export const CallNodeContextMenu = explicitConnect<
mapDispatchToProps: {
addTransformToStack,
addCollapseResourceTransformToStack,
+ addCollapseSourceTransformToStack,
expandAllCallNodeDescendants,
updateBottomBoxContentsAndMaybeOpen,
setContextMenuVisibility,
diff --git a/src/profile-logic/index-translation.ts b/src/profile-logic/index-translation.ts
index 45b1fff56a..50b1f2a3c9 100644
--- a/src/profile-logic/index-translation.ts
+++ b/src/profile-logic/index-translation.ts
@@ -6,6 +6,7 @@ import type {
CallNodePath,
IndexIntoFuncTable,
IndexIntoResourceTable,
+ IndexIntoSourceTable,
ProfileIndexTranslationMaps,
} from 'firefox-profiler/types';
@@ -21,9 +22,20 @@ export function translateResourceIndex(
return newResourceIndexPlusOne !== 0 ? newResourceIndexPlusOne - 1 : null;
}
+// Returns the new source index for the given old source index.
+// Returns null if the index has no new index equivalent.
+export function translateSourceIndex(
+ sourceIndex: IndexIntoSourceTable,
+ translationMaps: ProfileIndexTranslationMaps
+): IndexIntoSourceTable | null {
+ const newSourceIndexPlusOne =
+ translationMaps.oldSourceToNewSourcePlusOne[sourceIndex];
+ return newSourceIndexPlusOne !== 0 ? newSourceIndexPlusOne - 1 : null;
+}
+
// Returns the new func index for the given old func index.
-// This handles indexes for "reserved funcs" for collapsed resources, which
-// are located after the regular funcTable.
+// This handles indexes for "reserved funcs" for collapsed resources and
+// collapsed sources, which are located after the regular funcTable.
// Returns null if the index has no new index equivalent.
export function translateFuncIndex(
funcIndex: IndexIntoFuncTable,
@@ -35,14 +47,26 @@ export function translateFuncIndex(
translationMaps.oldFuncToNewFuncPlusOne[funcIndex];
return newFuncIndexPlusOne !== 0 ? newFuncIndexPlusOne - 1 : null;
}
- // This must be a funcIndex from the "func table with reserved functions for collapsed resources".
- const resourceIndex = funcIndex - oldFuncCount;
- const newResourceIndex = translateResourceIndex(
- resourceIndex,
- translationMaps
- );
- return newResourceIndex !== null
- ? translationMaps.newFuncCount + newResourceIndex
+ const reservedOffset = funcIndex - oldFuncCount;
+ const oldResourceCount = translationMaps.oldResourceCount;
+ if (reservedOffset < oldResourceCount) {
+ // This is a reserved func for a collapsed resource.
+ const resourceIndex = reservedOffset;
+ const newResourceIndex = translateResourceIndex(
+ resourceIndex,
+ translationMaps
+ );
+ return newResourceIndex !== null
+ ? translationMaps.newFuncCount + newResourceIndex
+ : null;
+ }
+ // This is a reserved func for a collapsed source.
+ const sourceIndex = reservedOffset - oldResourceCount;
+ const newSourceIndex = translateSourceIndex(sourceIndex, translationMaps);
+ return newSourceIndex !== null
+ ? translationMaps.newFuncCount +
+ translationMaps.newResourceCount +
+ newSourceIndex
: null;
}
diff --git a/src/profile-logic/merge-compare.ts b/src/profile-logic/merge-compare.ts
index 269fc4fc46..c123ab5fa9 100644
--- a/src/profile-logic/merge-compare.ts
+++ b/src/profile-logic/merge-compare.ts
@@ -503,6 +503,8 @@ export function mergeSharedData(profiles: Profile[]): {
oldThreadIndexToNew: null,
oldFuncCount: profile.shared.funcTable.length,
newFuncCount: newFuncTable.length,
+ oldResourceCount: profile.shared.resourceTable.length,
+ newResourceCount: newResourceTable.length,
oldLibToNewLibPlusOne,
oldStringToNewStringPlusOne,
oldSourceToNewSourcePlusOne,
diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts
index 645d8a9cc8..d150321b0a 100644
--- a/src/profile-logic/profile-data.ts
+++ b/src/profile-logic/profile-data.ts
@@ -3281,18 +3281,27 @@ export function getOriginAnnotationForFunc(
*
* This returns a new thread with an extended funcTable.
*
- * At the moment, the only functions we reserve are "collapsed resource" functions.
- * These are used by the "collapse resource" transform.
+ * We reserve two sets of functions:
+ * - "collapsed resource" functions (used by the "collapse-resource" transform)
+ * - "collapsed source" functions (used by the "collapse-source" transform)
+ *
+ * Resource reserved funcs occupy indices [funcCount, funcCount + resourceCount).
+ * Source reserved funcs occupy indices [funcCount + resourceCount, funcCount + resourceCount + sourceCount).
*/
export function reserveFunctionsForCollapsedResources(
originalFuncTable: FuncTable,
- resourceTable: ResourceTable
+ resourceTable: ResourceTable,
+ sourceTable: SourceTable
): FuncTableWithReservedFunctions {
const funcTable = shallowCloneFuncTable(originalFuncTable);
const reservedFunctionsForResources = new Map<
IndexIntoResourceTable,
IndexIntoFuncTable
>();
+ const reservedFunctionsForSources = new Map<
+ IndexIntoSourceTable,
+ IndexIntoFuncTable
+ >();
const jsResourceTypes = [
ResourceType.Addon,
ResourceType.Url,
@@ -3318,9 +3327,23 @@ export function reserveFunctionsForCollapsedResources(
funcTable.length++;
reservedFunctionsForResources.set(resourceIndex, funcIndex);
}
+ for (let sourceIndex = 0; sourceIndex < sourceTable.length; sourceIndex++) {
+ const name = sourceTable.filename[sourceIndex];
+ const funcIndex = funcTable.length;
+ funcTable.isJS.push(true);
+ funcTable.relevantForJS.push(true);
+ funcTable.name.push(name);
+ funcTable.resource.push(-1);
+ funcTable.source.push(sourceIndex);
+ funcTable.lineNumber.push(null);
+ funcTable.columnNumber.push(null);
+ funcTable.length++;
+ reservedFunctionsForSources.set(sourceIndex, funcIndex);
+ }
return {
funcTable,
reservedFunctionsForResources,
+ reservedFunctionsForSources,
};
}
diff --git a/src/profile-logic/sanitize.ts b/src/profile-logic/sanitize.ts
index dec2160f55..47f2334162 100644
--- a/src/profile-logic/sanitize.ts
+++ b/src/profile-logic/sanitize.ts
@@ -390,6 +390,9 @@ export function sanitizePII(
oldFuncCount: profile.shared.funcTable.length,
newFuncCount:
compactedProfileWithTranslationMaps.profile.shared.funcTable.length,
+ oldResourceCount: profile.shared.resourceTable.length,
+ newResourceCount:
+ compactedProfileWithTranslationMaps.profile.shared.resourceTable.length,
...compactedProfileWithTranslationMaps.translationMaps,
},
};
diff --git a/src/profile-logic/transforms.ts b/src/profile-logic/transforms.ts
index 05ba86b2bc..65da62cf96 100644
--- a/src/profile-logic/transforms.ts
+++ b/src/profile-logic/transforms.ts
@@ -31,6 +31,7 @@ import type {
IndexIntoFuncTable,
IndexIntoStackTable,
IndexIntoResourceTable,
+ IndexIntoSourceTable,
CallNodePath,
CallNodeTable,
StackType,
@@ -54,6 +55,7 @@ import {
translateCallNodePath,
translateFuncIndex,
translateResourceIndex,
+ translateSourceIndex,
} from './index-translation';
import { checkBit, makeBitSet, setBit } from 'firefox-profiler/utils/bitset';
@@ -70,6 +72,7 @@ const TRANSFORM_OBJ: { [key in TransformType]: true } = {
'merge-function': true,
'drop-function': true,
'collapse-resource': true,
+ 'collapse-source': true,
'collapse-direct-recursion': true,
'collapse-recursion': true,
'collapse-function-subtree': true,
@@ -112,6 +115,9 @@ ALL_TRANSFORM_TYPES.forEach((transform: TransformType) => {
case 'collapse-resource':
shortKey = 'cr';
break;
+ case 'collapse-source':
+ shortKey = 'cs';
+ break;
case 'collapse-direct-recursion':
shortKey = 'drec';
break;
@@ -183,6 +189,25 @@ export function parseTransforms(transformString: string): TransformStack {
break;
}
+ case 'collapse-source': {
+ // e.g. "cs-js-325-8"
+ const [, implementation, sourceIndexRaw, collapsedFuncIndexRaw] = tuple;
+ const sourceIndex = parseInt(sourceIndexRaw, 10);
+ const collapsedFuncIndex = parseInt(collapsedFuncIndexRaw, 10);
+ if (isNaN(sourceIndex) || isNaN(collapsedFuncIndex)) {
+ break;
+ }
+ if (sourceIndex >= 0) {
+ transforms.push({
+ type,
+ sourceIndex,
+ collapsedFuncIndex,
+ implementation: toValidImplementationFilter(implementation),
+ });
+ }
+
+ break;
+ }
case 'collapse-recursion': {
// e.g. "rec-325"
const [, funcIndexRaw] = tuple;
@@ -381,6 +406,8 @@ export function stringifyTransforms(transformStack: TransformStack): string {
return `${shortKey}-${transform.category}`;
case 'collapse-resource':
return `${shortKey}-${transform.implementation}-${transform.resourceIndex}-${transform.collapsedFuncIndex}`;
+ case 'collapse-source':
+ return `${shortKey}-${transform.implementation}-${transform.sourceIndex}-${transform.collapsedFuncIndex}`;
case 'collapse-recursion':
return `${shortKey}-${transform.funcIndex}`;
case 'collapse-direct-recursion':
@@ -426,7 +453,7 @@ export function getTransformLabelL10nIds(
threadName: string,
transforms: Transform[]
): Array {
- const { funcTable, stringTable, resourceTable } = thread;
+ const { funcTable, stringTable, resourceTable, sources } = thread;
const { categories } = meta;
const labels: TransformLabeL10nIds[] = transforms.map((transform) => {
// Lookup library information.
@@ -439,6 +466,15 @@ export function getTransformLabelL10nIds(
};
}
+ if (transform.type === 'collapse-source') {
+ const nameIndex = sources.filename[transform.sourceIndex];
+ const sourceName = stringTable.getString(nameIndex);
+ return {
+ l10nId: 'TransformNavigator--collapse-source',
+ item: sourceName,
+ };
+ }
+
if (transform.type === 'focus-category') {
if (categories === undefined) {
throw new Error('Expected categories to be defined.');
@@ -564,6 +600,13 @@ export function applyTransformToCallNodePath(
transformedThread.funcTable,
callNodePath
);
+ case 'collapse-source':
+ return _collapseSourceInCallNodePath(
+ transform.sourceIndex,
+ transform.collapsedFuncIndex,
+ transformedThread.funcTable,
+ callNodePath
+ );
case 'collapse-direct-recursion':
return _collapseDirectRecursionInCallNodePath(
transform.funcIndex,
@@ -698,6 +741,32 @@ function _collapseResourceInCallNodePath(
);
}
+function _collapseSourceInCallNodePath(
+ sourceIndex: IndexIntoSourceTable,
+ collapsedFuncIndex: IndexIntoFuncTable,
+ funcTable: FuncTable,
+ callNodePath: CallNodePath
+) {
+ return (
+ callNodePath
+ // Map any collapsed functions into the collapsedFuncIndex
+ .map((pathFuncIndex) => {
+ return funcTable.source[pathFuncIndex] === sourceIndex
+ ? collapsedFuncIndex
+ : pathFuncIndex;
+ })
+ // De-duplicate contiguous collapsed funcs
+ .filter(
+ (pathFuncIndex, pathIndex, path) =>
+ // This function doesn't match the previous one, so keep it.
+ pathFuncIndex !== path[pathIndex - 1] ||
+ // This function matched the previous, only keep it if doesn't match the
+ // collapsed func.
+ pathFuncIndex !== collapsedFuncIndex
+ )
+ );
+}
+
function _collapseDirectRecursionInCallNodePath(
funcIndex: IndexIntoFuncTable,
callNodePath: CallNodePath
@@ -973,6 +1042,46 @@ export function collapseResource(
return collapseDirectRecursion(newThread, collapsedFuncIndex, implementation);
}
+/**
+ * Substitute any functions from a given source file with the source's
+ * "collapsed source function", and then collapse consecutive frames with that
+ * function into a single frame.
+ *
+ * This is the source-based counterpart of collapseResource, using the source
+ * table (specific JS file paths) instead of the resource table (script origins).
+ */
+export function collapseSource(
+ thread: Thread,
+ sourceIndexToCollapse: IndexIntoSourceTable,
+ collapsedFuncIndex: IndexIntoFuncTable,
+ implementation: ImplementationFilter
+): Thread {
+ // Strategy: remap all frames from the given source to collapsedFuncIndex,
+ // then delegate to collapseDirectRecursion to merge consecutive frames with
+ // that func into one.
+ const { funcTable, frameTable } = thread;
+
+ // Remap every frame whose func belongs to the collapsed source.
+ const newFrameTableFuncCol = frameTable.func.slice();
+ for (let i = 0; i < frameTable.length; i++) {
+ const funcIndex = frameTable.func[i];
+ const sourceIndex = funcTable.source[funcIndex];
+ if (sourceIndex === sourceIndexToCollapse) {
+ newFrameTableFuncCol[i] = collapsedFuncIndex;
+ }
+ }
+
+ const newThread = {
+ ...thread,
+ frameTable: {
+ ...frameTable,
+ func: newFrameTableFuncCol,
+ },
+ };
+
+ return collapseDirectRecursion(newThread, collapsedFuncIndex, implementation);
+}
+
export function collapseDirectRecursion(
thread: Thread,
funcToCollapse: IndexIntoFuncTable,
@@ -1841,6 +1950,13 @@ export function applyTransform(
transform.collapsedFuncIndex,
transform.implementation
);
+ case 'collapse-source':
+ return collapseSource(
+ thread,
+ transform.sourceIndex,
+ transform.collapsedFuncIndex,
+ transform.implementation
+ );
case 'collapse-direct-recursion':
return collapseDirectRecursion(
thread,
@@ -2004,6 +2120,30 @@ export function translateTransform(
collapsedFuncIndex: newCollapsedFuncIndex,
};
}
+ case 'collapse-source': {
+ const newSourceIndex = translateSourceIndex(
+ transform.sourceIndex,
+ translationMaps
+ );
+ if (newSourceIndex === null) {
+ // If the collapsed source is missing, that means we don't have any
+ // samples in the sanitized thread which contain any function with this
+ // source in their stack, which means that this transform was a no-op
+ // in the range filtered thread.
+ // We can just drop this transform.
+ return null;
+ }
+ const newCollapsedFuncIndex =
+ translationMaps.newFuncCount +
+ translationMaps.newResourceCount +
+ newSourceIndex;
+ return {
+ type,
+ sourceIndex: newSourceIndex,
+ implementation: transform.implementation,
+ collapsedFuncIndex: newCollapsedFuncIndex,
+ };
+ }
case 'collapse-direct-recursion': {
const newFuncIndex = translateFuncIndex(
transform.funcIndex,
diff --git a/src/selectors/profile.ts b/src/selectors/profile.ts
index 7ac0ef4f2c..81c92e6e4e 100644
--- a/src/selectors/profile.ts
+++ b/src/selectors/profile.ts
@@ -77,6 +77,7 @@ import type {
SourceTable,
FuncTableWithReservedFunctions,
IndexIntoResourceTable,
+ IndexIntoSourceTable,
IndexIntoFuncTable,
FuncTable,
} from 'firefox-profiler/types';
@@ -267,6 +268,7 @@ export const getFuncTableWithReservedFunctions: Selector getRawProfileSharedData(state).funcTable,
(state: State) => getRawProfileSharedData(state).resourceTable,
+ (state: State) => getRawProfileSharedData(state).sources,
reserveFunctionsForCollapsedResources
);
@@ -278,6 +280,11 @@ export const getReservedFunctionsForResources: Selector<
> = (state) =>
getFuncTableWithReservedFunctions(state).reservedFunctionsForResources;
+export const getReservedFunctionsForSources: Selector<
+ Map
+> = (state) =>
+ getFuncTableWithReservedFunctions(state).reservedFunctionsForSources;
+
export const getSourceTable: Selector = (state: State) =>
getRawProfileSharedData(state).sources;
diff --git a/src/test/components/CallNodeContextMenu.test.tsx b/src/test/components/CallNodeContextMenu.test.tsx
index 0e032e73cf..8b598086bb 100644
--- a/src/test/components/CallNodeContextMenu.test.tsx
+++ b/src/test/components/CallNodeContextMenu.test.tsx
@@ -148,6 +148,14 @@ describe('calltree/CallNodeContextMenu', function () {
).toBe(type);
});
});
+
+ it('adds a transform for "collapse-source"', function () {
+ const { getState } = setup(createStoreWithJsCallStack());
+ fireFullClick(screen.getByText(/Collapse source/));
+ expect(
+ selectedThreadSelectors.getTransformStack(getState())[0].type
+ ).toBe('collapse-source');
+ });
});
describe('clicking on the rest of the menu items', function () {
diff --git a/src/test/components/TransformShortcuts.test.tsx b/src/test/components/TransformShortcuts.test.tsx
index aefbcfbd4a..8f76ac2af7 100644
--- a/src/test/components/TransformShortcuts.test.tsx
+++ b/src/test/components/TransformShortcuts.test.tsx
@@ -12,10 +12,10 @@ import {
changeSelectedCallNode,
changeRightClickedCallNode,
} from '../../actions/profile-view';
+import { addSourceToTable, fireFullKeyPress } from '../fixtures/utils';
import { FlameGraph } from '../../components/flame-graph';
import { selectedThreadSelectors } from 'firefox-profiler/selectors';
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 { StackChart } from 'firefox-profiler/components/stack-chart';
@@ -326,3 +326,73 @@ describe('stack chart transform shortcuts', () => {
});
}
});
+
+describe('collapse-source shortcut (X)', () => {
+ function setupWithJsSource() {
+ const {
+ profile,
+ stringTable,
+ funcNamesPerThread: [funcNames],
+ } = getProfileFromTextSamples(`
+ A.js
+ B.js
+ `);
+ const fileNameIndex = stringTable.indexForString(
+ 'https://example.com/script.js'
+ );
+ const sourceIndex = addSourceToTable(profile.shared.sources, fileNameIndex);
+
+ const { funcTable } = profile.shared;
+ const funcIndexB = funcNames.indexOf('B.js');
+ funcTable.source[funcIndexB] = sourceIndex;
+
+ const store = storeWithProfile(profile);
+ const { getState } = store;
+
+ render(
+
+
+
+ );
+
+ act(() => {
+ store.dispatch(
+ changeSelectedCallNode(0, [funcNames.indexOf('A.js'), funcIndexB])
+ );
+ });
+
+ return {
+ store,
+ sourceIndex,
+ funcIndexB,
+ getTransform: () => {
+ const stack = selectedThreadSelectors.getTransformStack(getState());
+ return stack.length === 1 ? stack[0] : null;
+ },
+ pressKey: pressKeyBuilder('treeViewBody'),
+ };
+ }
+
+ it('handles collapse source', () => {
+ const { pressKey, getTransform, sourceIndex } = setupWithJsSource();
+ pressKey({ key: 'X' });
+ expect(getTransform()).toMatchObject({
+ type: 'collapse-source',
+ sourceIndex,
+ });
+ });
+
+ it('ignores X when no source is set', () => {
+ // setupStore creates a profile where functions have no source entries
+ const { store, funcNames, getTransform } = setupStore(
+
+ );
+ const { A, B } = funcNames;
+ act(() => {
+ store.dispatch(changeSelectedCallNode(0, [A, B]));
+ });
+ const pressKey = pressKeyBuilder('treeViewBody');
+ pressKey({ key: 'X' });
+ expect(getTransform()).toBeNull();
+ });
+});
diff --git a/src/test/store/transforms.test.ts b/src/test/store/transforms.test.ts
index 0773185967..6d79e040fa 100644
--- a/src/test/store/transforms.test.ts
+++ b/src/test/store/transforms.test.ts
@@ -8,7 +8,7 @@ import {
getProfileWithJsAllocations,
addMarkersToThreadWithCorrespondingSamples,
} from '../fixtures/profiles/processed-profile';
-import { formatTree } from '../fixtures/utils';
+import { formatTree, addSourceToTable } from '../fixtures/utils';
import { storeWithProfile } from '../fixtures/stores';
import { assertSetContainsOnly } from '../fixtures/custom-assertions';
import {
@@ -19,6 +19,7 @@ import {
import {
addTransformToStack,
addCollapseResourceTransformToStack,
+ addCollapseSourceTransformToStack,
popTransformsFromStack,
changeInvertCallstack,
changeImplementationFilter,
@@ -1231,6 +1232,85 @@ describe('"collapse-resource" transform', function () {
});
});
+describe('"collapse-source" transform', function () {
+ /**
+ * A A
+ * -----´ `----- |
+ * / \ v
+ * v v Collapse foo.js foo.js
+ * B[src:foo.js] E[src:foo.js] -> / \
+ * | | D F
+ * v v
+ * C[src:foo.js] F
+ * |
+ * v
+ * D
+ */
+ const {
+ profile,
+ stringTable,
+ funcNamesPerThread: [funcNames],
+ } = getProfileFromTextSamples(`
+ A A
+ B E
+ C F
+ D
+ `);
+ const fooUrlIndex = stringTable.indexForString('foo.js');
+ const fooSourceIndex = addSourceToTable(profile.shared.sources, fooUrlIndex);
+ const { funcTable } = profile.shared;
+ funcTable.source[funcNames.indexOf('B')] = fooSourceIndex;
+ funcTable.source[funcNames.indexOf('C')] = fooSourceIndex;
+ funcTable.source[funcNames.indexOf('E')] = fooSourceIndex;
+ const collapsedFuncNames = [...funcNames, 'foo.js'];
+ const threadIndex = 0;
+
+ it('starts as an unfiltered call tree', function () {
+ const { getState } = storeWithProfile(profile);
+ expect(formatTree(selectedThreadSelectors.getCallTree(getState()))).toEqual(
+ [
+ '- A (total: 2, self: —)',
+ ' - B (total: 1, self: —)',
+ ' - C (total: 1, self: —)',
+ ' - D (total: 1, self: 1)',
+ ' - E (total: 1, self: —)',
+ ' - F (total: 1, self: 1)',
+ ]
+ );
+ });
+
+ it('can collapse functions from "foo.js"', function () {
+ const { dispatch, getState } = storeWithProfile(profile);
+ dispatch(
+ addCollapseSourceTransformToStack(threadIndex, fooSourceIndex, 'combined')
+ );
+ expect(formatTree(selectedThreadSelectors.getCallTree(getState()))).toEqual(
+ [
+ '- A (total: 2, self: —)',
+ ' - foo.js (total: 2, self: —)',
+ ' - D (total: 1, self: 1)',
+ ' - F (total: 1, self: 1)',
+ ]
+ );
+ });
+
+ it('can apply the transform to the selected CallNodePaths', function () {
+ const { dispatch, getState } = storeWithProfile(profile);
+ dispatch(
+ changeSelectedCallNode(
+ threadIndex,
+ ['A', 'B', 'C', 'D'].map((name) => collapsedFuncNames.indexOf(name))
+ )
+ );
+ dispatch(
+ addCollapseSourceTransformToStack(threadIndex, fooSourceIndex, 'combined')
+ );
+ expect(selectedThreadSelectors.getSelectedCallNodePath(getState())).toEqual(
+ ['A', 'foo.js', 'D'].map((name) => collapsedFuncNames.indexOf(name))
+ );
+ });
+});
+
describe('"collapse-function-subtree" transform', function () {
/**
* A:4,0 A:4,0
diff --git a/src/test/url-handling.test.ts b/src/test/url-handling.test.ts
index 88ff994ee0..770e1675de 100644
--- a/src/test/url-handling.test.ts
+++ b/src/test/url-handling.test.ts
@@ -1309,7 +1309,7 @@ describe('url upgrading', function () {
describe('URL serialization of the transform stack', function () {
const transformString =
'f-combined-0w2~mcn-combined-2w4~f-js-3w5-i~mf-6~ff-7~fg-42~cr-combined-8-9~' +
- 'drec-combined-10~rec-11~df-12~cfs-13';
+ 'drec-combined-10~rec-11~df-12~cfs-13~cs-combined-14-15';
const { getState } = _getStoreWithURL({
search: '?transforms=' + transformString,
});
@@ -1372,6 +1372,12 @@ describe('URL serialization of the transform stack', function () {
type: 'collapse-function-subtree',
funcIndex: 13,
},
+ {
+ type: 'collapse-source',
+ sourceIndex: 14,
+ collapsedFuncIndex: 15,
+ implementation: 'combined',
+ },
]);
});
diff --git a/src/types/profile-derived.ts b/src/types/profile-derived.ts
index f52454fd9e..82d0ef577e 100644
--- a/src/types/profile-derived.ts
+++ b/src/types/profile-derived.ts
@@ -531,6 +531,7 @@ export type FuncTableWithReservedFunctions = {
IndexIntoResourceTable,
IndexIntoFuncTable
>;
+ reservedFunctionsForSources: Map;
};
/**
@@ -814,6 +815,8 @@ export type ProfileIndexTranslationMaps = {
oldThreadIndexToNew: Map | null;
oldFuncCount: number;
newFuncCount: number;
+ oldResourceCount: number;
+ newResourceCount: number;
oldStackToNewStackPlusOne: Int32Array;
oldFrameToNewFramePlusOne: Int32Array;
oldFuncToNewFuncPlusOne: Int32Array;
diff --git a/src/types/transforms.ts b/src/types/transforms.ts
index 0837ced0d7..8c63d56f46 100644
--- a/src/types/transforms.ts
+++ b/src/types/transforms.ts
@@ -17,6 +17,7 @@
import type {
IndexIntoFuncTable,
IndexIntoResourceTable,
+ IndexIntoSourceTable,
IndexIntoCategoryList,
} from './profile';
import type { CallNodePath, ThreadsKey } from './profile-derived';
@@ -281,6 +282,31 @@ export type TransformDefinitions = {
readonly implementation: ImplementationFilter;
};
+ /**
+ * Collapse source takes CallNodes that are from a consecutive source file, and
+ * collapses them into a new collapsed pseudo-stack. This is the same operation
+ * as collapse-resource, but it uses the source table (specific JS file paths)
+ * instead of the resource table (script origins/hosts).
+ *
+ * A A
+ * / \ |
+ * v v Collapse foo.js v
+ * B:foo.js E:foo.js -> foo.js
+ * | | / \
+ * v v D F
+ * C:foo.js F
+ * |
+ * v
+ * D
+ */
+ 'collapse-source': {
+ readonly type: 'collapse-source';
+ readonly sourceIndex: IndexIntoSourceTable;
+ // This is the index of the newly created function that represents the collapsed stack.
+ readonly collapsedFuncIndex: IndexIntoFuncTable;
+ readonly implementation: ImplementationFilter;
+ };
+
/**
* Collapse direct recursion takes a function that calls itself recursively and collapses
* it into a single stack.
diff --git a/src/utils/types.ts b/src/utils/types.ts
index 1d6df05f5e..5a89891020 100644
--- a/src/utils/types.ts
+++ b/src/utils/types.ts
@@ -88,6 +88,7 @@ export function convertToTransformType(type: string): TransformType | null {
case 'focus-category':
case 'focus-self':
case 'collapse-resource':
+ case 'collapse-source':
case 'collapse-direct-recursion':
case 'collapse-recursion':
case 'collapse-function-subtree':