From c0e51317c59f7428688f2e3ae3b31485f3893b9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Fri, 10 Apr 2026 14:30:31 +0200 Subject: [PATCH] Add "Collapse source" transform This is going to be very useful when we have the source map symbolication support. This way, we will be able to callapse things like react.min.js that is polluting the call tree otherwise. --- locales/en-US/app.ftl | 13 ++ src/actions/profile-view.ts | 39 +++++ src/app-logic/url-handling.ts | 7 +- src/components/shared/CallNodeContextMenu.tsx | 66 ++++++++ src/profile-logic/index-translation.ts | 44 ++++-- src/profile-logic/merge-compare.ts | 2 + src/profile-logic/profile-data.ts | 29 +++- src/profile-logic/sanitize.ts | 3 + src/profile-logic/transforms.ts | 142 +++++++++++++++++- src/selectors/profile.ts | 7 + .../components/CallNodeContextMenu.test.tsx | 8 + .../components/TransformShortcuts.test.tsx | 72 ++++++++- src/test/store/transforms.test.ts | 82 +++++++++- src/test/url-handling.test.ts | 8 +- src/types/profile-derived.ts | 3 + src/types/transforms.ts | 26 ++++ src/utils/types.ts | 1 + 17 files changed, 534 insertions(+), 18 deletions(-) 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':