From 367a93062b9e10ea5e1bb15fa29473617be9a15c Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sat, 16 May 2026 00:24:17 -0400 Subject: [PATCH 01/36] Use `Int32Array` for the derived stack table's `frame` column. This doesn't change anything about the profile format, this is just about the derived in-memory representation. --- src/profile-logic/profile-data.ts | 7 +++++-- src/test/store/__snapshots__/profile-view.test.ts.snap | 10 +++++----- src/types/profile-derived.ts | 7 +++---- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index 787fdc7fda..10100ef72e 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -1688,7 +1688,7 @@ export function createStackTableBySkippingDiscarded( keepStack: BitSet ): StackTable { const newStackCount = newPrefixCol.length; - const newFrameCol = new Array(newStackCount); + const newFrameCol = new Int32Array(newStackCount); const newCategoryCol = new Uint8Array(newStackCount); const newSubcategoryCol = stackTable.subcategory instanceof Uint16Array @@ -4764,8 +4764,11 @@ export function computeStackTableFromRawStackTable( subcategoryColumn[stackIndex] = stackSubcategory; } + // The frame column is a typed array in the derived stack table. + const frame = new Int32Array(rawStackTable.frame); + return { - frame: rawStackTable.frame, + frame, category: categoryColumn, subcategory: subcategoryColumn, prefix: rawStackTable.prefix, diff --git a/src/test/store/__snapshots__/profile-view.test.ts.snap b/src/test/store/__snapshots__/profile-view.test.ts.snap index c342a59f5a..738ec4d3d7 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -2721,7 +2721,7 @@ CallTree { 0, 0, ], - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -3109,7 +3109,7 @@ Object { 0, 0, ], - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -3563,7 +3563,7 @@ Object { 0, 0, ], - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -3949,7 +3949,7 @@ Object { 0, 0, ], - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -4335,7 +4335,7 @@ Object { 0, 0, ], - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, diff --git a/src/types/profile-derived.ts b/src/types/profile-derived.ts index 75325be75c..89e7cfab68 100644 --- a/src/types/profile-derived.ts +++ b/src/types/profile-derived.ts @@ -29,7 +29,6 @@ import type { JsTracerTable, IndexIntoStackTable, WeightType, - IndexIntoFrameTable, SourceTable, IndexIntoSourceTable, CounterDisplayConfig, @@ -200,7 +199,8 @@ export type Counter = { * The `StackTable` type of the derived thread. * * The only difference from the `RawStackTable` is that the `StackTable` has a - * `category` and a `subcategory` column. + * `category` and a `subcategory` column, and that the `StackTable` always uses + * a typed array for the `frame` column. * * The category of a stack node is always non-null and is derived from a stack's * frame and its prefix. Frames can have null categories, stacks cannot. If a @@ -225,8 +225,7 @@ export type Counter = { * nsAttrAndChildArray::InsertChildAt stack before transforms are applied. */ export type StackTable = { - // Same as in RawStackTable - frame: IndexIntoFrameTable[]; + frame: Int32Array; prefix: Array; length: number; From 09909dac7c8fa895b687535cf9d9de2944dab1a7 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sat, 16 May 2026 00:18:52 -0400 Subject: [PATCH 02/36] Add a JSON utility that serializes typed arrays as regular arrays. `JSON.stringify` serializes typed arrays as objects with stringified numeric keys (e.g. `{"0": 1, "1": 2}`), which is not what we want when a profile contains typed arrays. `jsonEncodeObjectWithTypedArraysAsRegularArrays` traverses the object and converts any typed array it finds to a regular array of numbers before it calls `JSON.stringify`. The new function is not used yet; the next patch in this series will switch profile serialization to use it. --- src/test/unit/json-with-typed-arrays.test.ts | 129 +++++++++++++++++++ src/utils/json-with-typed-arrays.ts | 81 ++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 src/test/unit/json-with-typed-arrays.test.ts create mode 100644 src/utils/json-with-typed-arrays.ts diff --git a/src/test/unit/json-with-typed-arrays.test.ts b/src/test/unit/json-with-typed-arrays.test.ts new file mode 100644 index 0000000000..2ba9a2f070 --- /dev/null +++ b/src/test/unit/json-with-typed-arrays.test.ts @@ -0,0 +1,129 @@ +/* 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 { jsonEncodeObjectWithTypedArraysAsRegularArrays as encode } from '../../utils/json-with-typed-arrays'; + +describe('jsonEncodeObjectWithTypedArraysAsRegularArrays', function () { + it('encodes primitives the same as JSON.stringify', function () { + expect(encode(42)).toBe('42'); + expect(encode('hello')).toBe('"hello"'); + expect(encode(null)).toBe('null'); + expect(encode(true)).toBe('true'); + }); + + it('encodes a plain object the same as JSON.stringify', function () { + const obj = { a: 1, b: 'two', c: [1, 2, 3], d: { nested: true } }; + expect(encode(obj)).toBe(JSON.stringify(obj)); + }); + + it('encodes a top-level typed array as a regular array of numbers', function () { + const arr = new Int32Array([1, 2, 3, 4]); + expect(encode(arr)).toBe('[1,2,3,4]'); + }); + + it('encodes an empty typed array as an empty array', function () { + expect(encode(new Uint8Array())).toBe('[]'); + }); + + it('handles all typed array variants', function () { + expect(encode(new Int8Array([1, -2]))).toBe('[1,-2]'); + expect(encode(new Uint8Array([1, 2]))).toBe('[1,2]'); + expect(encode(new Uint8ClampedArray([1, 2]))).toBe('[1,2]'); + expect(encode(new Int16Array([1, -2]))).toBe('[1,-2]'); + expect(encode(new Uint16Array([1, 2]))).toBe('[1,2]'); + expect(encode(new Int32Array([1, -2]))).toBe('[1,-2]'); + expect(encode(new Uint32Array([1, 2]))).toBe('[1,2]'); + expect(encode(new Float32Array([1.5]))).toBe('[1.5]'); + expect(encode(new Float64Array([1.5]))).toBe('[1.5]'); + }); + + it('encodes typed arrays nested inside an object', function () { + const obj = { + name: 'data', + values: new Int32Array([10, 20, 30]), + }; + expect(encode(obj)).toBe('{"name":"data","values":[10,20,30]}'); + }); + + it('encodes typed arrays nested inside an array', function () { + const arr = [1, new Uint8Array([2, 3]), 4]; + expect(encode(arr)).toBe('[1,[2,3],4]'); + }); + + it('encodes deeply nested typed arrays', function () { + const obj = { + a: { + b: { + c: [ + { d: new Float32Array([1.5, 2.5]) }, + { e: new Int16Array([7, 8]) }, + ], + }, + }, + }; + expect(encode(obj)).toBe('{"a":{"b":{"c":[{"d":[1.5,2.5]},{"e":[7,8]}]}}}'); + }); + + it('does not mutate the original object', function () { + const typedArr = new Int32Array([1, 2, 3]); + const inner = { values: typedArr, label: 'x' }; + const obj = { inner, count: 2 }; + const originalKeys = Object.keys(obj); + const innerKeys = Object.keys(inner); + + encode(obj); + + expect(obj.inner).toBe(inner); + expect(inner.values).toBe(typedArr); + expect(inner.label).toBe('x'); + expect(obj.count).toBe(2); + expect(Object.keys(obj)).toEqual(originalKeys); + expect(Object.keys(inner)).toEqual(innerKeys); + }); + + it('does not mutate the original array', function () { + const typedArr = new Int32Array([1, 2, 3]); + const arr = [1, typedArr, 3]; + + encode(arr); + + expect(arr[1]).toBe(typedArr); + expect(arr).toEqual([1, typedArr, 3]); + }); + + it('leaves DataView alone (encoded as a regular object)', function () { + // DataView is excluded from typed-array handling and falls through + // to the regular object path. JSON.stringify on a DataView produces "{}". + const buffer = new ArrayBuffer(8); + const view = new DataView(buffer); + expect(encode(view)).toBe(JSON.stringify(view)); + }); + + it('handles null values inside objects and arrays', function () { + const obj = { a: null, b: [null, 1, null], c: new Int32Array([5]) }; + expect(encode(obj)).toBe('{"a":null,"b":[null,1,null],"c":[5]}'); + }); + + it('handles an empty object and an empty array', function () { + expect(encode({})).toBe('{}'); + expect(encode([])).toBe('[]'); + }); + + it('preserves array element order with typed arrays interleaved', function () { + const arr = [ + new Uint8Array([1]), + 'middle', + new Uint8Array([2]), + 42, + new Uint8Array([3]), + ]; + expect(encode(arr)).toBe('[[1],"middle",[2],42,[3]]'); + }); + + it('encodes the same shared typed array twice when it appears in two places', function () { + const shared = new Int32Array([7, 8, 9]); + const obj = { first: shared, second: shared }; + expect(encode(obj)).toBe('{"first":[7,8,9],"second":[7,8,9]}'); + }); +}); diff --git a/src/utils/json-with-typed-arrays.ts b/src/utils/json-with-typed-arrays.ts new file mode 100644 index 0000000000..9ab283f35c --- /dev/null +++ b/src/utils/json-with-typed-arrays.ts @@ -0,0 +1,81 @@ +/* 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/. */ + +/** + * JSON.stringify an object which may contain typed arrays somewhere in + * its structure (nested arbitrarily deeply), with those typed arrays + * serialized as regular arrays of numbers. + * + * Calling JSON.stringify on a typed array would normally give you something + * like `{"0": 1, "1": 2}` which is not what you want. + * + * This function does not mutate rootObject. + */ +export function jsonEncodeObjectWithTypedArraysAsRegularArrays( + rootObject: unknown +): string { + // We could use JSON.stringify with a "replacer" here. + // But instead, we do a full traversal of the object first, possibly + // creating new objects (so that the original doesn't get mutated), + // and then a regular JSON.stringify with no replacer. This is 5x faster. + return JSON.stringify(rewriteTypedArrays(rootObject)); +} + +function rewriteTypedArrays(value: unknown): unknown { + if (value === null || typeof value !== 'object') { + return value; + } + if (ArrayBuffer.isView(value) && !(value instanceof DataView)) { + // Found a typed array! Use Array.from to convert it to a regular + // array of numbers. + return Array.from(value as unknown as Iterable); + } + if (Array.isArray(value)) { + return rewriteTypedArraysInArray(value); + } + return rewriteTypedArraysInObject(value as Record); +} + +function rewriteTypedArraysInArray(arr: readonly unknown[]): unknown[] { + let result: unknown[] | null = null; + for (let i = 0; i < arr.length; i++) { + const el = arr[i]; + // Inline fast-path for primitives: an array of 1000 numbers walks this + // loop without any function call. + if (el === null || typeof el !== 'object') { + continue; + } + const replaced = rewriteTypedArrays(el); + if (replaced !== el) { + if (result === null) { + result = arr.slice(); + } + result[i] = replaced; + } + } + return result ?? (arr as unknown[]); +} + +function rewriteTypedArraysInObject( + obj: Record +): Record { + let result: Record | null = null; + for (const key in obj) { + if (!Object.hasOwn(obj, key)) { + continue; + } + const el = obj[key]; + if (el === null || typeof el !== 'object') { + continue; + } + const replaced = rewriteTypedArrays(el); + if (replaced !== el) { + if (result === null) { + result = { ...obj }; + } + result[key] = replaced; + } + } + return result ?? obj; +} From 974517d2752411a4009407e8d552545637ec82b4 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sat, 16 May 2026 00:19:26 -0400 Subject: [PATCH 03/36] Use jsonEncodeObjectWithTypedArraysAsRegularArrays for profile JSON serialization. This will allow us to have typed arrays in the profile and still serialize them as regular JSON arrays whenever the profile is serialized to JSON. --- src/profile-logic/process-profile.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/profile-logic/process-profile.ts b/src/profile-logic/process-profile.ts index 712dda0c52..3781d305fa 100644 --- a/src/profile-logic/process-profile.ts +++ b/src/profile-logic/process-profile.ts @@ -107,6 +107,7 @@ import type { CounterDisplayConfig, } from 'firefox-profiler/types'; import { decompress, isGzip } from 'firefox-profiler/utils/gz'; +import { jsonEncodeObjectWithTypedArraysAsRegularArrays } from 'firefox-profiler/utils/json-with-typed-arrays'; type RegExpResult = null | string[]; /** @@ -2056,7 +2057,7 @@ export function processGeckoProfile(geckoProfile: GeckoProfile): Profile { * Take a processed profile and convert it to a string. */ export function serializeProfileToJsonString(profile: Profile): string { - return JSON.stringify(profile); + return jsonEncodeObjectWithTypedArraysAsRegularArrays(profile); } /** From 7dbb6533ba58e97792119c2c3178cc85aa84af1d Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sat, 16 May 2026 00:24:17 -0400 Subject: [PATCH 04/36] Update tests to deal with typed arrays in profiles. Some of our tests were auto-stringifying profiles (e.g. the ones using fetchMock), which will produce bad results once profiles start containing typed arrays. Use explicit serializeProfileToJsonString calls in those places. --- src/test/components/Root-history.test.tsx | 3 ++- src/test/components/UrlManager.test.tsx | 3 ++- src/test/store/receive-profile.test.ts | 27 +++++++++++++++++------ 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/test/components/Root-history.test.tsx b/src/test/components/Root-history.test.tsx index 13a130aa71..adacbd6510 100644 --- a/src/test/components/Root-history.test.tsx +++ b/src/test/components/Root-history.test.tsx @@ -13,6 +13,7 @@ import { autoMockCanvasContext } from '../fixtures/mocks/canvas-context'; import { fireFullClick } from '../fixtures/utils'; import { getProfileUrlForHash } from '../../utils/profile-fetch'; import { blankStore } from '../fixtures/stores'; +import { serializeProfileToJsonString } from '../../profile-logic/process-profile'; import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; import { autoMockFullNavigation, @@ -198,5 +199,5 @@ describe('Root with history', function () { function mockFetchProfileAtUrl(url: string, profile: Profile): void { window.fetchMock .catch(404) // catchall - .get(url, profile); + .get(url, serializeProfileToJsonString(profile)); } diff --git a/src/test/components/UrlManager.test.tsx b/src/test/components/UrlManager.test.tsx index 86d9f4ef3d..e5811d062a 100644 --- a/src/test/components/UrlManager.test.tsx +++ b/src/test/components/UrlManager.test.tsx @@ -18,6 +18,7 @@ import { waitUntilState } from '../fixtures/utils'; import { createGeckoProfile } from '../fixtures/profiles/gecko-profile'; import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; import { CURRENT_URL_VERSION } from '../../app-logic/url-handling'; +import { serializeProfileToJsonString } from '../../profile-logic/process-profile'; import { autoMockFullNavigation } from '../fixtures/mocks/window-navigation'; import { profilePublished } from 'firefox-profiler/actions/publish'; import { @@ -35,7 +36,7 @@ describe('UrlManager', function () { autoMockFullNavigation(); function getSerializableProfile() { - return getProfileFromTextSamples('A').profile; + return serializeProfileToJsonString(getProfileFromTextSamples('A').profile); } function setup(urlPath?: string) { diff --git a/src/test/store/receive-profile.test.ts b/src/test/store/receive-profile.test.ts index f480f467c9..7a660fa865 100644 --- a/src/test/store/receive-profile.test.ts +++ b/src/test/store/receive-profile.test.ts @@ -879,7 +879,10 @@ describe('actions/receive-profile', function () { const hash = 'c5e53f9ab6aecef926d4be68c84f2de550e2ac2f'; const expectedUrl = `https://storage.googleapis.com/profile-store/${hash}`; - window.fetchMock.get(expectedUrl, _getSimpleProfile()); + window.fetchMock.get( + expectedUrl, + serializeProfileToJsonString(_getSimpleProfile()) + ); const store = blankStore(); await store.dispatch(retrieveProfileFromStore(hash)); @@ -939,7 +942,7 @@ describe('actions/receive-profile', function () { const expectedUrl = `https://storage.googleapis.com/profile-store/${hash}`; window.fetchMock .getOnce(expectedUrl, 403) - .get(expectedUrl, _getSimpleProfile()); + .get(expectedUrl, serializeProfileToJsonString(_getSimpleProfile())); const store = blankStore(); const views = ( @@ -1026,7 +1029,10 @@ describe('actions/receive-profile', function () { it('can retrieve a profile from the web and save it to state', async function () { const expectedUrl = 'https://profiles.club/shared.json'; - window.fetchMock.get(expectedUrl, _getSimpleProfile()); + window.fetchMock.get( + expectedUrl, + serializeProfileToJsonString(_getSimpleProfile()) + ); const store = blankStore(); await store.dispatch(retrieveProfileOrZipFromUrl(expectedUrl)); @@ -1063,7 +1069,7 @@ describe('actions/receive-profile', function () { // The first call will still be a 403 -- remember, it's the default return value. window.fetchMock .getOnce(expectedUrl, 403) - .get(expectedUrl, _getSimpleProfile()); + .get(expectedUrl, serializeProfileToJsonString(_getSimpleProfile())); const store = blankStore(); const views = ( @@ -1526,7 +1532,9 @@ describe('actions/receive-profile', function () { ]) ); } - window.fetchMock.getOnce('*', profile1).getOnce('*', profile2); + window.fetchMock + .getOnce('*', serializeProfileToJsonString(profile1)) + .getOnce('*', serializeProfileToJsonString(profile2)); const { dispatch, getState } = blankStore(); await dispatch(retrieveProfilesToCompare([url1, url2])); @@ -1793,7 +1801,9 @@ describe('actions/receive-profile', function () { it('gives a fatal error when the selected thread index is out of bounds', async function () { const { dispatch, getState } = blankStore(); const { profile1, profile2 } = getSomeProfiles(); - window.fetchMock.getOnce('*', profile1).getOnce('*', profile2); + window.fetchMock + .getOnce('*', serializeProfileToJsonString(profile1)) + .getOnce('*', serializeProfileToJsonString(profile2)); await dispatch( retrieveProfilesToCompare([ @@ -1813,7 +1823,10 @@ describe('actions/receive-profile', function () { location: Partial, requiredProfile: number = 1 ) { - const profile = _getSimpleProfile(); + const simpleProfile = _getSimpleProfile(); + // Create a profile object we can use with fetchMock, which uses JSON.stringify internally. + // `simpleProfile` may contain typed arrays which wouldn't survive JSON.stringfy. + const profile = JSON.parse(serializeProfileToJsonString(simpleProfile)); const geckoProfile = createGeckoProfile(); // Add mock fetch response for the required number of times. From adaaa665975381d0ce955e4a59af48c9aaef511b Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sat, 16 May 2026 00:12:43 -0400 Subject: [PATCH 05/36] Introduce `RawStackTableBuilder` for stack table construction. `RawStackTable` is being prepared to allow `frame` to be an `Int32Array` in a later commit. `Int32Array` is fixed-size and doesn't support `push`, so the existing "push to .frame and bump .length" pattern needs a builder that uses a plain `number[]` during construction and converts to the final representation via `finishRawStackTableBuilder` at the end. Switch all stack table construction sites to use the builder. The builder still produces a plain `number[]` for `frame` in this commit; the type change happens in a follow-up. --- src/profile-logic/data-structures.ts | 33 +++++++++++++++++-- src/profile-logic/global-data-collector.ts | 13 ++++---- src/profile-logic/import/chrome.ts | 2 +- src/profile-logic/import/dhat.ts | 2 +- src/profile-logic/import/flame-graph.ts | 2 +- src/profile-logic/import/simpleperf.ts | 8 +++-- src/profile-logic/js-tracer.ts | 10 +++++- src/profile-logic/merge-compare.ts | 10 ++++-- src/profile-logic/process-profile.ts | 6 ++-- src/profile-logic/profile-data.ts | 7 ++-- src/profile-logic/symbolication.ts | 10 ++++-- src/test/fixtures/profiles/call-nodes.ts | 12 +++++-- .../fixtures/profiles/processed-profile.ts | 17 +++++++--- 13 files changed, 98 insertions(+), 34 deletions(-) diff --git a/src/profile-logic/data-structures.ts b/src/profile-logic/data-structures.ts index 18f8ab12fc..7771052bc7 100644 --- a/src/profile-logic/data-structures.ts +++ b/src/profile-logic/data-structures.ts @@ -27,6 +27,8 @@ import type { CallNodeTable, SourceTable, SourceLocationTable, + IndexIntoFrameTable, + IndexIntoStackTable, } from 'firefox-profiler/types'; /** @@ -47,7 +49,13 @@ export function getEmptySamplesTable(): RawSamplesTable { }; } -export function getEmptyRawStackTable(): RawStackTable { +export type RawStackTableBuilder = { + frame: IndexIntoFrameTable[]; + prefix: Array; + length: number; +}; + +export function getRawStackTableBuilder(): RawStackTableBuilder { return { // Important! // If modifying this structure, please update all callers of this function to ensure @@ -59,6 +67,27 @@ export function getEmptyRawStackTable(): RawStackTable { }; } +export function getRawStackTableBuilderWithExistingContents( + existing: RawStackTable +): RawStackTableBuilder { + return { + frame: [...existing.frame], + prefix: [...existing.prefix], + length: existing.length, + }; +} + +export function finishRawStackTableBuilder( + builder: RawStackTableBuilder +): RawStackTable { + const { frame, prefix, length } = builder; + return { + frame, + prefix, + length, + }; +} + /** * Returns an empty samples table with eventDelay field instead of responsiveness. * eventDelay is a new field and it replaced responsiveness. We should still @@ -393,7 +422,7 @@ export function getEmptyThread(overrides?: Partial): RawThread { export function getEmptySharedData(): RawProfileSharedData { return { - stackTable: getEmptyRawStackTable(), + stackTable: finishRawStackTableBuilder(getRawStackTableBuilder()), frameTable: getEmptyFrameTable(), funcTable: getEmptyFuncTable(), resourceTable: getEmptyResourceTable(), diff --git a/src/profile-logic/global-data-collector.ts b/src/profile-logic/global-data-collector.ts index 78b5b38543..c52e170293 100644 --- a/src/profile-logic/global-data-collector.ts +++ b/src/profile-logic/global-data-collector.ts @@ -4,13 +4,14 @@ import { StringTable } from '../utils/string-table'; import { + finishRawStackTableBuilder, getEmptyFrameTable, getEmptyFuncTable, getEmptyNativeSymbolTable, - getEmptyRawStackTable, getEmptyResourceTable, getEmptySourceTable, getEmptySourceLocationTable, + getRawStackTableBuilder, } from './data-structures'; import type { @@ -22,7 +23,6 @@ import type { RawProfileSharedData, SourceTable, FrameTable, - RawStackTable, FuncTable, ResourceTable, NativeSymbolTable, @@ -34,6 +34,7 @@ import type { Bytes, } from 'firefox-profiler/types'; import { ResourceType } from 'firefox-profiler/types'; +import type { RawStackTableBuilder } from './data-structures'; /** * GlobalDataCollector collects data which is global in the processed profile @@ -50,7 +51,7 @@ export class GlobalDataCollector { _stringTable: StringTable = StringTable.withBackingArray(this._stringArray); _sources: SourceTable = getEmptySourceTable(); _frameTable: FrameTable = getEmptyFrameTable(); - _stackTable: RawStackTable = getEmptyRawStackTable(); + _stackTableBuilder: RawStackTableBuilder = getRawStackTableBuilder(); _funcTable: FuncTable = getEmptyFuncTable(); _resourceTable: ResourceTable = getEmptyResourceTable(); _nativeSymbols: NativeSymbolTable = getEmptyNativeSymbolTable(); @@ -302,15 +303,15 @@ export class GlobalDataCollector { return this._frameTable; } - getStackTable(): RawStackTable { - return this._stackTable; + getStackTableBuilder(): RawStackTableBuilder { + return this._stackTableBuilder; } // Package up all de-duplicated global tables so that they can be embedded in // the profile. finish(): { libs: Lib[]; shared: RawProfileSharedData } { const shared: RawProfileSharedData = { - stackTable: this._stackTable, + stackTable: finishRawStackTableBuilder(this._stackTableBuilder), frameTable: this._frameTable, funcTable: this._funcTable, resourceTable: this._resourceTable, diff --git a/src/profile-logic/import/chrome.ts b/src/profile-logic/import/chrome.ts index 96add0a137..be4253f0ce 100644 --- a/src/profile-logic/import/chrome.ts +++ b/src/profile-logic/import/chrome.ts @@ -501,7 +501,7 @@ async function processTracingEvents( const stringTable = globalDataCollector.getStringTable(); const frameTable = globalDataCollector.getFrameTable(); - const stackTable = globalDataCollector.getStackTable(); + const stackTable = globalDataCollector.getStackTableBuilder(); let profileEvents: (ProfileEvent | CpuProfileEvent)[] = (eventsByName.get( 'Profile' diff --git a/src/profile-logic/import/dhat.ts b/src/profile-logic/import/dhat.ts index dc56c06f17..6f883c2e29 100644 --- a/src/profile-logic/import/dhat.ts +++ b/src/profile-logic/import/dhat.ts @@ -183,7 +183,7 @@ export function attemptToConvertDhat(json: unknown): Profile | null { profile.meta.product = dhat.cmd + ' (dhat)'; profile.meta.importedFrom = `dhat`; const globalDataCollector = new GlobalDataCollector(); - const stackTable = globalDataCollector.getStackTable(); + const stackTable = globalDataCollector.getStackTableBuilder(); const frameTable = globalDataCollector.getFrameTable(); const stringTable = globalDataCollector.getStringTable(); diff --git a/src/profile-logic/import/flame-graph.ts b/src/profile-logic/import/flame-graph.ts index 148a777bd2..b468d4d008 100644 --- a/src/profile-logic/import/flame-graph.ts +++ b/src/profile-logic/import/flame-graph.ts @@ -60,7 +60,7 @@ export function convertFlameGraphProfile(profileText: string): Profile { }); const frameTable = globalDataCollector.getFrameTable(); - const stackTable = globalDataCollector.getStackTable(); + const stackTable = globalDataCollector.getStackTableBuilder(); const { samples } = thread; // Maps to deduplicate stacks, frames, and functions. diff --git a/src/profile-logic/import/simpleperf.ts b/src/profile-logic/import/simpleperf.ts index 89b632cd85..83c38d8929 100644 --- a/src/profile-logic/import/simpleperf.ts +++ b/src/profile-logic/import/simpleperf.ts @@ -24,7 +24,9 @@ import { getEmptyFuncTable, getEmptyResourceTable, getEmptyFrameTable, - getEmptyRawStackTable, + getRawStackTableBuilder, + finishRawStackTableBuilder, + type RawStackTableBuilder, getEmptySamplesTable, getEmptyRawMarkerTable, getEmptyNativeSymbolTable, @@ -189,7 +191,7 @@ class FirefoxFrameTable { class FirefoxSampleTable { strings: StringTable; - stackTable: RawStackTable = getEmptyRawStackTable(); + stackTable: RawStackTableBuilder = getRawStackTableBuilder(); stackMap: Map = new Map(); constructor(strings: StringTable) { @@ -197,7 +199,7 @@ class FirefoxSampleTable { } toJson(): RawStackTable { - return this.stackTable; + return finishRawStackTableBuilder(this.stackTable); } findOrAddStack( diff --git a/src/profile-logic/js-tracer.ts b/src/profile-logic/js-tracer.ts index 8993ddfbca..e4664aee95 100644 --- a/src/profile-logic/js-tracer.ts +++ b/src/profile-logic/js-tracer.ts @@ -4,6 +4,8 @@ import { getEmptySamplesTableWithEventDelay, getEmptyRawMarkerTable, + finishRawStackTableBuilder, + getRawStackTableBuilderWithExistingContents, } from './data-structures'; import { StringTable } from '../utils/string-table'; import { ensureExists } from '../utils/types'; @@ -512,7 +514,10 @@ export function convertJsTracerToThreadWithoutSamples( samples, }; - const { funcTable, frameTable, stackTable } = shared; + const { funcTable, frameTable } = shared; + const stackTable = getRawStackTableBuilderWithExistingContents( + shared.stackTable + ); // Keep a stack of js tracer events, and end timings, that will be used to find // the stack prefixes. Once a JS tracer event starts past another event end, the @@ -620,6 +625,9 @@ export function convertJsTracerToThreadWithoutSamples( unmatchedEventEnds[unmatchedIndex] = end; } + // Write the augmented stackTable back to the shared data. + shared.stackTable = finishRawStackTableBuilder(stackTable); + return { thread, stackMap }; } diff --git a/src/profile-logic/merge-compare.ts b/src/profile-logic/merge-compare.ts index 5eeecb1705..6e9e424b1b 100644 --- a/src/profile-logic/merge-compare.ts +++ b/src/profile-logic/merge-compare.ts @@ -13,7 +13,8 @@ import { getEmptyNativeSymbolTable, getEmptyFrameTable, getEmptyFuncTable, - getEmptyRawStackTable, + getRawStackTableBuilder, + finishRawStackTableBuilder, getEmptyRawMarkerTable, getEmptySamplesTableWithEventDelay, shallowCloneRawMarkerTable, @@ -1117,7 +1118,7 @@ function mergeStackTables( translationMapsForFrames: TranslationMapForFrames[] ): { stackTable: RawStackTable; translationMaps: TranslationMapForStacks[] } { const translationMaps: TranslationMapForStacks[] = []; - const newStackTable = getEmptyRawStackTable(); + const newStackTable = getRawStackTableBuilder(); profiles.forEach((profile, profileIndex) => { const { stackTable } = profile.shared; @@ -1143,7 +1144,10 @@ function mergeStackTables( translationMaps.push(oldStackToNewStackPlusOne); }); - return { stackTable: newStackTable, translationMaps }; + return { + stackTable: finishRawStackTableBuilder(newStackTable), + translationMaps, + }; } /** diff --git a/src/profile-logic/process-profile.ts b/src/profile-logic/process-profile.ts index 3781d305fa..d1f96c4c90 100644 --- a/src/profile-logic/process-profile.ts +++ b/src/profile-logic/process-profile.ts @@ -16,6 +16,7 @@ import { getEmptyRawMarkerTable, getEmptyJsAllocationsTable, getEmptyUnbalancedNativeAllocationsTable, + type RawStackTableBuilder, } from './data-structures'; import { immutableUpdate, ensureExists } from '../utils/types'; import { verifyMagic, SIMPLEPERF as SIMPLEPERF_MAGIC } from '../utils/magic'; @@ -53,7 +54,6 @@ import type { FrameTable, RawCounterSamplesTable, RawSamplesTable, - RawStackTable, RawMarkerTable, LibMapping, IndexIntoStackTable, @@ -549,7 +549,7 @@ function _processFrameTable( */ function _processStackTable( geckoStackTable: GeckoStackStruct, - sharedStackTable: RawStackTable, + sharedStackTable: RawStackTableBuilder, frameIndexOffset: IndexIntoFrameTable ): IndexIntoStackTable { const stackIndexOffset = sharedStackTable.length; @@ -1337,7 +1337,7 @@ function _processThread( ); const stackIndexOffset = _processStackTable( geckoStackTable, - globalDataCollector.getStackTable(), + globalDataCollector.getStackTableBuilder(), frameIndexOffset ); diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index 10100ef72e..43db6eb47f 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -6,7 +6,8 @@ import memoize from 'memoize-immutable'; import MixedTupleMap from 'mixedtuplemap'; import { oneLine } from 'common-tags'; import { - getEmptyRawStackTable, + getRawStackTableBuilder, + finishRawStackTableBuilder, getEmptyCallNodeTable, shallowCloneFrameTable, shallowCloneFuncTable, @@ -4343,7 +4344,7 @@ export function nudgeReturnAddresses(profile: Profile): Profile { // Now the frame table contains adjusted / "nudged" addresses. // Make a new stack table which refers to the adjusted frames. - const newStackTable = getEmptyRawStackTable(); + const newStackTable = getRawStackTableBuilder(); const mapForSamplingSelfStacks = new Map< null | IndexIntoStackTable, null | IndexIntoStackTable @@ -4385,7 +4386,7 @@ export function nudgeReturnAddresses(profile: Profile): Profile { const newShared: RawProfileSharedData = { ...profile.shared, frameTable: newFrameTable, - stackTable: newStackTable, + stackTable: finishRawStackTableBuilder(newStackTable), }; const newThreads = updateRawThreadStacksSeparate( diff --git a/src/profile-logic/symbolication.ts b/src/profile-logic/symbolication.ts index a6370bc6fa..dfbfd345c2 100644 --- a/src/profile-logic/symbolication.ts +++ b/src/profile-logic/symbolication.ts @@ -2,7 +2,8 @@ * 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 { - getEmptyRawStackTable, + getRawStackTableBuilder, + finishRawStackTableBuilder, shallowCloneFuncTable, shallowCloneNativeSymbolTable, shallowCloneFrameTable, @@ -420,7 +421,7 @@ function _computeStackTableWithAddedExpansionStacks( if (frameIndexToInlineExpansionFrames.size === 0) { return null; } - const newStackTable = getEmptyRawStackTable(); + const newStackTable = getRawStackTableBuilder(); const oldStackToNewStack = new Int32Array(stackTable.length); for (let stack = 0; stack < stackTable.length; stack++) { const oldFrame = stackTable.frame[stack]; @@ -452,7 +453,10 @@ function _computeStackTableWithAddedExpansionStacks( } oldStackToNewStack[stack] = prefix ?? -1; } - return { newStackTable, oldStackToNewStack }; + return { + newStackTable: finishRawStackTableBuilder(newStackTable), + oldStackToNewStack, + }; } /** diff --git a/src/test/fixtures/profiles/call-nodes.ts b/src/test/fixtures/profiles/call-nodes.ts index 909b48e7e7..0ffed2dd52 100644 --- a/src/test/fixtures/profiles/call-nodes.ts +++ b/src/test/fixtures/profiles/call-nodes.ts @@ -6,7 +6,8 @@ import type { FuncTable, FrameTable, Profile } from 'firefox-profiler/types'; import { getEmptyThread, getEmptyProfile, - getEmptyRawStackTable, + getRawStackTableBuilder, + finishRawStackTableBuilder, } from '../../../profile-logic/data-structures'; import { StringTable } from '../../../utils/string-table'; @@ -87,7 +88,7 @@ export default function getProfile(): Profile { length: frameFuncs.length, }; - const stackTable = getEmptyRawStackTable(); + const stackTable = getRawStackTableBuilder(); // Provide a utility function for readability. function addToStackTable(frame: any, prefix: any) { @@ -119,7 +120,12 @@ export default function getProfile(): Profile { length: 2, }; - profile.shared = { ...profile.shared, stackTable, funcTable, frameTable }; + profile.shared = { + ...profile.shared, + stackTable: finishRawStackTableBuilder(stackTable), + funcTable, + frameTable, + }; profile.threads.push(thread, { ...thread }, { ...thread }); diff --git a/src/test/fixtures/profiles/processed-profile.ts b/src/test/fixtures/profiles/processed-profile.ts index 07ef1d7bab..708018511f 100644 --- a/src/test/fixtures/profiles/processed-profile.ts +++ b/src/test/fixtures/profiles/processed-profile.ts @@ -8,6 +8,8 @@ import { getEmptyJsAllocationsTable, getEmptyUnbalancedNativeAllocationsTable, getEmptyBalancedNativeAllocationsTable, + getRawStackTableBuilderWithExistingContents, + finishRawStackTableBuilder, } from '../../../profile-logic/data-structures'; import { mergeProfilesForDiffing } from '../../../profile-logic/merge-compare'; import { computeReferenceCPUDeltaPerMs } from '../../../profile-logic/cpu'; @@ -983,7 +985,7 @@ function _buildThreadFromTextOnlyStacks( } // Attempt to find a stack that satisfies the given frameIndex and prefix. - const stackTable = globalDataCollector.getStackTable(); + const stackTable = globalDataCollector.getStackTableBuilder(); let stackIndex; for (let i = 0; i < stackTable.length; i++) { if ( @@ -1994,8 +1996,10 @@ function getStackIndexForCallNodePath( /** * Use this function to add window id information to frames, using call node * paths to point to frames using stacks. + * This mutates `shared`. * * @param thread The thread to mutate. + * @param shared The shared profile data to mutate. * @param listOfOperations A list of pairs { innerWindowID, callNodes } * indicating which call nodes this innerWindowID will * be assigned to. @@ -2037,6 +2041,9 @@ export function addInnerWindowIdToStacks( IndexIntoStackTable >(); + const stackTableBuilder = + getRawStackTableBuilderWithExistingContents(stackTable); + for (const callNode of callNodesToDupe) { const stackIndex = getStackIndexForCallNodePath(shared, callNode); const foundFrameIndex = stackTable.frame[stackIndex]; @@ -2062,14 +2069,16 @@ export function addInnerWindowIdToStacks( frameTable.innerWindowID.push(listOfOperations[1].innerWindowID); // Clone the stack - const newStackIndex = stackTable.length++; - stackTable.prefix.push(stackTable.prefix[stackIndex]); + const newStackIndex = stackTableBuilder.length++; + stackTableBuilder.prefix.push(stackTable.prefix[stackIndex]); // Using the cloned frame index. - stackTable.frame.push(newFrameIndex); + stackTableBuilder.frame.push(newFrameIndex); mapStackIndexToDupe.set(stackIndex, newStackIndex); } + shared.stackTable = finishRawStackTableBuilder(stackTableBuilder); + const sampleTimes = ensureExists(samples.time); for (let sampleIndex = samples.length; sampleIndex >= 0; sampleIndex--) { // We're looping from the end because we'll push some samples to the end From d116be9ff9f0a0fffa74b4384990bfef4103dc82 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sat, 16 May 2026 00:24:17 -0400 Subject: [PATCH 06/36] Allow the `stackTable.frame` column to contain an Int32Array. This is the first typed array that we're supporting inside the profile format. When a profile is saved in the JsonSlabs format, it will now have this column as a separate slab that doesn't require JSON parsing. Profile compacting now always turns `stackTable.frame` into a typed array, even if that column was a regular JS array in the input profile. We still allow a regular JSON array here, because profiles stored as JSON cannot contain typed arrays, and we want to use the same type definition for JSON and JSLB profiles. Here's how this change impacts profile sizes and loading times on this profile: https://storage.googleapis.com/profiler-get-symbols-fixtures/large-speedometer3-profile.json.gz | Version | .jslb.gz size | .jslb size | Load time | Profile of it loading | |---------|---------------|------------|-------------|-----------------------------------| | 64 | 122 MB | 605 MB | 7.6 seconds | https://share.firefox.dev/4ogUKba | | 65 | 125 MB | 544 MB | 6.0 seconds | https://share.firefox.dev/3Qopiem | The compressed size has grown a small bit, but the other savings are significant: - We no longer have 131 MB of text for the frame column in the JSON - the frame column is now stored in a 70 MB i32 slab. - Less time in GZ decompression, because the uncompressed size is now smaller. - There is a lot less time spent in TextDecoder.decode and JSON.parse, because we're no longer decoding and parsing 131 MB of text for the frame column. --- docs-developer/CHANGELOG-formats.md | 4 ++ src/app-logic/constants.ts | 2 +- src/profile-logic/data-structures.ts | 2 +- .../processed-profile-versioning.ts | 5 ++ src/profile-logic/profile-compacting.ts | 60 +++++++++++++--- src/profile-logic/profile-data.ts | 5 +- .../__snapshots__/profiler-edit.test.ts.snap | 8 +-- .../__snapshots__/profile-view.test.ts.snap | 4 +- .../profile-conversion.test.ts.snap | 72 +++++++++---------- .../profile-upgrading.test.ts.snap | 10 +-- src/types/profile.ts | 2 +- 11 files changed, 114 insertions(+), 60 deletions(-) diff --git a/docs-developer/CHANGELOG-formats.md b/docs-developer/CHANGELOG-formats.md index e07d501aa6..b59d5fe0e3 100644 --- a/docs-developer/CHANGELOG-formats.md +++ b/docs-developer/CHANGELOG-formats.md @@ -6,6 +6,10 @@ Note that this is not an exhaustive list. Processed profile format upgraders can ## Processed profile format +### Version 65 + +The stack table's `frame` column (stored at `profile.shared.stackTable.frame`) can now optionally be stored as an `Int32Array`, for profiles loaded from [JsonSlabs](https://github.com/mstange/json-slabs/) files (.jslb, .jslb.gz). Regular JS / JSON arrays are still accepted. + ### Version 64 A new `SourceLocationTable` has been added to `profile.shared.sourceLocationTable`. It holds the original (pre-compilation) source positions produced by source map symbolication, paired with the generated `line`/`column` already on `FrameTable`. diff --git a/src/app-logic/constants.ts b/src/app-logic/constants.ts index 6cba739b59..8d39768163 100644 --- a/src/app-logic/constants.ts +++ b/src/app-logic/constants.ts @@ -12,7 +12,7 @@ export const GECKO_PROFILE_VERSION = 34; // The current version of the "processed" profile format. // Please don't forget to update the processed profile format changelog in // `docs-developer/CHANGELOG-formats.md`. -export const PROCESSED_PROFILE_VERSION = 64; +export const PROCESSED_PROFILE_VERSION = 65; // The following are the margin sizes for the left and right of the timeline. Independent // components need to share these values. diff --git a/src/profile-logic/data-structures.ts b/src/profile-logic/data-structures.ts index 7771052bc7..0599e2153e 100644 --- a/src/profile-logic/data-structures.ts +++ b/src/profile-logic/data-structures.ts @@ -82,7 +82,7 @@ export function finishRawStackTableBuilder( ): RawStackTable { const { frame, prefix, length } = builder; return { - frame, + frame: new Int32Array(frame), prefix, length, }; diff --git a/src/profile-logic/processed-profile-versioning.ts b/src/profile-logic/processed-profile-versioning.ts index bcf3931179..16ab69e409 100644 --- a/src/profile-logic/processed-profile-versioning.ts +++ b/src/profile-logic/processed-profile-versioning.ts @@ -3255,6 +3255,11 @@ const _upgraders: { }; sources.content = new Array(sources.length).fill(null); }, + [65]: (_profile: any) => { + // The type of `profile.shared.stackTable.frame` was changed from + // `IndexIntoFrameTable[]` to `IndexIntoFrameTable[] | Int32Array`. + // All valid v64 profiles are valid v65 profiles, so no upgrader is needed. + }, // If you add a new upgrader here, please document the change in // `docs-developer/CHANGELOG-formats.md`. }; diff --git a/src/profile-logic/profile-compacting.ts b/src/profile-logic/profile-compacting.ts index 492f914716..e7300a3551 100644 --- a/src/profile-logic/profile-compacting.ts +++ b/src/profile-logic/profile-compacting.ts @@ -48,15 +48,22 @@ type ColumnDescription = null extends ( | { type: 'INDEX_REF_OR_NULL'; referencedTable: TableCompactionState } | { type: 'SELF_INDEX_REF_OR_NULL' } | { type: 'NO_REF' } - : - | { type: 'INDEX_REF'; referencedTable: TableCompactionState } - | { type: 'INDEX_REF_OR_NEG_ONE'; referencedTable: TableCompactionState } - | { type: 'NO_REF' }; + : Int32Array extends TCol + ? + | { type: 'INDEX_REF_INT32'; referencedTable: TableCompactionState } + | { type: 'NO_REF' } + : + | { type: 'INDEX_REF'; referencedTable: TableCompactionState } + | { + type: 'INDEX_REF_OR_NEG_ONE'; + referencedTable: TableCompactionState; + } + | { type: 'NO_REF' }; type TableDescription = { - [K in keyof T as T[K] extends Array ? K : never]: ColumnDescription< - T[K] - >; + [K in keyof T as T[K] extends Array | Int32Array + ? K + : never]: ColumnDescription; }; const ColDesc = { @@ -64,6 +71,10 @@ const ColDesc = { type: 'INDEX_REF' as const, referencedTable, }), + indexRefInt32: (referencedTable: TableCompactionState) => ({ + type: 'INDEX_REF_INT32' as const, + referencedTable, + }), indexRefOrNull: (referencedTable: TableCompactionState) => ({ type: 'INDEX_REF_OR_NULL' as const, referencedTable, @@ -145,7 +156,7 @@ export function computeCompactedProfile( }; const stackTableDesc: TableDescription = { - frame: ColDesc.indexRef(tcs.frameTable), + frame: ColDesc.indexRefInt32(tcs.frameTable), prefix: ColDesc.selfIndexRefOrNull(), }; const frameTableDesc: TableDescription = { @@ -326,6 +337,7 @@ function _markTableAndComputeTranslation( const col = (table as any)[key]; switch (desc.type) { case 'INDEX_REF': + case 'INDEX_REF_INT32': markColumn(col, markBuffer, desc.referencedTable.markBuffer); break; case 'INDEX_REF_OR_NULL': @@ -354,7 +366,13 @@ function _markTableAndComputeTranslation( thisTableCompactionState.computeIndexTranslation(); } -function markColumn(col: Array, shouldMark: BitSet, markBuf: BitSet) { +function markColumn( + col: Array | Int32Array, + shouldMark: BitSet, + markBuf: BitSet +) { + // Polymorphic: indexing works the same on Int32Array as on number[], so the + // INDEX_REF and INDEX_REF_INT32 cases share this function. for (let i = 0; i < col.length; i++) { if (checkBit(shouldMark, i)) { const val = col[i]; @@ -499,6 +517,14 @@ function _compactTable( newLength ); break; + case 'INDEX_REF_INT32': + result[key] = _compactColIndexInt32( + oldCol, + markBuffer, + desc.referencedTable.oldIndexToNewIndexPlusOne, + newLength + ); + break; case 'INDEX_REF_OR_NULL': result[key] = _compactColIndexOrNull( oldCol, @@ -564,6 +590,22 @@ function _compactColIndex( return newCol; } +function _compactColIndexInt32( + oldCol: Int32Array, + markBuffer: BitSet, + oldIndexToNewIndexPlusOne: Int32Array, + newLength: number +): Int32Array { + const newCol = new Int32Array(newLength); + let newIndex = 0; + for (let i = 0; i < oldCol.length; i++) { + if (checkBit(markBuffer, i)) { + newCol[newIndex++] = oldIndexToNewIndexPlusOne[oldCol[i]] - 1; + } + } + return newCol; +} + function _compactColIndexOrNull( oldCol: (number | null)[], markBuffer: BitSet, diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index 43db6eb47f..3b6e424499 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -4766,7 +4766,10 @@ export function computeStackTableFromRawStackTable( } // The frame column is a typed array in the derived stack table. - const frame = new Int32Array(rawStackTable.frame); + const frame = + rawStackTable.frame instanceof Int32Array + ? rawStackTable.frame + : new Int32Array(rawStackTable.frame); return { frame, diff --git a/src/test/integration/profiler-edit/__snapshots__/profiler-edit.test.ts.snap b/src/test/integration/profiler-edit/__snapshots__/profiler-edit.test.ts.snap index 9e919e59d0..8ffda5719f 100644 --- a/src/test/integration/profiler-edit/__snapshots__/profiler-edit.test.ts.snap +++ b/src/test/integration/profiler-edit/__snapshots__/profiler-edit.test.ts.snap @@ -87,7 +87,7 @@ Object { "markerSchema": Array [], "oscpu": "macOS 14.6.1", "pausedRanges": Array [], - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "a.out", "sampleUnits": Object { @@ -1452,7 +1452,7 @@ Object { "markerSchema": Array [], "oscpu": "macOS 14.6.1", "pausedRanges": Array [], - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "a.out", "sampleUnits": Object { @@ -2817,7 +2817,7 @@ Object { "markerSchema": Array [], "oscpu": "macOS 14.6.1", "pausedRanges": Array [], - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "a.out", "sampleUnits": Object { @@ -4182,7 +4182,7 @@ Object { "markerSchema": Array [], "oscpu": "macOS 14.6.1", "pausedRanges": Array [], - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "a.out", "sampleUnits": Object { diff --git a/src/test/store/__snapshots__/profile-view.test.ts.snap b/src/test/store/__snapshots__/profile-view.test.ts.snap index 738ec4d3d7..0e69b4671b 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -423,7 +423,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Firefox", "sourceURL": "", @@ -676,7 +676,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, diff --git a/src/test/unit/__snapshots__/profile-conversion.test.ts.snap b/src/test/unit/__snapshots__/profile-conversion.test.ts.snap index 1ef1824e9b..de79c9b9c5 100644 --- a/src/test/unit/__snapshots__/profile-conversion.test.ts.snap +++ b/src/test/unit/__snapshots__/profile-conversion.test.ts.snap @@ -591,7 +591,7 @@ Object { "oscpu": undefined, "physicalCPUs": undefined, "platform": undefined, - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "ART Trace (Android)", "sampleUnits": undefined, @@ -43096,7 +43096,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -83833,7 +83833,7 @@ Object { "oscpu": undefined, "physicalCPUs": undefined, "platform": undefined, - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "ART Trace (Android)", "sampleUnits": undefined, @@ -167262,7 +167262,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -329278,7 +329278,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Chrome Trace", "profilingEndTime": 119159778.026, @@ -329817,7 +329817,7 @@ Object { ], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -364259,7 +364259,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Chrome Trace", "profilingEndTime": 119159778.026, @@ -364798,7 +364798,7 @@ Object { ], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -399219,7 +399219,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Chrome Trace", "profilingEndTime": 66155012.423, @@ -400087,7 +400087,7 @@ Object { ], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -401927,7 +401927,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Chrome Trace", "sourceURL": "", @@ -401993,7 +401993,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [], + "frame": Int32Array [], "length": 0, "prefix": Array [], }, @@ -403765,7 +403765,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Chrome Trace", "profilingEndTime": 355035987.653, @@ -406722,7 +406722,7 @@ Object { ], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -407776,7 +407776,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Chrome Trace", "sourceURL": "", @@ -411314,7 +411314,7 @@ Object { ], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -413399,7 +413399,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Chrome Trace", "profilingEndTime": 66155012.423, @@ -414267,7 +414267,7 @@ Object { ], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -414544,7 +414544,7 @@ Object { "oscpu": undefined, "physicalCPUs": undefined, "platform": undefined, - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Firefox", "sampleUnits": undefined, @@ -417285,7 +417285,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -418704,7 +418704,7 @@ Object { "oscpu": undefined, "physicalCPUs": undefined, "platform": undefined, - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Firefox", "sampleUnits": undefined, @@ -429901,7 +429901,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -434833,7 +434833,7 @@ Object { "oscpu": undefined, "physicalCPUs": undefined, "platform": undefined, - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Firefox", "sampleUnits": undefined, @@ -439076,7 +439076,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -441509,7 +441509,7 @@ Object { "oscpu": undefined, "physicalCPUs": undefined, "platform": undefined, - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Firefox", "sampleUnits": undefined, @@ -454858,7 +454858,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -459410,7 +459410,7 @@ Object { "keepProfileThreadOrder": true, "markerSchema": Array [], "platform": "Android", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "com.example.sampleapplication", "sourceCodeIsNotOnSearchfox": true, @@ -499592,7 +499592,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -521502,7 +521502,7 @@ Object { "keepProfileThreadOrder": true, "markerSchema": Array [], "platform": "Android", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "com.example.sampleapplication", "sourceCodeIsNotOnSearchfox": true, @@ -559132,7 +559132,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -587927,7 +587927,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "target/debug/examples/work_log (dhat)", "sourceURL": "", @@ -594273,7 +594273,7 @@ Object { ], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 56, 55, @@ -597177,7 +597177,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Flamegraph", "sourceURL": "", @@ -600033,7 +600033,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -905382,7 +905382,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Flamegraph", "sourceURL": "", @@ -905556,7 +905556,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, diff --git a/src/test/unit/__snapshots__/profile-upgrading.test.ts.snap b/src/test/unit/__snapshots__/profile-upgrading.test.ts.snap index bd079be0b9..3715a6c4fa 100644 --- a/src/test/unit/__snapshots__/profile-upgrading.test.ts.snap +++ b/src/test/unit/__snapshots__/profile-upgrading.test.ts.snap @@ -40,7 +40,7 @@ Object { "oscpu": undefined, "physicalCPUs": undefined, "platform": undefined, - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Firefox", "sampleUnits": undefined, @@ -2781,7 +2781,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -7643,7 +7643,7 @@ Object { "misc": "rv:48.0", "oscpu": "Intel Mac OS X 10.11", "platform": "Macintosh", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Firefox", "stackwalk": 1, @@ -9019,7 +9019,7 @@ Object { "misc": "rv:48.0", "oscpu": "Intel Mac OS X 10.11", "platform": "Macintosh", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Firefox", "stackwalk": 1, @@ -10563,7 +10563,7 @@ Object { "misc": "rv:48.0", "oscpu": "Intel Mac OS X 10.11", "platform": "Macintosh", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Firefox", "stackwalk": 1, diff --git a/src/types/profile.ts b/src/types/profile.ts index ad504ac933..fd1ce620f8 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -60,7 +60,7 @@ export type Pid = string; * to storing them as actual lists of frames. */ export type RawStackTable = { - frame: IndexIntoFrameTable[]; + frame: IndexIntoFrameTable[] | Int32Array; prefix: Array; length: number; }; From bdc1c36eedce2cd445726a9d0b28b72082ccad4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Tue, 16 Jun 2026 16:55:20 +0300 Subject: [PATCH 07/36] Bump profiler-cli version to 0.3.0 (#6104) --- profiler-cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/profiler-cli/package.json b/profiler-cli/package.json index 5d528a63d7..e5dd4fbae9 100644 --- a/profiler-cli/package.json +++ b/profiler-cli/package.json @@ -1,6 +1,6 @@ { "name": "@firefox-devtools/profiler-cli", - "version": "0.2.1", + "version": "0.3.0", "description": "Command-line interface for querying Firefox Profiler profiles with persistent daemon sessions", "scripts": { "prepublishOnly": "node ../scripts/verify-profiler-cli-build.mjs" From 0a21ce1f1adfcd0691795cd4d4a1e84b6df7fcf7 Mon Sep 17 00:00:00 2001 From: "depfu[bot]" <23717796+depfu[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:03:08 +0200 Subject: [PATCH 08/36] Update all Yarn dependencies (2026-06-17) (#6106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com> Co-authored-by: Nazım Can Altınova --- package.json | 6 +++--- yarn.lock | 24 ++++++++++++------------ 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index b0f2b85b7d..9e414c19de 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "@codemirror/lang-rust": "^6.0.2", "@codemirror/language": "^6.12.3", "@codemirror/state": "^6.6.0", - "@codemirror/view": "^6.43.0", + "@codemirror/view": "^6.43.1", "@firefox-devtools/react-contextmenu": "^5.2.4", "@fluent/bundle": "^0.19.1", "@fluent/langneg": "^0.7.0", @@ -100,7 +100,7 @@ "mixedtuplemap": "^1.0.0", "namedtuplemap": "^1.0.0", "photon-colors": "^3.3.2", - "protobufjs": "^8.6.3", + "protobufjs": "^8.6.4", "query-string": "^9.4.0", "react": "~19.1.8", "react-dom": "~19.1.8", @@ -132,7 +132,7 @@ "@types/jest": "^30.0.0", "@types/node": "^24.13.2", "@types/query-string": "^6.3.0", - "@types/react": "^19.2.15", + "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@types/redux-logger": "^3.0.6", "@types/tgwf__co2": "^0.14.2", diff --git a/yarn.lock b/yarn.lock index 500d485b0f..cec0b57997 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1123,10 +1123,10 @@ dependencies: "@marijn/find-cluster-break" "^1.0.0" -"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.43.0": - version "6.43.0" - resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.43.0.tgz#a577da65f1d5d8f7cbf08e14849284c12f38365a" - integrity sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA== +"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.43.1": + version "6.43.1" + resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.43.1.tgz#bfe3ea568e38812631749c1b6c9c7869784143a5" + integrity sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw== dependencies: "@codemirror/state" "^6.6.0" crelt "^1.0.6" @@ -2542,10 +2542,10 @@ resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.2.3.tgz#c1e305d15a52a3e508d54dca770d202cb63abf2c" integrity sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ== -"@types/react@^19.2.15": - version "19.2.15" - resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.15.tgz#9e2c6a4251a290f5c525c3143caa872fa6e01e0d" - integrity sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q== +"@types/react@^19.2.17": + version "19.2.17" + resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.17.tgz#dccac365baa0f1734ec270ff4b51c89465e8dc7f" + integrity sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw== dependencies: csstype "^3.2.2" @@ -9210,10 +9210,10 @@ proto-list@~1.2.1: resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA== -protobufjs@^8.6.3: - version "8.6.3" - resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-8.6.3.tgz#67dadb85a6802fe5df5e2221ab50cee1017b77c6" - integrity sha512-alQyzT0j401LGBtwsqu6uprjR6pfNH1UJf9N6GBFMjIcd+HzTe0/HrjAbFCqun+zvnfLarrxAtMM2xvZ+kFZ5A== +protobufjs@^8.6.4: + version "8.6.4" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-8.6.4.tgz#61c1589f095d096cf89e77d1e6dfa5ca1c0d70bf" + integrity sha512-/+XMv9JalknuncEJSwsyEVlwcxVLKx2iaoSUXFZA86MJkdqyOdfrlB1sB7S6aKyUk9tl20YY+SgQe5J2sJHTcg== dependencies: long "^5.3.2" From 6568e16e81f5f244cd4938a19a5ba9a44cf6152c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Wed, 17 Jun 2026 14:27:37 +0200 Subject: [PATCH 09/36] Add the cli package name as a constant into the codebase This will allow us to see the package name from the cli source code and suggest updating it via npm. --- profiler-cli/src/constants.ts | 6 ++++++ scripts/build-profiler-cli.mjs | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/profiler-cli/src/constants.ts b/profiler-cli/src/constants.ts index 3050cbaaac..d8be3144d0 100644 --- a/profiler-cli/src/constants.ts +++ b/profiler-cli/src/constants.ts @@ -8,6 +8,7 @@ // These globals are defined via esbuild's define option. declare const __BUILD_HASH__: string; +declare const __PACKAGE_NAME__: string; declare const __VERSION__: string; /** @@ -16,6 +17,11 @@ declare const __VERSION__: string; */ export const BUILD_HASH = __BUILD_HASH__; +/** + * Package name from profiler-cli/package.json, injected at build time. + */ +export const PACKAGE_NAME = __PACKAGE_NAME__; + /** * Package version from profiler-cli/package.json, injected at build time. */ diff --git a/scripts/build-profiler-cli.mjs b/scripts/build-profiler-cli.mjs index 9e90b69c28..a8f8775c44 100644 --- a/scripts/build-profiler-cli.mjs +++ b/scripts/build-profiler-cli.mjs @@ -5,7 +5,7 @@ import esbuild from 'esbuild'; import { chmodSync, readFileSync } from 'fs'; import { nodeBaseConfig } from './lib/esbuild-configs.mjs'; -const { version } = JSON.parse( +const { name, version } = JSON.parse( readFileSync(new URL('../profiler-cli/package.json', import.meta.url), 'utf8') ); @@ -22,6 +22,7 @@ const profilerCliConfig = { }, define: { __BUILD_HASH__: JSON.stringify(BUILD_HASH), + __PACKAGE_NAME__: JSON.stringify(name), __VERSION__: JSON.stringify(version), // SOURCE_MAP_WORKER_PATH is injected by the browser build. The CLI doesn't // use source map workers but the shared code references this constant. From 53a30710ffc076cc137b282511b7b4c858952022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Wed, 17 Jun 2026 14:05:12 +0200 Subject: [PATCH 10/36] Show more user friendly errors for unsupported profile version in both the frontend and the cli I initially wanted to add extra information to the profile version errors in the cli, because currently we don't give any hint about how to update it. But while doing that, I realized that we could also improve the error handling of the frontend a bit more. The old frontend error was a non-localized text. This commit creates a new localized text for this type of error and serializes it in a more friendly way. Probably it's a bit of an overkill for this error as it should ideally be not seen by the users, but there were existing errors with the same way, so I wanted to be consistent. Also, the cli now shows a tip about how to update the cli. --- locales/en-US/app.ftl | 6 ++++ profiler-cli/src/daemon.ts | 32 +++++++++++++++---- src/components/app/AppViewRouter.tsx | 3 ++ src/profile-logic/errors.ts | 27 ++++++++++++++++ src/profile-logic/gecko-profile-versioning.ts | 9 +++--- src/profile-logic/process-profile.ts | 6 ++++ .../processed-profile-versioning.ts | 9 +++--- 7 files changed, 78 insertions(+), 14 deletions(-) diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl index 5625f479b5..86409569e7 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -54,6 +54,12 @@ AppViewRouter--error-from-localhost-url-safari = this page in { -firefox-brand-name } or Chrome instead. .title = Safari cannot import local profiles +# This error message is displayed when the profile is in a newer format version +# than this build of the Profiler is able to read. +AppViewRouter--error-profile-version = + This profile uses a format that is not supported by this version of { -profiler-brand-name }. + Try refreshing the page to check if there is an update available for { -profiler-brand-name }. + AppViewRouter--route-not-found--home = .specialMessage = The URL you tried to reach was not recognized. diff --git a/profiler-cli/src/daemon.ts b/profiler-cli/src/daemon.ts index 992b827f62..44e2e3e9ec 100644 --- a/profiler-cli/src/daemon.ts +++ b/profiler-cli/src/daemon.ts @@ -11,6 +11,7 @@ import * as net from 'net'; import * as fs from 'fs'; import { ProfileQuerier } from '../../src/profile-query'; import type { LoadPhase } from '../../src/profile-query/loader'; +import { ProfileVersionError } from 'firefox-profiler/profile-logic/errors'; import type { ClientCommand, ClientMessage, @@ -28,7 +29,27 @@ import { ensureSessionDir, } from './session'; import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; -import { BUILD_HASH } from './constants'; +import { BUILD_HASH, PACKAGE_NAME } from './constants'; + +/** + * Build a user-facing message for a profile load failure. When the profile is + * too new for this build, append instructions on how to update the CLI. + */ +function formatProfileLoadError(error: unknown): string { + if ( + error instanceof ProfileVersionError || + (error instanceof Error && error.name === 'ProfileVersionError') + ) { + const versionError = error as ProfileVersionError; + return ( + `This profile is version ${versionError.profileVersion}, but this profiler-cli only ` + + `supports up to version ${versionError.supportedVersion} of the ${versionError.formatName} profile format.\n` + + `Update to the latest version with:\n` + + ` npm install -g ${PACKAGE_NAME}@latest` + ); + } + return error instanceof Error ? error.message : String(error); +} export class Daemon { private querier: ProfileQuerier | null = null; @@ -41,7 +62,7 @@ export class Daemon { private profilePath: string; private symbolServerUrl?: string; private loadPhase: LoadPhase = 'fetching'; - private profileLoadError: Error | null = null; + private profileLoadError: string | null = null; constructor( sessionDir: string, @@ -149,8 +170,7 @@ export class Daemon { console.log('Profile loaded successfully'); } catch (error) { console.error(`Failed to load profile: ${error}`); - this.profileLoadError = - error instanceof Error ? error : new Error(String(error)); + this.profileLoadError = formatProfileLoadError(error); } } @@ -210,7 +230,7 @@ export class Daemon { if (this.profileLoadError) { return { type: 'error', - error: `Profile load failed: ${this.profileLoadError.message}`, + error: `Profile load failed: ${this.profileLoadError}`, }; } switch (this.loadPhase) { @@ -245,7 +265,7 @@ export class Daemon { if (this.profileLoadError) { return { type: 'error', - error: `Profile load failed: ${this.profileLoadError.message}`, + error: `Profile load failed: ${this.profileLoadError}`, }; } if (this.loadPhase !== 'ready' || !this.querier) { diff --git a/src/components/app/AppViewRouter.tsx b/src/components/app/AppViewRouter.tsx index fc04a205ff..8c025eab8b 100644 --- a/src/components/app/AppViewRouter.tsx +++ b/src/components/app/AppViewRouter.tsx @@ -87,6 +87,9 @@ class AppViewRouterImpl extends PureComponent { if (view.error) { if (view.error.name === 'SafariLocalhostHTTPLoadError') { message = 'AppViewRouter--error-from-localhost-url-safari'; + } else if (view.error.name === 'ProfileVersionError') { + message = 'AppViewRouter--error-profile-version'; + additionalMessage =

{view.error.toString()}

; } else { console.error(view.error); additionalMessage = ( diff --git a/src/profile-logic/errors.ts b/src/profile-logic/errors.ts index 863abc0a2f..30d8324079 100644 --- a/src/profile-logic/errors.ts +++ b/src/profile-logic/errors.ts @@ -21,3 +21,30 @@ export class SymbolsNotFoundError extends Error { this.errors = errors; } } + +// Thrown when a profile's format version is newer than the most recent version +// understood by this build. The message is deliberately neutral and only states +// the facts. Consumers (the web app, the CLI) detect this by name and append +// their own advice on how to update, since that advice is frontend-specific. +export class ProfileVersionError extends Error { + formatName: string; + profileVersion: number; + supportedVersion: number; + + constructor( + formatName: string, + profileVersion: number, + supportedVersion: number + ) { + super( + `Unable to parse a ${formatName} profile of version ${profileVersion}. ` + + `The most recent version understood by this build is version ${supportedVersion}.` + ); + // Workaround for a babel issue when extending Errors + (this as any).__proto__ = ProfileVersionError.prototype; + this.name = 'ProfileVersionError'; + this.formatName = formatName; + this.profileVersion = profileVersion; + this.supportedVersion = supportedVersion; + } +} diff --git a/src/profile-logic/gecko-profile-versioning.ts b/src/profile-logic/gecko-profile-versioning.ts index b15d93c3ba..38923e9c45 100644 --- a/src/profile-logic/gecko-profile-versioning.ts +++ b/src/profile-logic/gecko-profile-versioning.ts @@ -14,6 +14,7 @@ import { StringTable } from '../utils/string-table'; import { GECKO_PROFILE_VERSION } from '../app-logic/constants'; +import { ProfileVersionError } from './errors'; // Gecko profiles before version 1 did not have a profile.meta.version field. // Treat those as version zero. @@ -45,10 +46,10 @@ export function upgradeGeckoProfileToCurrentVersion(json: unknown) { } if (profileVersion > GECKO_PROFILE_VERSION) { - throw new Error( - `Unable to parse a Gecko profile of version ${profileVersion}, most likely profiler.firefox.com needs to be refreshed. ` + - `The most recent version understood by this version of profiler.firefox.com is version ${GECKO_PROFILE_VERSION}.\n` + - 'You can try refreshing this page in case profiler.firefox.com has updated in the meantime.' + throw new ProfileVersionError( + 'Gecko', + profileVersion, + GECKO_PROFILE_VERSION ); } diff --git a/src/profile-logic/process-profile.ts b/src/profile-logic/process-profile.ts index d1f96c4c90..eaf192e0e7 100644 --- a/src/profile-logic/process-profile.ts +++ b/src/profile-logic/process-profile.ts @@ -23,6 +23,7 @@ import { verifyMagic, SIMPLEPERF as SIMPLEPERF_MAGIC } from '../utils/magic'; import { attemptToUpgradeProcessedProfileThroughMutation } from './processed-profile-versioning'; import type { ProfileUpgradeInfo } from './processed-profile-versioning'; import { upgradeGeckoProfileToCurrentVersion } from './gecko-profile-versioning'; +import { ProfileVersionError } from './errors'; import { isPerfScriptFormat, convertPerfScriptProfile, @@ -2313,6 +2314,11 @@ export async function unserializeProfileOfArbitraryFormat( return processGeckoOrDevToolsProfile(json); } catch (e) { console.error('UnserializationError:', e); + // A version mismatch is already a clear, user-facing error. Re-throw it + // as-is so each frontend can detect it and add its own update advice. + if (e instanceof ProfileVersionError) { + throw e; + } throw new Error(`Unserializing the profile failed: ${e}`); } } diff --git a/src/profile-logic/processed-profile-versioning.ts b/src/profile-logic/processed-profile-versioning.ts index 16ab69e409..62dd36070d 100644 --- a/src/profile-logic/processed-profile-versioning.ts +++ b/src/profile-logic/processed-profile-versioning.ts @@ -18,6 +18,7 @@ import { ResourceType } from 'firefox-profiler/types'; import { StringTable } from '../utils/string-table'; import { timeCode } from '../utils/time-code'; import { PROCESSED_PROFILE_VERSION } from '../app-logic/constants'; +import { ProfileVersionError } from './errors'; import type { Profile } from 'firefox-profiler/types'; export type ProfileUpgradeInfo = { @@ -85,10 +86,10 @@ export function attemptToUpgradeProcessedProfileThroughMutation( } if (profileVersion > PROCESSED_PROFILE_VERSION) { - throw new Error( - `Unable to parse a processed profile of version ${profileVersion}, most likely profiler.firefox.com needs to be refreshed. ` + - `The most recent version understood by this version of profiler.firefox.com is version ${PROCESSED_PROFILE_VERSION}.\n` + - 'You can try refreshing this page in case profiler.firefox.com has updated in the meantime.' + throw new ProfileVersionError( + 'processed', + profileVersion, + PROCESSED_PROFILE_VERSION ); } From 34d6f8b98b435b01a6e587eaa7fdbc6a1982cf52 Mon Sep 17 00:00:00 2001 From: Pontoon Date: Thu, 18 Jun 2026 10:20:25 +0000 Subject: [PATCH 11/36] Pontoon/Firefox Profiler: Update Italian (it) Co-authored-by: Francesco Lodolo [:flod] (it) --- locales/it/app.ftl | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/locales/it/app.ftl b/locales/it/app.ftl index 09ba75b1cd..29b5ef0bc8 100644 --- a/locales/it/app.ftl +++ b/locales/it/app.ftl @@ -46,6 +46,11 @@ AppViewRouter--error-compare = Impossibile recuperare i profili. # https://profiler.firefox.com/from-url/http%3A%2F%2F127.0.0.1%3A3000%2Fprofile.json/ AppViewRouter--error-from-localhost-url-safari = A causa di una limitazione specifica di Safari, { -profiler-brand-name } non può importare profili dal dispositivo locale in questo browser. Aprire questa pagina in { -firefox-brand-name } o Chrome. .title = Impossibile importare profili locali in Safari +# This error message is displayed when the profile is in a newer format version +# than this build of the Profiler is able to read. +AppViewRouter--error-profile-version = + Questo profilo utilizza un formato non compatibile con questa versione di { -profiler-brand-name }. + Ricarica la pagina per verificare se è disponibile un aggiornamento per { -profiler-brand-name }. AppViewRouter--route-not-found--home = .specialMessage = L’URL che hai cercato di raggiungere non è stato riconosciuto. From b1ad832a6fe6ed94341e2d186e7d92bc91947db3 Mon Sep 17 00:00:00 2001 From: Pontoon Date: Thu, 18 Jun 2026 10:50:26 +0000 Subject: [PATCH 12/36] Pontoon/Firefox Profiler: Update French (fr), Dutch (nl) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Théo Chevalier (fr) Co-authored-by: Mark Heijl (nl) --- locales/fr/app.ftl | 5 +++++ locales/nl/app.ftl | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/locales/fr/app.ftl b/locales/fr/app.ftl index e96cdee9a3..4ac9c5e908 100644 --- a/locales/fr/app.ftl +++ b/locales/fr/app.ftl @@ -46,6 +46,11 @@ AppViewRouter--error-compare = Impossible de récupérer les profils. # https://profiler.firefox.com/from-url/http%3A%2F%2F127.0.0.1%3A3000%2Fprofile.json/ AppViewRouter--error-from-localhost-url-safari = En raison d’une limitation spécifique à Safari, { -profiler-brand-name } ne peut pas importer de profils depuis la machine locale dans ce navigateur. Veuillez ouvrir cette page dans { -firefox-brand-name } ou Chrome à la place. .title = Safari ne peut pas importer de profils locaux +# This error message is displayed when the profile is in a newer format version +# than this build of the Profiler is able to read. +AppViewRouter--error-profile-version = + Ce profil utilise un format qui n’est pas pris en charge par cette version de { -profiler-brand-name }. + Essayez d’actualiser la page pour vérifier si une mise à jour est disponible pour { -profiler-brand-name }. AppViewRouter--route-not-found--home = .specialMessage = L’URL que vous avez tenté d’atteindre n’a pas été trouvée diff --git a/locales/nl/app.ftl b/locales/nl/app.ftl index ca9147c060..57a9fd737e 100644 --- a/locales/nl/app.ftl +++ b/locales/nl/app.ftl @@ -49,6 +49,11 @@ AppViewRouter--error-from-localhost-url-safari = profielen van de lokale computer in deze browser importeren. Open in plaats daarvan deze pagina in { -firefox-brand-name } of Chrome. .title = Safari kan geen lokale profielen importeren +# This error message is displayed when the profile is in a newer format version +# than this build of the Profiler is able to read. +AppViewRouter--error-profile-version = + Dit profiel gebruikt een indeling die niet door deze versie van { -profiler-brand-name } wordt ondersteund. + Probeer de pagina te vernieuwen om te controleren of er een update beschikbaar is voor { -profiler-brand-name }. AppViewRouter--route-not-found--home = .specialMessage = De URL die u probeerde te bereiken, werd niet herkend. From 97e488b3dc48d73d0a6529b9714ece9b1e142c1d Mon Sep 17 00:00:00 2001 From: Pontoon Date: Thu, 18 Jun 2026 14:30:29 +0000 Subject: [PATCH 13/36] Pontoon/Firefox Profiler: Update German (de) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Michael Köhler (de) --- locales/de/app.ftl | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/locales/de/app.ftl b/locales/de/app.ftl index 85f964073f..178ac76272 100644 --- a/locales/de/app.ftl +++ b/locales/de/app.ftl @@ -46,6 +46,11 @@ AppViewRouter--error-compare = Die Profile konnten nicht abgerufen werden. # https://profiler.firefox.com/from-url/http%3A%2F%2F127.0.0.1%3A3000%2Fprofile.json/ AppViewRouter--error-from-localhost-url-safari = Aufgrund einer spezifischen Einschränkung in Safari kann { -profiler-brand-name } Profile vom lokalen Computer nicht in diesem Browser importieren. Bitte öffnen Sie diese Seite stattdessen in { -firefox-brand-name } oder Chrome. .title = Safari kann lokale Profile nicht importieren +# This error message is displayed when the profile is in a newer format version +# than this build of the Profiler is able to read. +AppViewRouter--error-profile-version = + Dieses Profil verwendet ein Format, das von dieser Version von { -profiler-brand-name } nicht unterstützt wird. + Versuchen Sie, die Seite zu aktualisieren, um zu überprüfen, ob ein Update für { -profiler-brand-name } verfügbar ist. AppViewRouter--route-not-found--home = .specialMessage = Die URL, die Sie erreichen wollten, wurde nicht erkannt. From 9e0158ff17dce45a36d182a9cbe75cba0f881863 Mon Sep 17 00:00:00 2001 From: Pontoon Date: Thu, 18 Jun 2026 14:40:30 +0000 Subject: [PATCH 14/36] Pontoon/Firefox Profiler: Update Frisian (fy-NL) Co-authored-by: Fjoerfoks (fy-NL) --- locales/fy-NL/app.ftl | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/locales/fy-NL/app.ftl b/locales/fy-NL/app.ftl index addadbbaa5..2fbae9169a 100644 --- a/locales/fy-NL/app.ftl +++ b/locales/fy-NL/app.ftl @@ -49,6 +49,11 @@ AppViewRouter--error-from-localhost-url-safari = profilen fan de lokale kompjûter yn dizze browser ymportearje. Iepenje yn stee dêrfan dizze side yn { -firefox-brand-name } of Chrome. .title = Safari kan geen lokale profielen importeren +# This error message is displayed when the profile is in a newer format version +# than this build of the Profiler is able to read. +AppViewRouter--error-profile-version = + Dit profyl brûkt in yndieling dy’t net troch dizze ferzje fan { -profiler-brand-name } stipe wurdt. + Probearje de side te ferfarskjen om te kontrolearjen oft der in fernijing beskikber is foar { -profiler-brand-name }. AppViewRouter--route-not-found--home = .specialMessage = De URL dy’t jo probearre te berikken, waard net werkend. From 3ce49d9816450493609997f940fb31185e0e344c Mon Sep 17 00:00:00 2001 From: Pontoon Date: Thu, 18 Jun 2026 16:10:25 +0000 Subject: [PATCH 15/36] Pontoon/Firefox Profiler: Update English (Great Britain) (en-GB) Co-authored-by: Ian Neal (en-GB) --- locales/en-GB/app.ftl | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/locales/en-GB/app.ftl b/locales/en-GB/app.ftl index 7ea46c3eba..e40039d84c 100644 --- a/locales/en-GB/app.ftl +++ b/locales/en-GB/app.ftl @@ -49,6 +49,11 @@ AppViewRouter--error-from-localhost-url-safari = to import profiles from the local machine in this browser. Please open this page in { -firefox-brand-name } or Chrome instead. .title = Safari cannot import local profiles +# This error message is displayed when the profile is in a newer format version +# than this build of the Profiler is able to read. +AppViewRouter--error-profile-version = + This profile uses a format that is not supported by this version of { -profiler-brand-name }. + Try refreshing the page to check if there is an update available for { -profiler-brand-name }. AppViewRouter--route-not-found--home = .specialMessage = The URL you tried to reach was not recognised. From 336c940729601387fc6a70c7cdbb17f1eb5ff344 Mon Sep 17 00:00:00 2001 From: Pontoon Date: Thu, 18 Jun 2026 16:20:22 +0000 Subject: [PATCH 16/36] Pontoon/Firefox Profiler: Update Chinese (Taiwan) (zh-TW) Co-authored-by: Pin-guang Chen (zh-TW) --- locales/zh-TW/app.ftl | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/locales/zh-TW/app.ftl b/locales/zh-TW/app.ftl index bc16bcecaf..6f3fb97161 100644 --- a/locales/zh-TW/app.ftl +++ b/locales/zh-TW/app.ftl @@ -46,6 +46,11 @@ AppViewRouter--error-compare = 無法取得效能檢測檔。 # https://profiler.firefox.com/from-url/http%3A%2F%2F127.0.0.1%3A3000%2Fprofile.json/ AppViewRouter--error-from-localhost-url-safari = 由於 Safari 的特殊限制,{ -profiler-brand-name } 無法從這套瀏覽器自本機匯入效能檢測檔。請改用 { -firefox-brand-name } 或 Chrome 開啟此頁面。 .title = 無法使用 Safari 匯入本機效能檢測檔 +# This error message is displayed when the profile is in a newer format version +# than this build of the Profiler is able to read. +AppViewRouter--error-profile-version = + 此版本的 { -profiler-brand-name } 不支援此效能檢測檔使用的格式。 + 請嘗試重新整理頁面,看看是否有 { -profiler-brand-name } 的更新可以使用。 AppViewRouter--route-not-found--home = .specialMessage = 無法處理您嘗試開啟的網址。 From 40ea2356163291fbebd6375c49db01b937f9f8aa Mon Sep 17 00:00:00 2001 From: Pontoon Date: Thu, 18 Jun 2026 16:40:33 +0000 Subject: [PATCH 17/36] Pontoon/Firefox Profiler: Update Swedish (sv-SE) Co-authored-by: Luna Jernberg (sv-SE) --- locales/sv-SE/app.ftl | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/locales/sv-SE/app.ftl b/locales/sv-SE/app.ftl index 79e9a8fd44..1799a97736 100644 --- a/locales/sv-SE/app.ftl +++ b/locales/sv-SE/app.ftl @@ -49,6 +49,11 @@ AppViewRouter--error-from-localhost-url-safari = { -profiler-brand-name } importera profiler från den lokala datorn i den här webbläsaren. Öppna den här sidan i { -firefox-brand-name } eller Chrome istället. .title = Safari kan inte importera lokala profiler +# This error message is displayed when the profile is in a newer format version +# than this build of the Profiler is able to read. +AppViewRouter--error-profile-version = + Den här profilen använder ett format som inte stöds av den här versionen av { -profiler-brand-name }. + Testa att uppdatera sidan för att kontrollera om det finns en tillgänglig uppdatering för { -profiler-brand-name }. AppViewRouter--route-not-found--home = .specialMessage = Webbadressen du försökte nå kändes inte igen. From 5fb6bf81844e479385b7f5b82be81a52be2054c7 Mon Sep 17 00:00:00 2001 From: Pontoon Date: Thu, 18 Jun 2026 21:50:22 +0000 Subject: [PATCH 18/36] Pontoon/Firefox Profiler: Update Interlingua (ia) Co-authored-by: Melo46 (ia) --- locales/ia/app.ftl | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/locales/ia/app.ftl b/locales/ia/app.ftl index 119fd5d9b8..fefc4679d5 100644 --- a/locales/ia/app.ftl +++ b/locales/ia/app.ftl @@ -49,6 +49,11 @@ AppViewRouter--error-from-localhost-url-safari = importar profilos ab le local apparato in iste browser. Per favor aperi in vice iste pagina in { -firefox-brand-name } o Chrome. .title = Safari non pote importar profilos local +# This error message is displayed when the profile is in a newer format version +# than this build of the Profiler is able to read. +AppViewRouter--error-profile-version = + Iste profilo usa un formato que non es supportate per iste version de { -profiler-brand-name }. + Essaya refrescar le pagina pro verificar si il ha un actualisation disponibile pro { -profiler-brand-name }. AppViewRouter--route-not-found--home = .specialMessage = Le URL que tu tentava attinger non ha essite recognoscite. From f26beca96a06547346c02123f6ca989e9ac72437 Mon Sep 17 00:00:00 2001 From: Pontoon Date: Fri, 19 Jun 2026 07:00:28 +0000 Subject: [PATCH 19/36] Pontoon/Firefox Profiler: Update Russian (ru) Co-authored-by: Valery Ledovskoy (ru) --- locales/ru/app.ftl | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/locales/ru/app.ftl b/locales/ru/app.ftl index 70f9a7633c..c30a2ab90b 100644 --- a/locales/ru/app.ftl +++ b/locales/ru/app.ftl @@ -49,6 +49,11 @@ AppViewRouter--error-from-localhost-url-safari = импортировать профили с локальной машины в этот браузер. Пожалуйста, откройте эту страницу в { -firefox-brand-name } или Chrome. .title = Safari не может импортировать локальные профили +# This error message is displayed when the profile is in a newer format version +# than this build of the Profiler is able to read. +AppViewRouter--error-profile-version = + Этот профиль использует формат, который не поддерживается этой версией { -profiler-brand-name }. + Попробуйте обновить страницу, чтобы проверить, доступно ли обновление для { -profiler-brand-name }. AppViewRouter--route-not-found--home = .specialMessage = URL-адрес, который вы пытались открыть, не был распознан. From b1f19f25b62cac4eae068339f2586cded279c03f Mon Sep 17 00:00:00 2001 From: Pontoon Date: Sun, 21 Jun 2026 11:30:30 +0000 Subject: [PATCH 20/36] Pontoon/Firefox Profiler: Update Greek (el) Co-authored-by: Jim Spentzos (el) --- locales/el/app.ftl | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/locales/el/app.ftl b/locales/el/app.ftl index 5f879431ee..d3df514dce 100644 --- a/locales/el/app.ftl +++ b/locales/el/app.ftl @@ -49,6 +49,11 @@ AppViewRouter--error-from-localhost-url-safari = εισαγάγει προφίλ από τη συσκευή σε αυτό το πρόγραμμα περιήγησης. Ανοίξτε αυτήν τη σελίδα στο { -firefox-brand-name } ή το Chrome. .title = Το Safari δεν μπορεί να εισαγάγει τοπικά προφίλ +# This error message is displayed when the profile is in a newer format version +# than this build of the Profiler is able to read. +AppViewRouter--error-profile-version = + Αυτό το προφίλ χρησιμοποιεί μια μορφή που δεν υποστηρίζεται από αυτήν την έκδοση του { -profiler-brand-name }. + Δοκιμάστε να ανανεώσετε τη σελίδα για να ελέγξετε αν διατίθεται ενημέρωση για το { -profiler-brand-name }. AppViewRouter--route-not-found--home = .specialMessage = Δεν αναγνωρίστηκε το URL που προσπαθήσατε να μεταβείτε. @@ -894,6 +899,10 @@ TrackMemoryGraph--relative-memory-at-this-time2 = { $value } # $value (String) - the memory range across the graph (e.g. "5MB") TrackMemoryGraph--memory-range-in-graph2 = { $value } .label = εύρος μνήμης στο γράφημα +# Variables: +# $value (String) - count of allocations and deallocations since the previous sample +TrackMemoryGraph--allocations-and-deallocations-since-the-previous-sample2 = { $value } + .label = εκχωρήσεις και αποδεσμεύσεις από το προηγούμενο δείγμα ## TrackProcessCPUGraph ## This is used to show the CPU usage of a process over time in the timeline. @@ -948,6 +957,11 @@ TrackPower--tooltip-average-power-watt = { $value } W # $value (String) - the power value at this location TrackPower--tooltip-average-power-milliwatt = { $value } mW .label = Μέση ισχύς στην τρέχουσα επιλογή +# This is used in the tooltip when the power value uses the microwatt unit. +# Variables: +# $value (String) - the power value at this location +TrackPower--tooltip-average-power-microwatt = { $value } μW + .label = Μέση ισχύς στην τρέχουσα επιλογή # This is used in the tooltip when the energy used in the current range uses the # kilowatt-hour unit. # Variables: From 9f08129e083a963060f33e85102af2934972f258 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:45:33 -0400 Subject: [PATCH 21/36] Split getSelfAndTotal. When we add the function list, we'll want to be able to sort the table by either the self column or the total column. For that use case it makes sense to have separate methods to query these values. Functionally neutral change. --- src/profile-logic/call-tree.ts | 41 +++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/src/profile-logic/call-tree.ts b/src/profile-logic/call-tree.ts index 49c62f5e0f..88858225e9 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -105,7 +105,8 @@ interface CallTreeInternal { hasChildren(callNodeIndex: IndexIntoCallNodeTable): boolean; createChildren(nodeIndex: IndexIntoCallNodeTable): CallNodeChildren; createRoots(): CallNodeChildren; - getSelfAndTotal(nodeIndex: IndexIntoCallNodeTable): SelfAndTotal; + getSelf(nodeIndex: IndexIntoCallNodeTable): number; + getTotal(nodeIndex: IndexIntoCallNodeTable): number; findHeaviestPathInSubtree( callNodeIndex: IndexIntoCallNodeTable ): CallNodePath; @@ -171,10 +172,12 @@ export class CallTreeInternalNonInverted implements CallTreeInternal { return this._callNodeHasChildren[callNodeIndex] !== 0; } - getSelfAndTotal(callNodeIndex: IndexIntoCallNodeTable): SelfAndTotal { - const self = this._callTreeTimings.self[callNodeIndex]; - const total = this._callTreeTimings.total[callNodeIndex]; - return { self, total }; + getSelf(callNodeIndex: IndexIntoCallNodeTable): number { + return this._callTreeTimings.self[callNodeIndex]; + } + + getTotal(callNodeIndex: IndexIntoCallNodeTable): number { + return this._callTreeTimings.total[callNodeIndex]; } findHeaviestPathInSubtree( @@ -216,11 +219,12 @@ export class CallTreeInternalFunctionList implements CallTreeInternal { return this._timings.sortedFuncs; } - getSelfAndTotal(nodeIndex: IndexIntoCallNodeTable): SelfAndTotal { - return { - self: this._timings.funcSelf[nodeIndex], - total: this._timings.funcTotal[nodeIndex], - }; + getSelf(nodeIndex: IndexIntoCallNodeTable): number { + return this._timings.funcSelf[nodeIndex]; + } + + getTotal(nodeIndex: IndexIntoCallNodeTable): number { + return this._timings.funcTotal[nodeIndex]; } findHeaviestPathInSubtree( @@ -288,13 +292,19 @@ class CallTreeInternalInverted implements CallTreeInternal { return children; } - getSelfAndTotal(callNodeIndex: IndexIntoCallNodeTable): SelfAndTotal { + getSelf(callNodeIndex: IndexIntoCallNodeTable): number { + if (this._callNodeInfo.isRoot(callNodeIndex)) { + return this._totalPerRootFunc[callNodeIndex]; + } + return 0; + } + + getTotal(callNodeIndex: IndexIntoCallNodeTable): number { if (this._callNodeInfo.isRoot(callNodeIndex)) { - const total = this._totalPerRootFunc[callNodeIndex]; - return { self: total, total }; + return this._totalPerRootFunc[callNodeIndex]; } const { total } = this._getTotalAndHasChildren(callNodeIndex); - return { self: 0, total }; + return total; } _getTotalAndHasChildren( @@ -445,7 +455,8 @@ export class CallTree { this._thread.funcTable.name[funcIndex] ); - const { self, total } = this._internal.getSelfAndTotal(callNodeIndex); + const total = this._internal.getTotal(callNodeIndex); + const self = this._internal.getSelf(callNodeIndex); const totalRelative = total / this._rootTotalSummary; const selfRelative = self / this._rootTotalSummary; From 0259de02e4b02b275c7beea39dcd9155fb8a99ae Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:45:53 -0400 Subject: [PATCH 22/36] Change sidebar splitter CSS to only apply to the sidebar, not to all splitters under .DetailsContainer. For the function list, we'll want to split the "main content" of the panel into the list on the left and the "wings" on the right, with a splitter. But this splitter should look like a normal splitter, not like the extra thick sidebar splitter. But these sidebar splitter rules were applying to all splitters under .DetailsContainer; this reduces the scope to only apply to the actual sidebar splitter. --- src/components/app/DetailsContainer.css | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/components/app/DetailsContainer.css b/src/components/app/DetailsContainer.css index 5c24522df0..083505c802 100644 --- a/src/components/app/DetailsContainer.css +++ b/src/components/app/DetailsContainer.css @@ -6,22 +6,23 @@ contain: size; } -.DetailsContainer .resizableWithSplitterInner > * { - min-width: 0; - flex: 1; -} - .DetailsContainerResizableSidebarWrapper { max-width: 600px; } /* overriding defaults from ResizableWithSplitter.css */ -.DetailsContainer .resizableWithSplitterSplitter { +.DetailsContainerResizableSidebarWrapper > .resizableWithSplitterSplitter { border-top: 1px solid var(--panel-border-color); border-left: 1px solid var(--panel-border-color); background: var(--panel-background-color); /* Same background as sidebars */ } -.DetailsContainer .resizableWithSplitterSplitter:hover { +.DetailsContainerResizableSidebarWrapper + > .resizableWithSplitterSplitter:hover { background: var(--panel-background-color); /* same as the border above */ } + +.DetailsContainerResizableSidebarWrapper > .resizableWithSplitterInner > * { + min-width: 0; + flex: 1; +} From c1dd4b121cd77fe8bad1bac014fd82a5268f5d17 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:49:01 -0400 Subject: [PATCH 23/36] Pass callNodeInfo to handleCallNodeTransformShortcut. The function handleCallNodeTransformShortcut takes a call node index as a parameter, but it was getting the call node info separately from a selector. At the moment that's fine because this is always the right call node info. But once we add separate call node infos for things like "the callees of the selected function in the function list", the two might get out of sync, so it's better to pass the correct call node info that is compatible with the passed call node index. Functionally-neutral change. --- src/actions/profile-view.ts | 2 +- src/components/calltree/CallTree.tsx | 3 ++- src/components/flame-graph/FlameGraph.tsx | 2 +- src/components/stack-chart/index.tsx | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 51ff3dcdb4..6c9484cb05 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -2015,6 +2015,7 @@ export function toggleBottomBoxFullscreen(): ThunkAction { export function handleCallNodeTransformShortcut( event: React.KeyboardEvent, threadsKey: ThreadsKey, + callNodeInfo: CallNodeInfo, callNodeIndex: IndexIntoCallNodeTable ): ThunkAction { return (dispatch, getState) => { @@ -2023,7 +2024,6 @@ export function handleCallNodeTransformShortcut( } const threadSelectors = getThreadSelectorsFromThreadsKey(threadsKey); const unfilteredThread = threadSelectors.getThread(getState()); - const callNodeInfo = threadSelectors.getCallNodeInfo(getState()); const implementation = getImplementationFilter(getState()); const inverted = getInvertCallstack(getState()); const callNodePath = callNodeInfo.getCallNodePathFromIndex(callNodeIndex); diff --git a/src/components/calltree/CallTree.tsx b/src/components/calltree/CallTree.tsx index 401247eee5..92355b2f63 100644 --- a/src/components/calltree/CallTree.tsx +++ b/src/components/calltree/CallTree.tsx @@ -276,6 +276,7 @@ class CallTreeImpl extends PureComponent { rightClickedCallNodeIndex, handleCallNodeTransformShortcut, threadsKey, + callNodeInfo, } = this.props; const nodeIndex = rightClickedCallNodeIndex !== null @@ -284,7 +285,7 @@ class CallTreeImpl extends PureComponent { if (nodeIndex === null) { return; } - handleCallNodeTransformShortcut(event, threadsKey, nodeIndex); + handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); }; _onEnterOrDoubleClick = (nodeId: IndexIntoCallNodeTable) => { diff --git a/src/components/flame-graph/FlameGraph.tsx b/src/components/flame-graph/FlameGraph.tsx index 376d0f3025..b0c2ad5953 100644 --- a/src/components/flame-graph/FlameGraph.tsx +++ b/src/components/flame-graph/FlameGraph.tsx @@ -302,7 +302,7 @@ class FlameGraphImpl return; } - handleCallNodeTransformShortcut(event, threadsKey, nodeIndex); + handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); }; _onCopy = (event: ClipboardEvent) => { diff --git a/src/components/stack-chart/index.tsx b/src/components/stack-chart/index.tsx index ea20211a15..eeb1347034 100644 --- a/src/components/stack-chart/index.tsx +++ b/src/components/stack-chart/index.tsx @@ -179,7 +179,7 @@ class StackChartImpl extends React.PureComponent { return; } - handleCallNodeTransformShortcut(event, threadsKey, nodeIndex); + handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); }; _onDoubleClick = (callNodeIndex: IndexIntoCallNodeTable | null) => { From 15d3f0c160555ebf89e867f481cddc6043bc75f5 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Mon, 22 Jun 2026 11:28:35 -0400 Subject: [PATCH 24/36] Remove unused isInverted prop from FlameGraphCanvas. --- src/components/flame-graph/Canvas.tsx | 1 - src/components/flame-graph/FlameGraph.tsx | 9 +-------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/components/flame-graph/Canvas.tsx b/src/components/flame-graph/Canvas.tsx index 1c163512b2..2c5f3745cf 100644 --- a/src/components/flame-graph/Canvas.tsx +++ b/src/components/flame-graph/Canvas.tsx @@ -70,7 +70,6 @@ export type OwnProps = { readonly scrollToSelectionGeneration: number; readonly categories: CategoryList; readonly interval: Milliseconds; - readonly isInverted: boolean; readonly callTreeSummaryStrategy: CallTreeSummaryStrategy; readonly ctssSamples: SamplesLikeTable; readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; diff --git a/src/components/flame-graph/FlameGraph.tsx b/src/components/flame-graph/FlameGraph.tsx index 376d0f3025..eff5ea1362 100644 --- a/src/components/flame-graph/FlameGraph.tsx +++ b/src/components/flame-graph/FlameGraph.tsx @@ -16,10 +16,7 @@ import { getProfileUsesMultipleStackTypes, } from 'firefox-profiler/selectors/profile'; import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; -import { - getSelectedThreadsKey, - getInvertCallstack, -} from '../../selectors/url-state'; +import { getSelectedThreadsKey } from '../../selectors/url-state'; import { ContextMenuTrigger } from 'firefox-profiler/components/shared/ContextMenuTrigger'; import { changeSelectedCallNode, @@ -83,7 +80,6 @@ type StateProps = { readonly scrollToSelectionGeneration: number; readonly categories: CategoryList; readonly interval: Milliseconds; - readonly isInverted: boolean; readonly callTreeSummaryStrategy: CallTreeSummaryStrategy; readonly ctssSamples: SamplesLikeTable; readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; @@ -336,7 +332,6 @@ class FlameGraphImpl callTreeSummaryStrategy, categories, interval, - isInverted, innerWindowIDToPageMap, weightType, ctssSamples, @@ -403,7 +398,6 @@ class FlameGraphImpl onDoubleClick: this._onCallNodeEnterOrDoubleClick, shouldDisplayTooltips: this._shouldDisplayTooltips, interval, - isInverted, ctssSamples, ctssSampleCategoriesAndSubcategories, tracedTiming: tracedTimingNonInverted, @@ -450,7 +444,6 @@ export const FlameGraph = explicitConnectWithForwardRef< selectedThreadSelectors.getRightClickedCallNodeIndex(state), scrollToSelectionGeneration: getScrollToSelectionGeneration(state), interval: getProfileInterval(state), - isInverted: getInvertCallstack(state), callTreeSummaryStrategy: selectedThreadSelectors.getCallTreeSummaryStrategy(state), innerWindowIDToPageMap: getInnerWindowIDToPageMap(state), From 8a4ea231bf8859637eefd294742eee01ba11b9b5 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Mon, 22 Jun 2026 17:05:12 -0400 Subject: [PATCH 25/36] Add missing transform shortcut key handling for S (focus-self). This shortcut key is advertised by the context menu but I forgot to actually implement it. --- src/actions/profile-view.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 51ff3dcdb4..f4505f8ebe 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -2051,6 +2051,15 @@ export function handleCallNodeTransformShortcut( }) ); break; + case 'S': + dispatch( + addTransformToStack(threadsKey, { + type: 'focus-self', + funcIndex, + implementation, + }) + ); + break; case 'M': dispatch( addTransformToStack(threadsKey, { From b09232e36a6293a9d6313c20339dabce98cc9b17 Mon Sep 17 00:00:00 2001 From: fatadel Date: Tue, 23 Jun 2026 13:03:39 +0200 Subject: [PATCH 26/36] Expose counter information in profiler-cli (#6084) Add `counter list` and `counter info ` commands, and list each counter under its process in `profile info`, to inspect any counter track from the terminal. Counters get stable `c-N` handles, like threads and functions. Per-counter stats come from the counter's own tooltip schema, reusing the timeline tooltips' labels and formatters so the CLI and UI agree. Stats respect the current zoom. Closes #6040 --- profiler-cli/guide.txt | 28 ++ profiler-cli/schemas.txt | 29 +- profiler-cli/src/commands/counter.ts | 49 ++++ profiler-cli/src/daemon.ts | 12 + profiler-cli/src/formatters.ts | 86 ++++++ profiler-cli/src/index.ts | 4 + profiler-cli/src/output.ts | 6 + profiler-cli/src/protocol.ts | 14 +- .../src/test/unit/counter-formatting.test.ts | 150 ++++++++++ src/profile-query/counter-map.ts | 37 +++ src/profile-query/formatters/counter-info.ts | 257 ++++++++++++++++++ src/profile-query/formatters/profile-info.ts | 15 +- src/profile-query/index.ts | 24 +- src/profile-query/types.ts | 60 +++- src/selectors/profile.ts | 19 ++ .../profile-query/profile-querier.test.ts | 110 +++++++- 16 files changed, 892 insertions(+), 8 deletions(-) create mode 100644 profiler-cli/src/commands/counter.ts create mode 100644 profiler-cli/src/test/unit/counter-formatting.test.ts create mode 100644 src/profile-query/counter-map.ts create mode 100644 src/profile-query/formatters/counter-info.ts diff --git a/profiler-cli/guide.txt b/profiler-cli/guide.txt index bc1cca6bfb..65cef7e2eb 100644 --- a/profiler-cli/guide.txt +++ b/profiler-cli/guide.txt @@ -117,6 +117,7 @@ HANDLE SYSTEM t-0, t-1 Thread handles (from "profile info") m-1234 Marker handles (from "thread markers") f-12 Function handles (from "thread samples", "thread functions") + c-0, c-1 Counter handles (from "counter list" or "profile info") ts-6 Timestamp handles (named points in time, usable with "zoom push") Handle lifetime and stability: @@ -127,6 +128,8 @@ HANDLE SYSTEM m-N thread markers No -- rebuilt each time the daemon starts f-N thread samples, Yes -- direct index into the profile's function thread functions table; same profile always yields the same f-N + c-N counter list Yes -- direct index into the profile's counter + array; same profile always yields the same c-N ts-N thread markers No -- position-based, session-scoped ────────────────────────────────────────────────────────────────────────── @@ -215,6 +218,31 @@ FILTERS profiler-cli filter push --during-marker --search Paint +COUNTERS + + Counters are time series the profiler records alongside samples: memory usage, + network bandwidth, process CPU, power, and similar. Each counter has a handle + (c-0, c-1, ...) and carries its own display metadata (label, unit, graph type). + + profiler-cli counter list List all counters with one-line summaries + profiler-cli counter info c-0 Detailed info and stats for one counter + + Counters also appear in "profile info", listed under their owning process + next to that process's threads (much like the timeline track list). + + The stats shown come from the counter's own tooltip schema, so they match the + timeline tooltips. Each counter reports its whole-range aggregates, e.g. the + memory range for Memory, data transferred for Bandwidth, or energy used (with a + CO2e estimate) for Power. + + All counter stats respect the current zoom: with no zoom they cover the whole + profile; after "zoom push" they cover the committed range. Combine with zoom to + see, for example, how much memory a specific time window allocated: + + profiler-cli zoom push 2.7,3.1 + profiler-cli counter info c-0 + + JSON OUTPUT Add --json to any command to get structured JSON output, suitable for piping to jq diff --git a/profiler-cli/schemas.txt b/profiler-cli/schemas.txt index 2a13b1389f..602560a352 100644 --- a/profiler-cli/schemas.txt +++ b/profiler-cli/schemas.txt @@ -19,12 +19,39 @@ profiler-cli profile info --json processes: [{ pid, name, cpuMs, threads: [{ threadHandle, threadIndex, name, tid, cpuMs }], - remainingThreads?: { count, combinedCpuMs, maxCpuMs } + remainingThreads?: { count, combinedCpuMs, maxCpuMs }, + counters?: [CounterSummary] }], remainingProcesses?: { count, combinedCpuMs, maxCpuMs }, context: SessionContext } +CounterSummary: + { + counterHandle, counterIndex, name, label, category, + unit, graphType, + color, pid, mainThreadIndex, mainThreadHandle, mainThreadName, + rangeSampleCount, + stats: [{ source, label, value, formattedValue, carbon? }] + } + +profiler-cli counter list --json + { + type: "counter-list", + counters: [CounterSummary], + context: SessionContext + } + +profiler-cli counter info --json + { + type: "counter-info", + ...CounterSummary, + description, + sampleCount, + rangeStart, rangeEnd, + context: SessionContext + } + profiler-cli thread samples --json { type: "thread-samples", diff --git a/profiler-cli/src/commands/counter.ts b/profiler-cli/src/commands/counter.ts new file mode 100644 index 0000000000..15a8e5d9a5 --- /dev/null +++ b/profiler-cli/src/commands/counter.ts @@ -0,0 +1,49 @@ +/* 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/. */ + +/** + * `profiler-cli counter` command. + */ + +import type { Command } from 'commander'; +import { sendCommand } from '../client'; +import { formatOutput } from '../output'; +import { addGlobalOptions } from './shared'; + +export function registerCounterCommand( + program: Command, + sessionDir: string +): void { + const counter = program + .command('counter') + .description('Counter-level commands'); + + addGlobalOptions( + counter + .command('list') + .description('List all counters with one-line summaries') + ).action(async (opts) => { + const result = await sendCommand( + sessionDir, + { command: 'counter', subcommand: 'list' }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + addGlobalOptions( + counter + .command('info [handle]') + .description('Show detailed information about a counter (e.g. c-0)') + .option('--counter ', 'Counter handle') + ).action(async (handleArg: string | undefined, opts) => { + const counterHandle = handleArg ?? opts.counter; + const result = await sendCommand( + sessionDir, + { command: 'counter', subcommand: 'info', counter: counterHandle }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); +} diff --git a/profiler-cli/src/daemon.ts b/profiler-cli/src/daemon.ts index 44e2e3e9ec..bc2375de9b 100644 --- a/profiler-cli/src/daemon.ts +++ b/profiler-cli/src/daemon.ts @@ -380,6 +380,18 @@ export class Daemon { default: throw assertExhaustiveCheck(command); } + case 'counter': + switch (command.subcommand) { + case 'list': + return this.querier!.counterList(); + case 'info': + if (!command.counter) { + throw new Error('counter handle required for counter info'); + } + return this.querier!.counterInfo(command.counter); + default: + throw assertExhaustiveCheck(command); + } case 'sample': switch (command.subcommand) { case 'info': diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts index f62da72cd5..6dee2303bc 100644 --- a/profiler-cli/src/formatters.ts +++ b/profiler-cli/src/formatters.ts @@ -35,6 +35,9 @@ import type { SampleFilterSpec, ProfileLogsResult, ThreadSelectResult, + CounterSummary, + CounterListResult, + CounterInfoResult, } from './protocol'; import { truncateFunctionName } from '../../src/profile-query/function-list'; import { describeSpec } from '../../src/profile-query/filter-stack'; @@ -428,6 +431,10 @@ Name: ${result.name}\n`; if (process.remainingThreads) { output += ` + ${process.remainingThreads.count} more threads with combined CPU time ${process.remainingThreads.combinedCpuMs.toFixed(3)}ms and max CPU time ${process.remainingThreads.maxCpuMs.toFixed(3)}ms (use --all to see all)\n`; } + + for (const counter of process.counters ?? []) { + output += ` ${counter.counterHandle}: ${counter.label}${formatCounterStats(counter)}\n`; + } } if (result.remainingProcesses) { @@ -451,6 +458,85 @@ Name: ${result.name}\n`; return output; } +function formatCounterStatInline( + stat: CounterSummary['stats'][number] +): string { + const value = stat.carbon + ? `${stat.formattedValue} (${stat.carbon})` + : stat.formattedValue; + return `${stat.label}: ${value}`; +} + +/** The ` - stat; stat [N samples]` trailer shared by counter list and profile info. */ +function formatCounterStats(counter: CounterSummary): string { + const stats = + counter.stats.length > 0 + ? ` - ${counter.stats.map(formatCounterStatInline).join('; ')}` + : ''; + return `${stats} [${counter.rangeSampleCount} samples]`; +} + +function formatCounterSummaryLine(counter: CounterSummary): string { + return ` ${counter.counterHandle}: ${counter.label} (${counter.category})${formatCounterStats(counter)}`; +} + +/** + * Format a CounterListResult as plain text. + */ +export function formatCounterListResult( + result: WithContext +): string { + const contextHeader = formatContextHeader(result.context); + if (result.counters.length === 0) { + return `${contextHeader}\n\nNo counters in this profile.`; + } + const lines = result.counters.map(formatCounterSummaryLine); + return `${contextHeader}\n\nCounters (${result.counters.length}):\n${lines.join('\n')}`; +} + +/** + * Format a CounterInfoResult as plain text. + */ +export function formatCounterInfoResult( + result: WithContext +): string { + const contextHeader = formatContextHeader(result.context); + const lines = [ + contextHeader, + '', + `Counter ${result.counterHandle}: ${result.label}`, + ` Name: ${result.name}`, + ` Category: ${result.category}`, + ]; + if (result.description) { + lines.push(` Description: ${result.description}`); + } + lines.push(` Unit: ${result.unit || '(none)'}`); + lines.push(` Graph type: ${result.graphType}`); + lines.push( + ` Main thread: ${result.mainThreadHandle} (${result.mainThreadName})` + ); + lines.push( + ` Samples: ${result.sampleCount} total, ${result.rangeSampleCount} in current range` + ); + if (result.rangeStart !== null && result.rangeEnd !== null) { + const zeroAt = result.context.rootRange.start; + lines.push( + ` Time span: ${formatDuration(result.rangeStart - zeroAt)} → ${formatDuration(result.rangeEnd - zeroAt)}` + ); + } + if (result.stats.length > 0) { + lines.push(' Stats (current range):'); + for (const stat of result.stats) { + const value = stat.carbon + ? `${stat.formattedValue} (${stat.carbon})` + : stat.formattedValue; + lines.push(` ${stat.label}: ${value}`); + } + } + return lines.join('\n'); +} + /** * Helper function to format a call tree node recursively. * diff --git a/profiler-cli/src/index.ts b/profiler-cli/src/index.ts index 05b7a640a7..c637516b9b 100644 --- a/profiler-cli/src/index.ts +++ b/profiler-cli/src/index.ts @@ -37,6 +37,7 @@ import { registerProfileCommand } from './commands/profile'; import { registerThreadCommand } from './commands/thread'; import { registerMarkerCommand } from './commands/marker'; import { registerFunctionCommand } from './commands/function'; +import { registerCounterCommand } from './commands/counter'; import { registerZoomCommand } from './commands/zoom'; import { registerFilterCommand } from './commands/filter'; import { registerSessionCommand } from './commands/session'; @@ -85,6 +86,8 @@ Examples: profiler-cli thread samples profiler-cli thread functions --search GC --min-self 1 profiler-cli thread markers --search DOMEvent --category Graphics + profiler-cli counter list + profiler-cli counter info c-0 profiler-cli zoom push 2.7,3.1 profiler-cli filter push --excludes-function f-184 profiler-cli status @@ -180,6 +183,7 @@ Examples: registerThreadCommand(program, SESSION_DIR); registerMarkerCommand(program, SESSION_DIR); registerFunctionCommand(program, SESSION_DIR); + registerCounterCommand(program, SESSION_DIR); registerZoomCommand(program, SESSION_DIR); registerFilterCommand(program, SESSION_DIR); registerSessionCommand(program, SESSION_DIR); diff --git a/profiler-cli/src/output.ts b/profiler-cli/src/output.ts index 36d8fc6345..a983f136cb 100644 --- a/profiler-cli/src/output.ts +++ b/profiler-cli/src/output.ts @@ -28,6 +28,8 @@ import { formatProfileLogsResult, formatThreadPageLoadResult, formatThreadSelectResult, + formatCounterListResult, + formatCounterInfoResult, } from './formatters'; /** @@ -88,6 +90,10 @@ export function formatOutput( return formatThreadPageLoadResult(result); case 'thread-select': return formatThreadSelectResult(result); + case 'counter-list': + return formatCounterListResult(result); + case 'counter-info': + return formatCounterInfoResult(result); default: throw assertExhaustiveCheck(result); } diff --git a/profiler-cli/src/protocol.ts b/profiler-cli/src/protocol.ts index 330dfe479f..3e817f9b43 100644 --- a/profiler-cli/src/protocol.ts +++ b/profiler-cli/src/protocol.ts @@ -50,6 +50,9 @@ export type { ProfileInfoResult, ProfileLogsResult, ThreadSelectResult, + CounterSummary, + CounterListResult, + CounterInfoResult, } from '../../src/profile-query/types'; export type { CallTreeCollectionOptions } from '../../src/profile-query/formatters/call-tree'; @@ -79,6 +82,8 @@ import type { FilterStackResult, ProfileLogsResult, ThreadSelectResult, + CounterListResult, + CounterInfoResult, } from '../../src/profile-query/types'; import type { CallTreeCollectionOptions } from '../../src/profile-query/formatters/call-tree'; @@ -141,6 +146,11 @@ export type ClientCommand = subcommand: 'info' | 'select' | 'stack'; marker?: string; } + | { + command: 'counter'; + subcommand: 'list' | 'info'; + counter?: string; + } | { command: 'sample'; subcommand: 'info' | 'select'; sample?: string } | { command: 'function'; @@ -195,7 +205,9 @@ export type CommandResult = | WithContext | WithContext | WithContext - | WithContext; + | WithContext + | WithContext + | WithContext; export interface SessionMetadata { id: string; diff --git a/profiler-cli/src/test/unit/counter-formatting.test.ts b/profiler-cli/src/test/unit/counter-formatting.test.ts new file mode 100644 index 0000000000..a9b7ca0958 --- /dev/null +++ b/profiler-cli/src/test/unit/counter-formatting.test.ts @@ -0,0 +1,150 @@ +/* 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 { + formatCounterListResult, + formatCounterInfoResult, +} from '../../formatters'; +import type { + CounterSummary, + CounterListResult, + CounterInfoResult, + SessionContext, + WithContext, +} from 'firefox-profiler/profile-query/types'; + +function createContext(): SessionContext { + return { + selectedThreadHandle: 't-0', + selectedThreads: [{ threadIndex: 0, name: 'GeckoMain' }], + currentViewRange: null, + rootRange: { start: 0, end: 3000 }, + }; +} + +function makeCounter(overrides: Partial = {}): CounterSummary { + return { + counterHandle: 'c-0', + counterIndex: 0, + name: 'malloc', + label: 'Memory', + category: 'Memory', + unit: 'bytes', + graphType: 'line-accumulated', + color: 'orange', + pid: '123', + mainThreadIndex: 0, + mainThreadHandle: 't-0', + mainThreadName: 'GeckoMain', + rangeSampleCount: 7, + stats: [ + { + source: 'count-range', + label: 'memory range in graph', + value: 27, + formattedValue: '27B', + }, + ], + ...overrides, + }; +} + +describe('formatCounterListResult', function () { + it('renders one line per counter with its stats', function () { + const result: WithContext = { + context: createContext(), + type: 'counter-list', + counters: [ + makeCounter(), + makeCounter({ + counterHandle: 'c-1', + name: 'eth0', + label: 'Bandwidth', + category: 'Bandwidth', + graphType: 'line-rate', + stats: [ + { + source: 'count-range', + label: 'Data transferred in the visible range', + value: 2048, + formattedValue: '2KB', + }, + ], + }), + ], + }; + + const output = formatCounterListResult(result); + expect(output).toContain('Counters (2):'); + expect(output).toContain('c-0: Memory (Memory)'); + expect(output).toContain('memory range in graph: 27B'); + expect(output).toContain('[7 samples]'); + expect(output).toContain('c-1: Bandwidth (Bandwidth)'); + expect(output).toContain('Data transferred in the visible range: 2KB'); + }); + + it('reports when there are no counters', function () { + const result: WithContext = { + context: createContext(), + type: 'counter-list', + counters: [], + }; + + expect(formatCounterListResult(result)).toContain( + 'No counters in this profile.' + ); + }); +}); + +describe('formatCounterInfoResult', function () { + function makeInfo( + overrides: Partial = {} + ): WithContext { + return { + context: createContext(), + ...makeCounter(), + type: 'counter-info', + description: 'Amount of allocated memory', + sampleCount: 7, + rangeStart: 0, + rangeEnd: 10, + ...overrides, + }; + } + + it('renders the counter detail block', function () { + const output = formatCounterInfoResult(makeInfo()); + expect(output).toContain('Counter c-0: Memory'); + expect(output).toContain('Name: malloc'); + expect(output).toContain('Category: Memory'); + expect(output).toContain('Unit: bytes'); + expect(output).toContain('Graph type: line-accumulated'); + expect(output).toContain('Main thread: t-0 (GeckoMain)'); + expect(output).toContain('Description: Amount of allocated memory'); + expect(output).toContain('memory range in graph: 27B'); + }); + + it('shows a CO2e estimate next to a stat when present', function () { + const output = formatCounterInfoResult( + makeInfo({ + label: 'Power', + category: 'power', + unit: 'pWh', + graphType: 'line-rate', + stats: [ + { + source: 'committed-range-total', + label: 'Energy used in the visible range', + value: 5, + formattedValue: '5 Wh', + carbon: '2 g CO₂e', + }, + ], + }) + ); + expect(output).toContain( + 'Energy used in the visible range: 5 Wh (2 g CO₂e)' + ); + }); +}); diff --git a/src/profile-query/counter-map.ts b/src/profile-query/counter-map.ts new file mode 100644 index 0000000000..8e6ae8a5f1 --- /dev/null +++ b/src/profile-query/counter-map.ts @@ -0,0 +1,37 @@ +/* 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 type { CounterIndex } from 'firefox-profiler/types'; + +/** + * A handle like "c-2" always refers to counter index 2 for this profile, + * making handles stable across sessions for the same processed profile data. + */ +export function getCounterHandle(counterIndex: CounterIndex): `c-${number}` { + return `c-${counterIndex}`; +} + +/** + * Parse a counter handle and validate it against the number of counters. + */ +export function parseCounterHandle( + counterHandle: string, + counterCount: number +): CounterIndex { + const match = /^c-(\d+)$/.exec(counterHandle); + if (match === null) { + throw new Error(`Unknown counter ${counterHandle}`); + } + + const counterIndex = Number(match[1]); + if ( + !Number.isInteger(counterIndex) || + counterIndex < 0 || + counterIndex >= counterCount + ) { + throw new Error(`Unknown counter ${counterHandle}`); + } + + return counterIndex; +} diff --git a/src/profile-query/formatters/counter-info.ts b/src/profile-query/formatters/counter-info.ts new file mode 100644 index 0000000000..57ad58f0bd --- /dev/null +++ b/src/profile-query/formatters/counter-info.ts @@ -0,0 +1,257 @@ +/* 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 { + getProfile, + getProfileRootRange, + getCounters, + getCounterSelectors, + getMeta, +} from 'firefox-profiler/selectors/profile'; +import { + formatBytes, + formatNumber, + formatPercent, +} from 'firefox-profiler/utils/format-numbers'; +import { + pwhToWh, + carbonForBytes, + carbonForWattHours, + POWER_LADDER, + ENERGY_LADDER, + pickTier, +} from 'firefox-profiler/components/timeline/TrackCounterTooltipFormat'; +import { getCounterHandle, parseCounterHandle } from '../counter-map'; +import type { + CounterIndex, + CounterTooltipDataSource, + CounterTooltipFormat, + ProfileMeta, +} from 'firefox-profiler/types'; +import type { Store } from '../../types/store'; +import type { ThreadMap } from '../thread-map'; +import type { + CounterStat, + CounterSummary, + CounterListResult, + CounterInfoResult, +} from '../types'; + +// The tooltip schema describes many per-sample and preview-selection rows that +// only make sense at a hover point. These are the two sources that aggregate +// over the whole committed range, so they are the only ones a CLI summary can +// resolve without a cursor. +const RANGE_AGGREGATE_SOURCES: Set = new Set([ + 'count-range', + 'committed-range-total', +]); + +const naturalSort = new Intl.Collator('en-US', { numeric: true }); + +export function getSortedCounterIndexes(store: Store): CounterIndex[] { + const state = store.getState(); + const counters = getCounters(state) ?? []; + const order = counters.map((_, index) => index); + if (getMeta(state).keepProfileThreadOrder) { + return order; + } + return order.sort((a, b) => { + const sortWeightDiff = + counters[a].display.sortWeight - counters[b].display.sortWeight; + if (sortWeightDiff !== 0) { + return sortWeightDiff; + } + return naturalSort.compare(counters[a].name, counters[b].name); + }); +} + +/** + * Format a resolved counter value exactly as the timeline tooltip does, minus + * the React/localization wrapping. Only the range-aggregate sources reach this, + * so the power-`scale` ladder normalization (which needs a per-sample dt) never + * applies; energy values are pWh sums converted to watt-hours. + */ +function formatCounterRowValue( + value: number, + format: CounterTooltipFormat, + meta: ProfileMeta +): { formattedValue: string; carbon?: string } { + if (format.scale) { + const valueForLadder = format.scale === 'energy' ? pwhToWh(value) : value; + const ladder = format.scale === 'power' ? POWER_LADDER : ENERGY_LADDER; + const tier = pickTier(valueForLadder, ladder); + const formattedValue = `${formatNumber(valueForLadder * tier.multiplier, tier.valueSignificantDigits)} ${tier.unitText}`; + let carbon: string | undefined; + if (format.co2 === 'per-watthour' && format.scale === 'energy') { + const grams = carbonForWattHours(valueForLadder, meta); + carbon = `${formatNumber(grams * tier.carbonMultiplier, tier.carbonSignificantDigits)} ${tier.carbonUnitText}`; + } + return { formattedValue, carbon }; + } + + let formattedValue: string; + switch (format.unit) { + case 'bytes': + formattedValue = formatBytes(value); + break; + case 'bytes-per-second': + formattedValue = `${formatBytes(value * 1000)} per second`; + break; + case 'percent': + formattedValue = formatPercent(value); + break; + case 'number': + formattedValue = formatNumber(value, 2, 0); + break; + default: + formattedValue = formatNumber(value); + break; + } + + let carbon: string | undefined; + if (format.co2 === 'per-byte') { + const bytesForCarbon = + format.unit === 'bytes-per-second' ? value * 1000 : value; + carbon = `${formatNumber(carbonForBytes(bytesForCarbon))} g CO₂e`; + } + return { formattedValue, carbon }; +} + +/** + * Resolve the counter's range-aggregate tooltip rows into formatted stats. + */ +function collectCounterStats( + store: Store, + counterIndex: CounterIndex +): CounterStat[] { + const state = store.getState(); + const selectors = getCounterSelectors(counterIndex); + const counter = selectors.getCounter(state); + const accumulated = selectors.getAccumulateCounterSamples(state); + const meta = getMeta(state); + + const stats: CounterStat[] = []; + for (const row of counter.display.tooltipRows) { + if (row.type !== 'value') { + continue; + } + if (row.requiresPreviewSelection) { + continue; + } + if (!RANGE_AGGREGATE_SOURCES.has(row.source)) { + continue; + } + + const value = + row.source === 'count-range' + ? accumulated.countRange + : selectors.getCommittedRangeCounterSampleSum(state); + + const { formattedValue, carbon } = formatCounterRowValue( + value, + row.format, + meta + ); + stats.push({ + source: row.source, + label: row.label, + value, + formattedValue, + carbon, + }); + } + return stats; +} + +/** + * Build the shared summary for a single counter. The stats cover the current + * committed (zoom) range, since the underlying counter selectors are + * range-aware. + */ +export function collectCounterSummary( + store: Store, + threadMap: ThreadMap, + counterIndex: CounterIndex +): CounterSummary { + const state = store.getState(); + const profile = getProfile(state); + const selectors = getCounterSelectors(counterIndex); + const counter = selectors.getCounter(state); + const { display } = counter; + + const [rangeStartIndex, rangeEndIndex] = + selectors.getCommittedRangeCounterSampleRange(state); + + const mainThreadName = profile.threads[counter.mainThreadIndex]?.name ?? ''; + + return { + counterHandle: getCounterHandle(counterIndex), + counterIndex, + name: counter.name, + label: display.label || counter.name, + category: counter.category, + unit: display.unit, + graphType: display.graphType, + color: display.color, + pid: counter.pid, + mainThreadIndex: counter.mainThreadIndex, + mainThreadHandle: threadMap.handleForThreadIndex(counter.mainThreadIndex), + mainThreadName, + rangeSampleCount: Math.max(0, rangeEndIndex - rangeStartIndex), + stats: collectCounterStats(store, counterIndex), + }; +} + +/** + * Build summaries for every counter in the profile. Returns an empty list when + * the profile has no counters. + */ +export function collectCounterList( + store: Store, + threadMap: ThreadMap +): CounterListResult { + return { + type: 'counter-list', + counters: getSortedCounterIndexes(store).map((index) => + collectCounterSummary(store, threadMap, index) + ), + }; +} + +/** + * Build detailed information about a single counter, resolved by handle. + */ +export function collectCounterInfo( + store: Store, + threadMap: ThreadMap, + counterHandle: string +): CounterInfoResult { + const state = store.getState(); + const counters = getCounters(state) ?? []; + const counterIndex = parseCounterHandle(counterHandle, counters.length); + + const summary = collectCounterSummary(store, threadMap, counterIndex); + const selectors = getCounterSelectors(counterIndex); + const counter = selectors.getCounter(state); + const [rangeStartIndex, rangeEndIndex] = + selectors.getCommittedRangeCounterSampleRange(state); + + const zeroAt = getProfileRootRange(state).start; + const hasRange = rangeEndIndex > rangeStartIndex; + const rangeStart = hasRange + ? counter.samples.time[rangeStartIndex] + zeroAt + : null; + const rangeEnd = hasRange + ? counter.samples.time[rangeEndIndex - 1] + zeroAt + : null; + + return { + ...summary, + type: 'counter-info', + description: counter.description, + sampleCount: counter.samples.length, + rangeStart, + rangeEnd, + }; +} diff --git a/src/profile-query/formatters/profile-info.ts b/src/profile-query/formatters/profile-info.ts index 1cce4668d4..664738a18b 100644 --- a/src/profile-query/formatters/profile-info.ts +++ b/src/profile-query/formatters/profile-info.ts @@ -10,11 +10,12 @@ import { import { getProfileNameWithDefault } from 'firefox-profiler/selectors/url-state'; import { buildProcessThreadList } from '../process-thread-list'; import { collectSliceTree } from '../cpu-activity'; +import { collectCounterSummary, getSortedCounterIndexes } from './counter-info'; import type { Store } from '../../types/store'; import type { ThreadInfo, ProcessListItem } from '../process-thread-list'; import type { TimestampManager } from '../timestamps'; import type { ThreadMap } from '../thread-map'; -import type { ProfileInfoResult } from '../types'; +import type { ProfileInfoResult, CounterSummary } from '../types'; /** * Filter a list of processes by a search string. @@ -30,8 +31,7 @@ function applySearchFilter( for (const process of processes) { const processMatches = - process.name.toLowerCase().includes(query) || - String(process.pid).includes(query); + process.name.toLowerCase().includes(query) || process.pid.includes(query); const matchingThreads = processMatches ? process.threads @@ -108,6 +108,14 @@ export function collectProfileInfo( ? applySearchFilter(result.processes, search) : result.processes; + const countersByPid = new Map(); + for (const index of getSortedCounterIndexes(store)) { + const counter = collectCounterSummary(store, threadMap, index); + const list = countersByPid.get(counter.pid) ?? []; + list.push(counter); + countersByPid.set(counter.pid, list); + } + const processesData: ProfileInfoResult['processes'] = processesToShow.map( (processItem) => { let startTimeName: string | undefined; @@ -141,6 +149,7 @@ export function collectProfileInfo( cpuMs: thread.cpuMs, })), remainingThreads: processItem.remainingThreads, + counters: countersByPid.get(processItem.pid), }; } ); diff --git a/src/profile-query/index.ts b/src/profile-query/index.ts index 5ff640589d..73d9959263 100644 --- a/src/profile-query/index.ts +++ b/src/profile-query/index.ts @@ -62,6 +62,10 @@ import { collectProfileLogs, } from './formatters/marker-info'; import { collectThreadPageLoad } from './formatters/page-load'; +import { + collectCounterList, + collectCounterInfo, +} from './formatters/counter-info'; import { parseTimeValue } from './time-range-parser'; import { describeTransformGroup, pushSpecTransforms } from './filter-stack'; import { functionAnnotate as computeFunctionAnnotate } from './function-annotate'; @@ -92,6 +96,8 @@ import type { ThreadFunctionsResult, ThreadPageLoadResult, ProfileLogsResult, + CounterListResult, + CounterInfoResult, MarkerFilterOptions, FunctionFilterOptions, SampleFilterSpec, @@ -202,7 +208,7 @@ export class ProfileQuerier { showAll: boolean = false, search?: string ): Promise> { - const result = await collectProfileInfo( + const result = collectProfileInfo( this._store, this._timestampManager, this._threadMap, @@ -213,6 +219,22 @@ export class ProfileQuerier { return { ...result, context: this._getContext() }; } + async counterList(): Promise> { + const result = collectCounterList(this._store, this._threadMap); + return { ...result, context: this._getContext() }; + } + + async counterInfo( + counterHandle: string + ): Promise> { + const result = collectCounterInfo( + this._store, + this._threadMap, + counterHandle + ); + return { ...result, context: this._getContext() }; + } + async threadInfo( threadHandle?: string ): Promise> { diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index 5cc2983a99..65e1135b3a 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -7,7 +7,11 @@ * These types are used by both profile-query (the library) and profiler-cli. */ -import type { Transform } from 'firefox-profiler/types'; +import type { + Transform, + CounterGraphType, + CounterTooltipDataSource, +} from 'firefox-profiler/types'; // ===== Utility types ===== @@ -662,6 +666,59 @@ export type ThreadPageLoadResult = { jankPeriods: JankPeriod[]; // limited by jankLimit }; +// ===== Counter Commands ===== + +/** + * A single range-aggregate stat for a counter, derived from one of the + * counter's own `display.tooltipRows`. Only the rows whose data source is a + * whole-range aggregate (`count-range`, `committed-range-total`) are surfaced; + * per-sample and preview-selection rows have no CLI equivalent and are skipped. + * `label`, `value`, and `formattedValue` come straight from the tooltip schema, + * so the CLI and the timeline tooltips stay in lockstep. + */ +export type CounterStat = { + source: CounterTooltipDataSource; + label: string; + value: number; + formattedValue: string; + carbon?: string; +}; + +/** + * One-line summary of a counter, shared by `counter list`, `counter info`, and + * the `profile info --counters` section. The `stats` cover the current + * committed (zoom) range, or the whole profile when not zoomed. + */ +export type CounterSummary = { + counterHandle: string; // e.g. "c-0" + counterIndex: number; + name: string; // raw counter name, e.g. "malloc" + label: string; // display.label || name, e.g. "Memory" + category: string; // e.g. "Memory" + unit: string; // display.unit, e.g. "bytes" + graphType: CounterGraphType; + color: string; + pid: string; + mainThreadIndex: number; + mainThreadHandle: string; // e.g. "t-0" + mainThreadName: string; + rangeSampleCount: number; // samples within the current range + stats: CounterStat[]; // range-aggregate stats from the tooltip schema +}; + +export type CounterListResult = { + type: 'counter-list'; + counters: CounterSummary[]; +}; + +export type CounterInfoResult = CounterSummary & { + type: 'counter-info'; + description: string; + sampleCount: number; // total samples in the counter (whole profile) + rangeStart: number | null; // absolute time of first in-range sample + rangeEnd: number | null; // absolute time of last in-range sample +}; + // ===== Profile Commands ===== export type ProfileInfoResult = { @@ -694,6 +751,7 @@ export type ProfileInfoResult = { combinedCpuMs: number; maxCpuMs: number; }; + counters?: CounterSummary[]; }>; remainingProcesses?: { count: number; diff --git a/src/selectors/profile.ts b/src/selectors/profile.ts index 75b3121bdb..896ca26b27 100644 --- a/src/selectors/profile.ts +++ b/src/selectors/profile.ts @@ -16,6 +16,7 @@ import { getFriendlyThreadName, processCounter, getInclusiveSampleIndexRangeForSelection, + getSampleIndexRangeForSelection, computeInnerWindowIDToTabMap, computeTabToThreadIndexesMap, computeStackTableFromRawStackTable, @@ -379,6 +380,23 @@ function _createCounterSelectors(counterIndex: CounterIndex) { ) ); + const getCommittedRangeCounterSampleSum: Selector = createSelector( + getCounter, + getCommittedRange, + (counter, range) => { + const [begin, end] = getSampleIndexRangeForSelection( + counter.samples, + range.start, + range.end + ); + let sum = 0; + for (let i = begin; i < end; i++) { + sum += counter.samples.count[i]; + } + return sum; + } + ); + return { getCounter, getDescription, @@ -387,6 +405,7 @@ function _createCounterSelectors(counterIndex: CounterIndex) { getMaxCounterSampleCountPerMs, getMaxRangeCounterSampleCountPerMs, getCommittedRangeCounterSampleRange, + getCommittedRangeCounterSampleSum, }; } diff --git a/src/test/unit/profile-query/profile-querier.test.ts b/src/test/unit/profile-query/profile-querier.test.ts index 185328c970..eec3b04b6e 100644 --- a/src/test/unit/profile-query/profile-querier.test.ts +++ b/src/test/unit/profile-query/profile-querier.test.ts @@ -17,7 +17,10 @@ */ import { ProfileQuerier } from 'firefox-profiler/profile-query'; -import { getProfileFromTextSamples } from '../../fixtures/profiles/processed-profile'; +import { + getProfileFromTextSamples, + getCounterForThread, +} from '../../fixtures/profiles/processed-profile'; import { getProfileRootRange } from 'firefox-profiler/selectors/profile'; import { storeWithProfile } from '../../fixtures/stores'; @@ -344,6 +347,111 @@ describe('ProfileQuerier', function () { }); }); + describe('counters', function () { + function profileWithMemoryCounter() { + const { profile } = getProfileFromTextSamples(` + 0 1 2 + A A A + B B B + `); + const counter = getCounterForThread(profile.threads[0], 0, { + name: 'malloc', + category: 'Memory', + hasCountNumber: true, + }); + profile.counters = [counter]; + return { profile, counter }; + } + + function querierFor(profile: Parameters[0]) { + const store = storeWithProfile(profile); + return new ProfileQuerier(store, getProfileRootRange(store.getState())); + } + + it('counterList returns a schema-driven summary per counter', async function () { + const { profile } = profileWithMemoryCounter(); + const result = await querierFor(profile).counterList(); + + expect(result.counters).toHaveLength(1); + expect(result.counters[0].counterHandle).toBe('c-0'); + expect(result.counters[0].label).toBe('Memory'); + expect( + result.counters[0].stats.some((s) => s.source === 'count-range') + ).toBe(true); + }); + + it('orders counters by display.sortWeight, not profile order', async function () { + const { profile } = getProfileFromTextSamples(` + 0 1 2 + A A A + `); + // Listed Memory-first, but Bandwidth (sortWeight 10) should sort before + // Memory (sortWeight 20), matching the timeline track order. + const memory = getCounterForThread(profile.threads[0], 0, { + name: 'malloc', + category: 'Memory', + }); + const bandwidth = getCounterForThread(profile.threads[0], 0, { + name: 'eth0', + category: 'Bandwidth', + }); + profile.counters = [memory, bandwidth]; + + const result = await querierFor(profile).counterList(); + + expect(result.counters.map((c) => c.label)).toEqual([ + 'Bandwidth', + 'Memory', + ]); + expect(result.counters.map((c) => c.counterHandle)).toEqual([ + 'c-1', + 'c-0', + ]); + }); + + it('profileInfo nests each counter under its owning process', async function () { + const { profile, counter } = profileWithMemoryCounter(); + const info = await querierFor(profile).profileInfo(); + + const owningProcess = info.processes.find((p) => p.pid === counter.pid); + expect(owningProcess).toBeDefined(); + expect(owningProcess!.counters?.map((c) => c.counterHandle)).toEqual([ + 'c-0', + ]); + }); + + it('counterInfo resolves a counter by handle', async function () { + const { profile } = profileWithMemoryCounter(); + const info = await querierFor(profile).counterInfo('c-0'); + + expect(info.counterHandle).toBe('c-0'); + expect(info.description).toBe('My Description'); + expect(info.sampleCount).toBeGreaterThan(0); + }); + + it('counterInfo throws for an unknown handle', async function () { + const { profile } = profileWithMemoryCounter(); + await expect(querierFor(profile).counterInfo('c-9')).rejects.toThrow( + 'Unknown counter c-9' + ); + }); + + it('returns no counters for a profile without any', async function () { + const { profile } = getProfileFromTextSamples(` + 0 1 2 + A A A + `); + + const querier = querierFor(profile); + const list = await querier.counterList(); + + expect(list.counters).toEqual([]); + await expect(querier.counterInfo('c-0')).rejects.toThrow( + 'Unknown counter c-0' + ); + }); + }); + describe('threadSamples', function () { it('searches all roots when choosing the heaviest stack', async function () { const { profile } = getProfileFromTextSamples(` From 9d9438ab3de7b9a047d637f0b45b54b1e5fe8f28 Mon Sep 17 00:00:00 2001 From: "depfu[bot]" <23717796+depfu[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:30:20 +0200 Subject: [PATCH 27/36] Update all Yarn dependencies (2026-06-24) (#6120) Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com> --- package.json | 6 +++--- yarn.lock | 48 ++++++++++++++++++++++++------------------------ 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index 9e414c19de..efac5da626 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,7 @@ "mixedtuplemap": "^1.0.0", "namedtuplemap": "^1.0.0", "photon-colors": "^3.3.2", - "protobufjs": "^8.6.4", + "protobufjs": "^8.6.5", "query-string": "^9.4.0", "react": "~19.1.8", "react-dom": "~19.1.8", @@ -143,7 +143,7 @@ "babel-plugin-module-resolver": "^5.0.3", "browserslist": "^4.28.2", "browserslist-to-esbuild": "^2.1.1", - "caniuse-lite": "^1.0.30001793", + "caniuse-lite": "^1.0.30001799", "cross-env": "^10.1.0", "cross-spawn": "^7.0.6", "devtools-license-check": "^0.9.0", @@ -174,7 +174,7 @@ "postcss": "^8.5.15", "postinstall-postinstall": "^2.1.0", "rimraf": "^6.1.3", - "stylelint": "^17.12.0", + "stylelint": "^17.13.0", "stylelint-config-idiomatic-order": "^10.0.0", "stylelint-config-standard": "^40.0.0", "typescript": "^6.0.3", diff --git a/yarn.lock b/yarn.lock index cec0b57997..2963bc8cbd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1143,10 +1143,10 @@ resolved "https://registry.yarnpkg.com/@csstools/css-calc/-/css-calc-2.1.4.tgz#8473f63e2fcd6e459838dd412401d5948f224c65" integrity sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ== -"@csstools/css-calc@^3.2.0": - version "3.2.0" - resolved "https://registry.yarnpkg.com/@csstools/css-calc/-/css-calc-3.2.0.tgz#15ca1a80a026ced0f6c4e12124c398e3db8e1617" - integrity sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w== +"@csstools/css-calc@^3.2.1": + version "3.2.1" + resolved "https://registry.yarnpkg.com/@csstools/css-calc/-/css-calc-3.2.1.tgz#b30e061ca9f297ccb2b3b032bfee32fda02b1b27" + integrity sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg== "@csstools/css-color-parser@^3.0.9": version "3.0.10" @@ -1166,10 +1166,10 @@ resolved "https://registry.yarnpkg.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz#e1c65dc09378b42f26a111fca7f7075fc2c26164" integrity sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w== -"@csstools/css-syntax-patches-for-csstree@^1.1.3": - version "1.1.3" - resolved "https://registry.yarnpkg.com/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz#3204cf40deb97db83e225b0baa9e37d9c3bd344d" - integrity sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg== +"@csstools/css-syntax-patches-for-csstree@^1.1.4": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz#b8e26e0fe25e9a6ec607d045470cc46d2f62731e" + integrity sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A== "@csstools/css-tokenizer@^3.0.3": version "3.0.4" @@ -3560,10 +3560,10 @@ camelcase@^8.0.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-8.0.0.tgz#c0d36d418753fb6ad9c5e0437579745c1c14a534" integrity sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA== -caniuse-lite@^1.0.30001782, caniuse-lite@^1.0.30001793: - version "1.0.30001793" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz#238887ddf5fcfc8c36d872394d0a78a517312a72" - integrity sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA== +caniuse-lite@^1.0.30001782, caniuse-lite@^1.0.30001799: + version "1.0.30001799" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz#5c909138c27f1a61219d3e092071c1cc7d32dc55" + integrity sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw== ccount@^2.0.0: version "2.0.1" @@ -9128,7 +9128,7 @@ postcss-value-parser@^4.2.0: resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@^8.4.32, postcss@^8.5.14, postcss@^8.5.15: +postcss@^8.4.32, postcss@^8.5.15: version "8.5.15" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.15.tgz#d1eaf677a324e9ec02196da2d3fecf4a0b9a735c" integrity sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A== @@ -9210,10 +9210,10 @@ proto-list@~1.2.1: resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA== -protobufjs@^8.6.4: - version "8.6.4" - resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-8.6.4.tgz#61c1589f095d096cf89e77d1e6dfa5ca1c0d70bf" - integrity sha512-/+XMv9JalknuncEJSwsyEVlwcxVLKx2iaoSUXFZA86MJkdqyOdfrlB1sB7S6aKyUk9tl20YY+SgQe5J2sJHTcg== +protobufjs@^8.6.5: + version "8.6.5" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-8.6.5.tgz#7709c9ff77914c00010129888e620c9ccf7c294c" + integrity sha512-zeE5LPpencAGXvsxyOYmEgJhxzHY8IsmPAFzstZVhDSVT8QH03q6gMZwZRaQGApevZbAL6u28ugs4CC+YKB2jQ== dependencies: long "^5.3.2" @@ -10635,14 +10635,14 @@ stylelint-order@^6.0.2: postcss "^8.4.32" postcss-sorting "^8.0.2" -stylelint@^17.12.0: - version "17.12.0" - resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-17.12.0.tgz#388d3fb56bebbb816f1043c8cc1c290bf5a889ab" - integrity sha512-KIlzWXMHUvgfPUR0R7TK3H80yCIi0uoivUwf+6Az4yrHJD1Q3c1qIkh/H5Z0i/K3QXgtq/UMEkWyBUSUwnpnOg== +stylelint@^17.13.0: + version "17.13.0" + resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-17.13.0.tgz#cc68d0ad83d84e29e5147d5f8a8100ccd26a7346" + integrity sha512-G1WYzMerp7ihOaIe9VJCHLt12MoAD2QLf1AFerYP37+BCRBUK5UCpq8e/mN+zCIaJPKQcaxhE4WlPmqdiOx/gw== dependencies: - "@csstools/css-calc" "^3.2.0" + "@csstools/css-calc" "^3.2.1" "@csstools/css-parser-algorithms" "^4.0.0" - "@csstools/css-syntax-patches-for-csstree" "^1.1.3" + "@csstools/css-syntax-patches-for-csstree" "^1.1.4" "@csstools/css-tokenizer" "^4.0.0" "@csstools/media-query-list-parser" "^5.0.0" "@csstools/selector-resolve-nested" "^4.0.0" @@ -10666,7 +10666,7 @@ stylelint@^17.12.0: micromatch "^4.0.8" normalize-path "^3.0.0" picocolors "^1.1.1" - postcss "^8.5.14" + postcss "^8.5.15" postcss-safe-parser "^7.0.1" postcss-selector-parser "^7.1.1" postcss-value-parser "^4.2.0" From ec8f2a73475c223dc59a4412bfe15742aa2675fa Mon Sep 17 00:00:00 2001 From: "depfu[bot]" <23717796+depfu[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:58:42 +0200 Subject: [PATCH 28/36] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Update=20oxfmt=20to?= =?UTF-8?q?=20version=200.56.0=20(#6121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com> Co-authored-by: fatadel --- package.json | 2 +- yarn.lock | 234 +++++++++++++++++++++++++-------------------------- 2 files changed, 118 insertions(+), 118 deletions(-) diff --git a/package.json b/package.json index efac5da626..96e20725f8 100644 --- a/package.json +++ b/package.json @@ -169,7 +169,7 @@ "lockfile-lint": "^5.0.0", "npm-run-all2": "^8.0.4", "open": "^11.0.0", - "oxfmt": "^0.51.0", + "oxfmt": "^0.56.0", "patch-package": "^8.0.1", "postcss": "^8.5.15", "postinstall-postinstall": "^2.1.0", diff --git a/yarn.lock b/yarn.lock index 2963bc8cbd..c7c896af88 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1916,100 +1916,100 @@ resolved "https://registry.yarnpkg.com/@npmcli/name-from-folder/-/name-from-folder-1.0.1.tgz#77ecd0a4fcb772ba6fe927e2e2e155fbec2e6b1a" integrity sha512-qq3oEfcLFwNfEYOQ8HLimRGKlD8WSeGEdtUa7hmzpR8Sa7haL1KVQrvgO6wqMjhWFFVjgtrh1gIxDz+P8sjUaA== -"@oxfmt/binding-android-arm-eabi@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.51.0.tgz#aa1b9ef3c637deff8d068f407c5025557a140327" - integrity sha512-Ni0sCqg5CIHaLIYFGj+ncbcumylvNC6FE4rfD0KfdmnWHbPJ+zev0qZCXKxy2hFVa0fYRK0yPzf5nzPbkZou7g== - -"@oxfmt/binding-android-arm64@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.51.0.tgz#608bc9ec7c1b715b1a17ee2081fc86b6eced0a2b" - integrity sha512-eu5lAZjuo0KAkp+M24EhDqfOwA8owQ8d7wyBlOUUGRbDLHpU3IRlDHp8Dif+YqGlxs6jra7yS6WQu/NkPhAxeg== - -"@oxfmt/binding-darwin-arm64@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.51.0.tgz#86051323e7d879e780baed2d0e7ee892a766c131" - integrity sha512-6LsUNIdURhhcIfIn8+xsOb61mSTa9msAHTeSGx9Jf4rsP/gN8PGCF+SKWPAQZbND2w/WBkqQ6303jqEEIXzMdQ== - -"@oxfmt/binding-darwin-x64@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.51.0.tgz#56be4aac362bd6e18eec8ae6e327c94ab6849a91" - integrity sha512-9aUMGmVxdHjYMsEAW1tNRoieTJXlVNDFkRvIR1J7LttJXWjVYCu2ekclLij2KJtxBxSQOYSHd12ME/adVGVbZg== - -"@oxfmt/binding-freebsd-x64@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.51.0.tgz#bc980f22a7e2fa1a176c2d222bb2857dc22aa00c" - integrity sha512-mkY1nhZTqYb+NHaAWxOCKISN6FwdrwMNsu17vTUA3wzUV2VJ+Paq15ZokRcsMU/2PUdHO73prxyeJpjXQ3MPpQ== - -"@oxfmt/binding-linux-arm-gnueabihf@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.51.0.tgz#de06f649a6407063a6459a7486991f0b64c12936" - integrity sha512-wtFwNwE4+YCNuPaWoGDZeGsKvD6D1YSUNBJNn/rJBh7CrDBThFE+TBI5kY7vRW9rIOQRsbW2IpyyL3Du4Zqwiw== - -"@oxfmt/binding-linux-arm-musleabihf@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.51.0.tgz#b663b0d55e00098618b2b09e7f884080e90c1d1e" - integrity sha512-rnOaNx86G7iRKM6lsCIQMux0SMGNC/TEbFR+r7lpruJ12bnrIWgxd5w1PLqOvgR9r8ZJbpK/zfRKctJnh8/Jfg== - -"@oxfmt/binding-linux-arm64-gnu@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.51.0.tgz#e03c66a41d1767319f8bcc9801c8c37ad8f00d1c" - integrity sha512-jOgDzSqWcICGRjsp4mc08FxKMN8vzP2Kgs4E0d2HUP99F+nJDQKklRV4Zuj+0gcBgjrzx2CbpqaIdUVPepCojA== - -"@oxfmt/binding-linux-arm64-musl@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.51.0.tgz#1c8b05a590ceb4706aa233cacc3058605f6a4a1f" - integrity sha512-KBUCdrH5bwVrAvI9gU/1S55oH6fzXjr++J/oVocdu7bYTks1l7DNNT+rLd/1TDdAEjObGwmfWamn7LC1m8A0DQ== - -"@oxfmt/binding-linux-ppc64-gnu@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.51.0.tgz#3f96b0b6dfb4fd5fc8085ca415b6d7f625f15179" - integrity sha512-NapfjYsABFqTJ1Dn9Efq6sN5esaHconVKwVLbDGNQLrwpOx/g17mkwErHzU72PutL67nf3wNAkbq122H+zLxag== - -"@oxfmt/binding-linux-riscv64-gnu@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.51.0.tgz#1a8ac28e46c9bec9ea3e83608b9fc1f4e5e91d99" - integrity sha512-5dlDt1dUZCVi6elIhiK1PWg9wpTzTcIuj0IZnSurvIoMrhOWqqTcc1dSTxcSkNaBZhfsNqRZdINI1zAgbKkJNQ== - -"@oxfmt/binding-linux-riscv64-musl@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.51.0.tgz#d87cf83be567a24ee5cf66c88a630aaf5daa4d7f" - integrity sha512-pgdWUJn0S5nulyiVdlFV8DzCUnGXkU99W5PSkkmbaZW+LrZBPxpezun4G0DDHbQaVYuJeCuKsXsGKGo77CkUTQ== - -"@oxfmt/binding-linux-s390x-gnu@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.51.0.tgz#2c56624067b7a55443f9fbbbbf7c3947856acbd5" - integrity sha512-2XTFUe97CbDGAI8vjwDfZ1HdakO0XIADyJ24idEg64SC4/K4in/OisXVnrW4NMK7I6TgC7EqRhC0Ln/nKhAemA== - -"@oxfmt/binding-linux-x64-gnu@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.51.0.tgz#a5208625ff9000a47e5d1b1e32ff8442f7e81ffd" - integrity sha512-kQ1OuCqqt/yyf0ZN9VFxW1/JnlgJgii3Dr7pWf9vNBvrX1hv6g39/+mc5oGRHRGJFZtl3zsGDWR9c5N2B/gwBw== - -"@oxfmt/binding-linux-x64-musl@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.51.0.tgz#52e2f148abb8114d879b141f23c48685c25873e1" - integrity sha512-ARTYqxHF475o96Gbn41hvSWSSRygPlRDXZZgZ9I2scU1y0qiWpCQyZCoefaQa0mwv+wwtZ+luS4YOzsRzM/izg== - -"@oxfmt/binding-openharmony-arm64@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.51.0.tgz#deca8a3a3cc4df3e797ce8ed46b8dfd04b432e63" - integrity sha512-QiC1XrCl6a6BmqMzduO8hdIRMf1m44hCkt2Q68KWkTvUB/E7fd2iomyNh6KnnRca5w6eBrRAAtLFqTh+xjsjJA== - -"@oxfmt/binding-win32-arm64-msvc@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.51.0.tgz#87bf318e600b5c922e88bd0c96a521d1e4e0bd0d" - integrity sha512-NC/hJb9dtU23Zf8L7IVK95xnFjiQ7AfcLO2l5pb69TDEr958qxrtnB2CveeeNSCBFNIkgaTCfd/vHNSoG78l9g== - -"@oxfmt/binding-win32-ia32-msvc@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.51.0.tgz#f1bf8ab8cc5ea6a551f8be87f7e06966bbe137c3" - integrity sha512-2C45za4Rj36n8YIbhRL1PQbxmXJYf81WEcAgvj5I4ptRROG+A+81hREEN5bmCHADE1UfYaN312U6tkILoZZy6w== - -"@oxfmt/binding-win32-x64-msvc@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.51.0.tgz#396678bccf40060a685cbfe8fa1061cb66a1b5d6" - integrity sha512-73RqdAuVKQTkjZIDw08JaDHUM4lav5Qu+CaPwg4QbbA7k8o7LEW0p3UsfZ/F8dsO/pwVYh3RzFcanwLRTTahbQ== +"@oxfmt/binding-android-arm-eabi@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.56.0.tgz#beb31cb658cc73b560b69b82beda80c6f673968b" + integrity sha512-CSCxi7ovYojgfdPOdUb9T508HKeAdDIKeRGg7x8IZwVJrWz9gVgX7MbUnFqtQAE4QvoNo07mj2JlwnOzJw4qqA== + +"@oxfmt/binding-android-arm64@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.56.0.tgz#61d1043c4e818457b079fab2e4b3a4a906f1ed8d" + integrity sha512-HYJFnd+PkDwf6S9ZPGzXXtjNqvRWFnnhdbWaouh4mi/SxU8wmDuzlMn3xo/wDTGnr4Q1VA7ZzOaE/D4biW0W6A== + +"@oxfmt/binding-darwin-arm64@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.56.0.tgz#d5af60434c52e2f742b31f7b85feb9543aff8501" + integrity sha512-sftR/bEOr+t1gs+evwsHi/Xbq2FAPA2uU3VMr8n6ZU9PoK/IMSfnfu7+OEe/uy1+knhrFl4Wvy7Vkm3uo9mJ7g== + +"@oxfmt/binding-darwin-x64@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.56.0.tgz#1ccbfd8a11bacd771e32de616837f43eec04bcee" + integrity sha512-z66SdjLqa3MUPKvTp3Mbb5nSjKSbnYxJGeB+Wx987s8T5hPcIRiBMfnJ6zcPgYtQn3x5xjvdzNVkXrSeYH6ZFg== + +"@oxfmt/binding-freebsd-x64@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.56.0.tgz#79a87da2b83711460402672c8079010191e7e940" + integrity sha512-t2tkrV1vtZyaItSQ71dTi2ZVKZEI39b/LqLT12V5KMfIeXK6N32TUC1jhOXKVQmhECq9j2ZXMQV3JeT1kh9Vmg== + +"@oxfmt/binding-linux-arm-gnueabihf@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.56.0.tgz#c7a505a6267d015cd21fa3340f0d763bcba4c2ee" + integrity sha512-+gCy+Tp3RHeXQ9y/QrS76lXIpZkbziTyp6hIgjB2MssCwfMph3vG/GEfkhO34Rai1vhYIaUkvv8UT1BcDorJPw== + +"@oxfmt/binding-linux-arm-musleabihf@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.56.0.tgz#b92464412a4e69105094e651364a3a7f9dcc0588" + integrity sha512-0kKkVvQ2I+FJ2sxQyUu1zJ0yWP5kcWse/yVFnGQSFCXMwSSkfEaUGu0dW774O7nyy3jrcBGap7OSc8dZmU/CdA== + +"@oxfmt/binding-linux-arm64-gnu@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.56.0.tgz#b95377af041ace991f261dfe16e2f3bbf47b070a" + integrity sha512-npkA2siMbyWRh+wEhi1aTAx4RirukGcGNt8V4Ch86pG+xU9aurqS1MZOnKYMu03ISwat3rB6zkQx51SsB9obNw== + +"@oxfmt/binding-linux-arm64-musl@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.56.0.tgz#2f3f643f3f47384e9394057a4e5e4e88df9d17e2" + integrity sha512-UekqOjGkV4/MkqreCV9SPIB2jlR3/HbXrmhV1rVXJZ9wfDXMyCMriLtq3tHqLY4PkbVWNtfcm1kMojJ26KLSJw== + +"@oxfmt/binding-linux-ppc64-gnu@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.56.0.tgz#4ac2db360a2dd44e79a11280c7b0ed2b424f9259" + integrity sha512-XSzveSpeZMD5XJpew5lRFVtNnT04xd3rJxENXmk7wkZzN9oWzv2aFJyoNDhOtoz69BYaS/fg4SYl+CfEZRpB0Q== + +"@oxfmt/binding-linux-riscv64-gnu@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.56.0.tgz#4d30a2e63631b1743667c88817ffddd56fa45205" + integrity sha512-EkQ0nJa7k7HDDIVuPF7WY+k4k+bzdclLYtyIXNt7/OqVghfNiMym6YGppFBgx1XRIHW6QylxBz5OogumPjPJbQ== + +"@oxfmt/binding-linux-riscv64-musl@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.56.0.tgz#9aa3ba74a8268a25c7e69844aeb9baa3b1d9c05a" + integrity sha512-dyjAGW8jKRge0ik6U/dgvQG0nVpA3iBlRskQTz5qJLvQWIrySxX5jpqzPetLBNIIZ231KA82fDdi1nLTk8ENCw== + +"@oxfmt/binding-linux-s390x-gnu@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.56.0.tgz#f568fed2a60d0aaf4178e44c93d8156f57d15d95" + integrity sha512-60ZGH3LtfqlW8X6vcLdSFY4lvCQYINurttYBKaALnHCDVAUCYJ1LsUgS6p1XOzVlzEDx3yNUZvDF1Lvt59zoZw== + +"@oxfmt/binding-linux-x64-gnu@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.56.0.tgz#123967369c4b559f9b704826715111d79980ae32" + integrity sha512-u1suj1tgJHK4ZqB7buCtdbNef2n8+d0lXTPJwLHNmtyK6p+DTpsaoDvmqhQrA56fgKYv4LuRxNtL8YooebKOew== + +"@oxfmt/binding-linux-x64-musl@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.56.0.tgz#78ef9fb6e66c5c633ff5458793dd8164708e8380" + integrity sha512-aYGLvlQHt80y+qKEtfJY/Nm27G0125Lv+qyh9SJ4Cjc6lXnXjD+ndfhqQnbV24POpMi7rNRi0jvx/0d70FRpCQ== + +"@oxfmt/binding-openharmony-arm64@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.56.0.tgz#bfc7a984bc8c2838de1fc0615b5297bb7b809697" + integrity sha512-H/re/gO+7ysVc+kywHNuzY3C33EN9sQcZhg0kp1ZwOZl7y998ZE5mhnBiuGR/nYI0pqLL5xQfrHVUOJ/cIJsCA== + +"@oxfmt/binding-win32-arm64-msvc@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.56.0.tgz#b9e2bbce68ef363742313baf9da5fbff8a416e18" + integrity sha512-6qLNXfXmtAs8jXDvYMkxk6Wec5SUJoew+ZX1uOZmqaR7ks0EJFbAohuOCELDyJMWyVlxotVG8Xf8m74Bfq0O2w== + +"@oxfmt/binding-win32-ia32-msvc@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.56.0.tgz#1d96284cb5e6df6d219bd5bdbf9e0a8c5a8daf8c" + integrity sha512-UXEXuKphAe15bsob4AswNMArCw38XSmUIs3wk1s6e6MX9OWGW/IRWU95s1hZDiVg09STy1jHgyN2qkqbu1FT0w== + +"@oxfmt/binding-win32-x64-msvc@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.56.0.tgz#6c43da44642523b45fa8c0d152375fe96880f779" + integrity sha512-HPyNDjky+NIOuaMvHZflR+kst3YWdUOH2JUQYkf99grqZ5mEBTQM6h9iGy501Z8Xt5xMScrwHOuVCOlqDrktRw== "@pkgjs/parseargs@^0.11.0": version "0.11.0" @@ -8748,32 +8748,32 @@ own-keys@^1.0.1: object-keys "^1.1.1" safe-push-apply "^1.0.0" -oxfmt@^0.51.0: - version "0.51.0" - resolved "https://registry.yarnpkg.com/oxfmt/-/oxfmt-0.51.0.tgz#8e2919e8fb9632abcecc5b65bb20f4829f6fc4c8" - integrity sha512-l/AoAnaEOV7Q5/Z9kHOMDehVJnCgYN7wRoooWCTUMBMi16BJhLZqd9cmCnwcVFfVlzkt53zK2KLPFNp8vSsoDg== +oxfmt@^0.56.0: + version "0.56.0" + resolved "https://registry.yarnpkg.com/oxfmt/-/oxfmt-0.56.0.tgz#ef72d7687392a5638e8c91cc930c7236c7341f7e" + integrity sha512-9Dv0wV3zKiyvhjD7bRKaInKmHQ1sPx3RGOjQkGFJbbdQ16576yf8qhMSO9Q9cvHcs+1NpBsRTkuDDYFFPTJ6gw== dependencies: tinypool "2.1.0" optionalDependencies: - "@oxfmt/binding-android-arm-eabi" "0.51.0" - "@oxfmt/binding-android-arm64" "0.51.0" - "@oxfmt/binding-darwin-arm64" "0.51.0" - "@oxfmt/binding-darwin-x64" "0.51.0" - "@oxfmt/binding-freebsd-x64" "0.51.0" - "@oxfmt/binding-linux-arm-gnueabihf" "0.51.0" - "@oxfmt/binding-linux-arm-musleabihf" "0.51.0" - "@oxfmt/binding-linux-arm64-gnu" "0.51.0" - "@oxfmt/binding-linux-arm64-musl" "0.51.0" - "@oxfmt/binding-linux-ppc64-gnu" "0.51.0" - "@oxfmt/binding-linux-riscv64-gnu" "0.51.0" - "@oxfmt/binding-linux-riscv64-musl" "0.51.0" - "@oxfmt/binding-linux-s390x-gnu" "0.51.0" - "@oxfmt/binding-linux-x64-gnu" "0.51.0" - "@oxfmt/binding-linux-x64-musl" "0.51.0" - "@oxfmt/binding-openharmony-arm64" "0.51.0" - "@oxfmt/binding-win32-arm64-msvc" "0.51.0" - "@oxfmt/binding-win32-ia32-msvc" "0.51.0" - "@oxfmt/binding-win32-x64-msvc" "0.51.0" + "@oxfmt/binding-android-arm-eabi" "0.56.0" + "@oxfmt/binding-android-arm64" "0.56.0" + "@oxfmt/binding-darwin-arm64" "0.56.0" + "@oxfmt/binding-darwin-x64" "0.56.0" + "@oxfmt/binding-freebsd-x64" "0.56.0" + "@oxfmt/binding-linux-arm-gnueabihf" "0.56.0" + "@oxfmt/binding-linux-arm-musleabihf" "0.56.0" + "@oxfmt/binding-linux-arm64-gnu" "0.56.0" + "@oxfmt/binding-linux-arm64-musl" "0.56.0" + "@oxfmt/binding-linux-ppc64-gnu" "0.56.0" + "@oxfmt/binding-linux-riscv64-gnu" "0.56.0" + "@oxfmt/binding-linux-riscv64-musl" "0.56.0" + "@oxfmt/binding-linux-s390x-gnu" "0.56.0" + "@oxfmt/binding-linux-x64-gnu" "0.56.0" + "@oxfmt/binding-linux-x64-musl" "0.56.0" + "@oxfmt/binding-openharmony-arm64" "0.56.0" + "@oxfmt/binding-win32-arm64-msvc" "0.56.0" + "@oxfmt/binding-win32-ia32-msvc" "0.56.0" + "@oxfmt/binding-win32-x64-msvc" "0.56.0" p-cancelable@^3.0.0: version "3.0.0" From add6b76a639ea9e596cefed04959dcbb2acfbce1 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:51:35 -0400 Subject: [PATCH 29/36] Move column declarations out into a separate file. This will let us use them from other tree views, like the planned function list tree view. --- src/components/calltree/CallTree.tsx | 127 +------------------------- src/components/calltree/columns.ts | 130 +++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 123 deletions(-) create mode 100644 src/components/calltree/columns.ts diff --git a/src/components/calltree/CallTree.tsx b/src/components/calltree/CallTree.tsx index 92355b2f63..a86053f130 100644 --- a/src/components/calltree/CallTree.tsx +++ b/src/components/calltree/CallTree.tsx @@ -2,11 +2,9 @@ * 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 { PureComponent } from 'react'; -import memoize from 'memoize-immutable'; import explicitConnect from 'firefox-profiler/utils/connect'; import { TreeView } from 'firefox-profiler/components/shared/TreeView'; import { CallTreeEmptyReasons } from './CallTreeEmptyReasons'; -import { Icon } from 'firefox-profiler/components/shared/Icon'; import { getInvertCallstack, getImplementationFilter, @@ -30,7 +28,6 @@ import { changeTableViewOptions, updateBottomBoxContentsAndMaybeOpen, } from 'firefox-profiler/actions/profile-view'; -import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; import type { State, @@ -46,13 +43,10 @@ import type { 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 { - Column, - MaybeResizableColumn, -} from 'firefox-profiler/components/shared/TreeView'; import type { ConnectedProps } from 'firefox-profiler/utils/connect'; import './CallTree.css'; +import { nameColumn, libColumn, treeColumnsForWeightType } from './columns'; type StateProps = { readonly threadsKey: ThreadsKey; @@ -86,124 +80,11 @@ type DispatchProps = { type Props = ConnectedProps<{}, StateProps, DispatchProps>; class CallTreeImpl extends PureComponent { - _mainColumn: Column = { - propName: 'name', - titleL10nId: '', - }; - _appendageColumn: Column = { - propName: 'lib', - titleL10nId: '', - }; _treeView: TreeView | null = null; _takeTreeViewRef = (treeView: TreeView | null) => { this._treeView = treeView; }; - /** - * Call Trees can have different types of "weights" for the data. Choose the - * appropriate labels for the call tree based on this weight. - */ - _weightTypeToColumns = memoize( - (weightType: WeightType): MaybeResizableColumn[] => { - switch (weightType) { - case 'tracing-ms': - return [ - { - propName: 'totalPercent', - titleL10nId: '', - initialWidth: 55, - hideDividerAfter: true, - }, - { - propName: 'total', - titleL10nId: 'CallTree--tracing-ms-total', - minWidth: 30, - initialWidth: 70, - resizable: true, - headerWidthAdjustment: 55 /* totalPercent initialWidth */, - }, - { - propName: 'self', - titleL10nId: 'CallTree--tracing-ms-self', - minWidth: 40, - initialWidth: 80, - resizable: true, - }, - { - propName: 'icon', - titleL10nId: '', - component: Icon as any, - initialWidth: 20, - }, - ]; - case 'samples': - return [ - { - propName: 'totalPercent', - titleL10nId: '', - initialWidth: 55, - hideDividerAfter: true, - }, - { - propName: 'total', - titleL10nId: 'CallTree--samples-total', - minWidth: 30, - initialWidth: 70, - resizable: true, - headerWidthAdjustment: 55 /* totalPercent initialWidth */, - }, - { - propName: 'self', - titleL10nId: 'CallTree--samples-self', - minWidth: 40, - initialWidth: 80, - resizable: true, - }, - { - propName: 'icon', - titleL10nId: '', - component: Icon as any, - initialWidth: 20, - }, - ]; - case 'bytes': - return [ - { - propName: 'totalPercent', - titleL10nId: '', - initialWidth: 55, - hideDividerAfter: true, - }, - { - propName: 'total', - titleL10nId: 'CallTree--bytes-total', - minWidth: 30, - initialWidth: 140, - resizable: true, - headerWidthAdjustment: 55 /* totalPercent initialWidth */, - }, - { - propName: 'self', - titleL10nId: 'CallTree--bytes-self', - minWidth: 40, - initialWidth: 100, - resizable: true, - }, - { - propName: 'icon', - titleL10nId: '', - component: Icon as any, - initialWidth: 20, - }, - ]; - default: - throw assertExhaustiveCheck(weightType, 'Unhandled WeightType.'); - } - }, - // Use a Map cache, as the function only takes one argument, which is a simple string. - { cache: new Map() } - ); - override componentDidMount() { this.focus(); this.maybeProcureInterestingInitialSelection(); @@ -367,9 +248,9 @@ class CallTreeImpl extends PureComponent { return ( = { + propName: 'name', + titleL10nId: '', +}; + +export const libColumn: Column = { + propName: 'lib', + titleL10nId: '', +}; + +export const treeColumnsForTracingMs: MaybeResizableColumn[] = + [ + { + propName: 'totalPercent', + titleL10nId: '', + initialWidth: 55, + hideDividerAfter: true, + }, + { + propName: 'total', + titleL10nId: 'CallTree--tracing-ms-total', + minWidth: 30, + initialWidth: 70, + resizable: true, + headerWidthAdjustment: 55 /* totalPercent initialWidth */, + }, + { + propName: 'self', + titleL10nId: 'CallTree--tracing-ms-self', + minWidth: 40, + initialWidth: 80, + resizable: true, + }, + { + propName: 'icon', + titleL10nId: '', + component: Icon as any, + initialWidth: 20, + }, + ]; + +export const treeColumnsForSamples: MaybeResizableColumn[] = + [ + { + propName: 'totalPercent', + titleL10nId: '', + initialWidth: 55, + hideDividerAfter: true, + }, + { + propName: 'total', + titleL10nId: 'CallTree--samples-total', + minWidth: 30, + initialWidth: 70, + resizable: true, + headerWidthAdjustment: 55 /* totalPercent initialWidth */, + }, + { + propName: 'self', + titleL10nId: 'CallTree--samples-self', + minWidth: 40, + initialWidth: 80, + resizable: true, + }, + { + propName: 'icon', + titleL10nId: '', + component: Icon as any, + initialWidth: 20, + }, + ]; + +export const treeColumnsForBytes: MaybeResizableColumn[] = + [ + { + propName: 'totalPercent', + titleL10nId: '', + initialWidth: 55, + hideDividerAfter: true, + }, + { + propName: 'total', + titleL10nId: 'CallTree--bytes-total', + minWidth: 30, + initialWidth: 140, + resizable: true, + headerWidthAdjustment: 55 /* totalPercent initialWidth */, + }, + { + propName: 'self', + titleL10nId: 'CallTree--bytes-self', + minWidth: 40, + initialWidth: 100, + resizable: true, + }, + { + propName: 'icon', + titleL10nId: '', + component: Icon as any, + initialWidth: 20, + }, + ]; + +export function treeColumnsForWeightType( + weightType: WeightType +): MaybeResizableColumn[] { + switch (weightType) { + case 'tracing-ms': + return treeColumnsForTracingMs; + case 'samples': + return treeColumnsForSamples; + case 'bytes': + return treeColumnsForBytes; + default: + throw assertExhaustiveCheck(weightType, 'Unhandled WeightType.'); + } +} From 4169f211c0d549b40b158006e074af0836faced5 Mon Sep 17 00:00:00 2001 From: Pontoon Date: Wed, 24 Jun 2026 23:40:31 +0000 Subject: [PATCH 30/36] Pontoon/Firefox Profiler: Update Spanish (Chile) (es-CL) Co-authored-by: ravmn (es-CL) --- locales/es-CL/app.ftl | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/locales/es-CL/app.ftl b/locales/es-CL/app.ftl index f14e73b5ff..7591ba2890 100644 --- a/locales/es-CL/app.ftl +++ b/locales/es-CL/app.ftl @@ -46,6 +46,12 @@ AppViewRouter--error-compare = No se pudieron recuperar los perfiles. # https://profiler.firefox.com/from-url/http%3A%2F%2F127.0.0.1%3A3000%2Fprofile.json/ AppViewRouter--error-from-localhost-url-safari = Debido a una limitación específica en Safari, { -profiler-brand-name } no puede importar perfiles de la máquina local en este navegador. Por favor, abre esta página en { -firefox-brand-name } o Chrome en su lugar. .title = Safari no puede importar perfiles locales +# This error message is displayed when the profile is in a newer format version +# than this build of the Profiler is able to read. +AppViewRouter--error-profile-version = + Este perfil utiliza un formato no compatible con esta versión de { -profiler-brand-name }. + + Intenta actualizar la página para comprobar si hay alguna actualización disponible para { -profiler-brand-name }. AppViewRouter--route-not-found--home = .specialMessage = La URL a la que intentaste acceder no fue reconocida. From 876db19c3b95fabb001f23258283f8fb357fa9eb Mon Sep 17 00:00:00 2001 From: fatadel Date: Thu, 25 Jun 2026 12:22:29 +0200 Subject: [PATCH 31/36] Keep menu panels above the selected-marker tooltip (#6125) When a marker stayed selected, its sticky tooltip rendered on top of the Re-upload and other top-bar menu panels, covering them and intercepting clicks. The panels already carry a higher z-index than tooltips (--z-arrow-panel above --z-tooltip), but .profileViewer set z-index: 0, creating a stacking context that trapped the panels at its own altitude. Tooltips render in the body-level #root-overlay layer, which paints later, so they always won regardless of the scale. Removing that z-index lets the panels and tooltips share the root stacking context, where the scale orders them correctly. Closes #6102 --- src/components/app/ProfileViewer.css | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/components/app/ProfileViewer.css b/src/components/app/ProfileViewer.css index 447647b6a1..36d0c4d8e8 100644 --- a/src/components/app/ProfileViewer.css +++ b/src/components/app/ProfileViewer.css @@ -47,10 +47,7 @@ } .profileViewer { - /* Create a stacking context, so that the KeyboardShortcut can overlay the profile - viewer. */ position: relative; - z-index: 0; display: flex; min-width: 0; /* This allows Flexible Layout to shrink this further than its min-content */ flex: 1; From 930639e0c2550a955898c8cf3d8d41f7f36277f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Thu, 25 Jun 2026 19:02:06 +0300 Subject: [PATCH 32/36] Make sure to always sanitize source contents even when no PII sanitization is requested (#6127) This was a regression from #6018. Previously, we didn't have a content column in the sources table. But we now added it, and they are filled automatically during the source map resolution. We would like to add a sharing feature soon, but this needs to be an explicit approval. Due to the early return in this `sanitizePII` function, we were mistakenly keeping these content values if we hit the early return (if the user wants to sanitize nothing). But even if the user wants to upload everything, we should unconditionally sanitize the sources. --- src/profile-logic/sanitize.ts | 27 ++++++++++++++++----------- src/test/unit/sanitize.test.ts | 17 +++++++++++++++++ 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/profile-logic/sanitize.ts b/src/profile-logic/sanitize.ts index a69bec579f..851f919f7f 100644 --- a/src/profile-logic/sanitize.ts +++ b/src/profile-logic/sanitize.ts @@ -61,10 +61,23 @@ export function sanitizePII( maybePIIToBeRemoved: RemoveProfileInformation | null, markerSchemaByName: MarkerSchemaByName ): SanitizeProfileResult { + // FIXME: Currently we always sanitize the source contents while publishing. + // But we should have a sharing option in the publish panel to be able to + // include sources. This needs to happen before the early return below, so + // that the source contents are removed even when no other PII removal is + // requested. + const unconditionallySanitizedShared = { + ...profile.shared, + sources: { + ...profile.shared.sources, + content: Array(profile.shared.sources.content.length).fill(null), + }, + }; + if (maybePIIToBeRemoved === null) { - // Nothing is sanitized. + // Nothing else is sanitized, but the source contents are still removed. return { - profile, + profile: { ...profile, shared: unconditionallySanitizedShared }, isSanitized: false, translationMaps: null, committedRanges: null, @@ -122,18 +135,10 @@ export function sanitizePII( } const stringTable = StringTable.withBackingArray(stringArray); const newShared = { - ...profile.shared, + ...unconditionallySanitizedShared, stringArray, }; - // FIXME: Currently we always sanitize the source contents while publishing. - // But we should have a sharing option in the publish panel to be able to - // include sources. - newShared.sources = { - ...newShared.sources, - content: Array(newShared.sources.content.length).fill(null), - }; - let stackFlags: Uint8Array | null = null; if (windowIdFromPrivateBrowsing.size > 0) { diff --git a/src/test/unit/sanitize.test.ts b/src/test/unit/sanitize.test.ts index 70d43c9ba6..c22f1ad41b 100644 --- a/src/test/unit/sanitize.test.ts +++ b/src/test/unit/sanitize.test.ts @@ -1275,4 +1275,21 @@ describe('sanitizePII', function () { 'file2.js' ); }); + + it('always removes source contents even when no PII removal is requested', function () { + const { profile } = getProfileFromTextSamples(`A[file:file1.js]`); + // There should be only one source. + expect(profile.shared.sources.content.length).toEqual(1); + // Pretend the source content have been loaded. + profile.shared.sources.content[0] = 'source code'; + + // A null `RemoveProfileInformation` means the user kept all sharing options + // checked, so no other sanitization happens. + const { profile: sanitizedProfile } = sanitizePII(profile, [], null, {}); + + expect(sanitizedProfile.shared.sources.content.length).toEqual( + profile.shared.sources.content.length + ); + expect(sanitizedProfile.shared.sources.content[0]).toBe(null); + }); }); From f07749f2b0d4897f878e62f96bc2cd74c7a218a4 Mon Sep 17 00:00:00 2001 From: Pontoon Date: Thu, 25 Jun 2026 19:10:28 +0000 Subject: [PATCH 33/36] Pontoon/Firefox Profiler: Update Friulian (fur) Co-authored-by: Fabio Tomat (fur) --- locales/fur/app.ftl | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/locales/fur/app.ftl b/locales/fur/app.ftl index 2f3c985016..3389b02751 100644 --- a/locales/fur/app.ftl +++ b/locales/fur/app.ftl @@ -811,6 +811,32 @@ TrackNameButton--hide-track = TrackNameButton--hide-process = .title = Plate procès +## TrackMemoryGraph +## This is used to show the memory graph of that process in the timeline part of +## the UI. To learn more about it, visit: +## https://profiler.firefox.com/docs/#/./memory-allocations?id=memory-track + +# Variables: +# $value (String) - the relative memory at this time (e.g. "5MB") +TrackMemoryGraph--relative-memory-at-this-time2 = { $value } + .label = memorie relative a chest pont +# Variables: +# $value (String) - the memory range across the graph (e.g. "5MB") +TrackMemoryGraph--memory-range-in-graph2 = { $value } + .label = interval di memorie tal grafic +# Variables: +# $value (String) - count of allocations and deallocations since the previous sample +TrackMemoryGraph--allocations-and-deallocations-since-the-previous-sample2 = { $value } + .label = assegnazion e disassegnazion dal campion di prime + +## TrackProcessCPUGraph +## This is used to show the CPU usage of a process over time in the timeline. + +# Variables: +# $value (String) - the CPU usage at this sample (e.g. "50%") +TrackProcessCPUGraph--cpu = { $value } + .label = CPU + ## TrackPower ## This is used to show the power used by the CPU and other chips in a computer, ## graphed over time. @@ -856,6 +882,11 @@ TrackPower--tooltip-average-power-watt = { $value } W # $value (String) - the power value at this location TrackPower--tooltip-average-power-milliwatt = { $value } mW .label = Consum medi te selezion atuâl +# This is used in the tooltip when the power value uses the microwatt unit. +# Variables: +# $value (String) - the power value at this location +TrackPower--tooltip-average-power-microwatt = { $value } μW + .label = Consum medi te selezion corinte # This is used in the tooltip when the energy used in the current range uses the # kilowatt-hour unit. # Variables: From d8e52fac0b71361c15398059a6e4eeaca06e3b2a Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 12:06:31 -0400 Subject: [PATCH 34/36] Convert FlameGraphTiming into a class with a getRow() method. This lets us build the timing rows lazily, as the flame graph scrolls them into view. Building the rows isn't that expensive with the regular flame graph, but it'll be more expensive for the inverted "calls to the selected function" icicle flame graph that I'm planning to add to the function list panel. --- src/components/flame-graph/Canvas.tsx | 21 ++- src/components/flame-graph/FlameGraph.tsx | 4 +- src/profile-logic/flame-graph.ts | 45 ++++++- .../__snapshots__/profile-view.test.ts.snap | 126 +++++++++--------- src/test/store/actions.test.ts | 7 +- src/test/store/profile-view.test.ts | 10 +- 6 files changed, 127 insertions(+), 86 deletions(-) diff --git a/src/components/flame-graph/Canvas.tsx b/src/components/flame-graph/Canvas.tsx index 2c5f3745cf..73dd9394da 100644 --- a/src/components/flame-graph/Canvas.tsx +++ b/src/components/flame-graph/Canvas.tsx @@ -243,13 +243,13 @@ class FlameGraphCanvasImpl extends React.PureComponent { // Only draw the stack frames that are vertically within view. // The graph is drawn from bottom to top, in order of increasing depth. for (let depth = startDepth; depth < endDepth; depth++) { - // Get the timing information for a row of stack frames. - const stackTiming = flameGraphTiming[depth]; - - if (!stackTiming) { + if (depth < 0 || depth >= flameGraphTiming.rowCount) { continue; } + // Get the timing information for a row of stack frames. + const stackTiming = flameGraphTiming.getRow(depth); + const cssRowTop: CssPixels = (maxStackDepthPlusOne - depth - 1) * ROW_HEIGHT - viewportTop; const cssRowBottom: CssPixels = @@ -372,10 +372,11 @@ class FlameGraphCanvasImpl extends React.PureComponent { return null; } - const stackTiming = flameGraphTiming[depth]; - if (!stackTiming) { + if (depth < 0 || depth >= flameGraphTiming.rowCount) { return null; } + + const stackTiming = flameGraphTiming.getRow(depth); const callNodeIndex = stackTiming.callNode[flameGraphTimingIndex]; if (callNodeIndex === undefined) { return null; @@ -442,7 +443,7 @@ class FlameGraphCanvasImpl extends React.PureComponent { const { depth, flameGraphTimingIndex } = hoveredItem; const { flameGraphTiming } = this.props; - const stackTiming = flameGraphTiming[depth]; + const stackTiming = flameGraphTiming.getRow(depth); const callNodeIndex = stackTiming.callNode[flameGraphTimingIndex]; return callNodeIndex; } @@ -476,12 +477,10 @@ class FlameGraphCanvasImpl extends React.PureComponent { const depth = Math.floor( maxStackDepthPlusOne - (y + viewportTop) / ROW_HEIGHT ); - const stackTiming = flameGraphTiming[depth]; - - if (!stackTiming) { + if (depth < 0 || depth >= flameGraphTiming.rowCount) { return null; } - + const stackTiming = flameGraphTiming.getRow(depth); for (let i = 0; i < stackTiming.length; i++) { const start = stackTiming.start[i]; const end = stackTiming.end[i]; diff --git a/src/components/flame-graph/FlameGraph.tsx b/src/components/flame-graph/FlameGraph.tsx index bfccc38a93..434efd4a91 100644 --- a/src/components/flame-graph/FlameGraph.tsx +++ b/src/components/flame-graph/FlameGraph.tsx @@ -165,7 +165,7 @@ class FlameGraphImpl const callNodeTable = callNodeInfo.getCallNodeTable(); const depth = callNodeTable.depth[callNodeIndex]; - const row = flameGraphTiming[depth]; + const row = flameGraphTiming.getRow(depth); const columnIndex = row.callNode.indexOf(callNodeIndex); return row.end[columnIndex] - row.start[columnIndex] > SELECTABLE_THRESHOLD; }; @@ -190,7 +190,7 @@ class FlameGraphImpl const callNodeTable = callNodeInfo.getCallNodeTable(); const depth = callNodeTable.depth[callNodeIndex]; - const row = flameGraphTiming[depth]; + const row = flameGraphTiming.getRow(depth); let columnIndex = row.callNode.indexOf(callNodeIndex); do { diff --git a/src/profile-logic/flame-graph.ts b/src/profile-logic/flame-graph.ts index 2cf8e3701d..510ed94fdd 100644 --- a/src/profile-logic/flame-graph.ts +++ b/src/profile-logic/flame-graph.ts @@ -16,9 +16,8 @@ export type FlameGraphDepth = number; export type IndexIntoFlameGraphTiming = number; /** - * FlameGraphTiming is an array containing data used for rendering the - * flame graph. Each element in the array describes one row in the - * graph. Each such element in turn contains one or more functions, + * FlameGraphTimingRow contains the data used for rendering a single + * row of the flame graph. Each row contains one or more functions, * drawn as boxes with start and end positions, represented as unit * intervals of the profile range. It should be noted that start and * end does not represent units of time, but only positions on the @@ -30,13 +29,47 @@ export type IndexIntoFlameGraphTiming = number; * selfRelative contains the self time relative to the total time, * which is used to color the drawn functions. */ -export type FlameGraphTiming = Array<{ +export type FlameGraphTimingRow = { start: UnitIntervalOfProfileRange[]; end: UnitIntervalOfProfileRange[]; selfRelative: Array; callNode: IndexIntoCallNodeTable[]; length: number; -}>; +}; + +/** + * Used by the flame graph canvas to know which boxes to render where. + * + * The flame graph only calls getRow(depth) for on-screen rows; this allows + * the implementation to generate rows lazily as the user scrolls towards + * deeper calls. + */ +export class FlameGraphTiming { + _rows: FlameGraphTimingRow[]; + + constructor(rows: FlameGraphTimingRow[]) { + this._rows = rows; + } + + get rowCount(): number { + return this._rows.length; + } + + 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}` + ); + } + + return this._rows[depth]; + } + + // Convenience method for tests, don't call in production + getAllRowsForTesting(): FlameGraphTimingRow[] { + return this._rows; + } +} /** * FlameGraphRows is an array of rows, where each row is an array of call node @@ -305,5 +338,5 @@ export function getFlameGraphTiming( }; } - return timing; + return new FlameGraphTiming(timing); } diff --git a/src/test/store/__snapshots__/profile-view.test.ts.snap b/src/test/store/__snapshots__/profile-view.test.ts.snap index 0e69b4671b..9bf40c02a2 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -3181,68 +3181,70 @@ Object { `; exports[`snapshots of selectors/profile matches the last stored run of selectedThreadSelector.getFlameGraphTiming 1`] = ` -Array [ - Object { - "callNode": Array [ - 0, - ], - "end": Array [ - 1, - ], - "length": 1, - "selfRelative": Array [ - 0, - ], - "start": Array [ - 0, - ], - }, - Object { - "callNode": Array [ - 1, - ], - "end": Array [ - 1, - ], - "length": 1, - "selfRelative": Array [ - 0, - ], - "start": Array [ - 0, - ], - }, - Object { - "callNode": Array [ - 5, - ], - "end": Array [ - 1, - ], - "length": 1, - "selfRelative": Array [ - 0, - ], - "start": Array [ - 0, - ], - }, - Object { - "callNode": Array [ - 6, - ], - "end": Array [ - 1, - ], - "length": 1, - "selfRelative": Array [ - 1, - ], - "start": Array [ - 0, - ], - }, -] +Object { + "rows": Array [ + Object { + "callNode": Array [ + 0, + ], + "end": Array [ + 1, + ], + "length": 1, + "selfRelative": Array [ + 0, + ], + "start": Array [ + 0, + ], + }, + Object { + "callNode": Array [ + 1, + ], + "end": Array [ + 1, + ], + "length": 1, + "selfRelative": Array [ + 0, + ], + "start": Array [ + 0, + ], + }, + Object { + "callNode": Array [ + 5, + ], + "end": Array [ + 1, + ], + "length": 1, + "selfRelative": Array [ + 0, + ], + "start": Array [ + 0, + ], + }, + Object { + "callNode": Array [ + 6, + ], + "end": Array [ + 1, + ], + "length": 1, + "selfRelative": Array [ + 1, + ], + "start": Array [ + 0, + ], + }, + ], +} `; exports[`snapshots of selectors/profile matches the last stored run of selectedThreadSelector.getJankMarkersForHeader 1`] = ` diff --git a/src/test/store/actions.test.ts b/src/test/store/actions.test.ts index 7c2b2f17c6..4d26391777 100644 --- a/src/test/store/actions.test.ts +++ b/src/test/store/actions.test.ts @@ -206,7 +206,9 @@ describe('selectors/getFlameGraphTiming', function () { store.getState() ); - return flameGraphTiming.map(({ callNode, end, length, start }) => { + const flameGraphTimingRows = flameGraphTiming.getAllRowsForTesting(); + + return flameGraphTimingRows.map(({ callNode, end, length, start }) => { const lines = []; for (let i = 0; i < length; i++) { const callNodeIndex = callNode[i]; @@ -239,8 +241,9 @@ describe('selectors/getFlameGraphTiming', function () { const flameGraphTiming = selectedThreadSelectors.getFlameGraphTiming( store.getState() ); + const flameGraphTimingRows = flameGraphTiming.getAllRowsForTesting(); - return flameGraphTiming.map(({ selfRelative, callNode, length }) => { + return flameGraphTimingRows.map(({ selfRelative, callNode, length }) => { const lines = []; for (let i = 0; i < length; i++) { const callNodeIndex = callNode[i]; diff --git a/src/test/store/profile-view.test.ts b/src/test/store/profile-view.test.ts index 2b830e08c5..9fda33ec65 100644 --- a/src/test/store/profile-view.test.ts +++ b/src/test/store/profile-view.test.ts @@ -2385,9 +2385,13 @@ describe('snapshots of selectors/profile', function () { it('matches the last stored run of selectedThreadSelector.getFlameGraphTiming', function () { const { getState } = setupStore(); - expect( - selectedThreadSelectors.getFlameGraphTiming(getState()) - ).toMatchSnapshot(); + const timing = selectedThreadSelectors.getFlameGraphTiming(getState()); + // Build a plain shape with the flame graph timing contents, so that the + // snapshot doesn't contain the class name / class fields. + const plainTiming = { + rows: timing.getAllRowsForTesting(), + }; + expect(plainTiming).toMatchSnapshot(); }); it('matches the last stored run of selectedThreadSelector.getFriendlyThreadName', function () { From f31eedf8bb4250465e00da728ec2006cba0d9d9d Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 13:26:06 -0400 Subject: [PATCH 35/36] Compute FlameGraphTiming rows lazily. This takes advantage of the fact that the flame graph now requests a row only once it's on the screen. Note that there's a difference between "FlameGraphRows" and "FlameGraphTiming" rows. The FlameGraphRows are still computed eagerly. It's just the timing that is now computed lazily per displayed row. The FlameGraphRows are based on the call node info and independent of sample counts and preview selection. When switching to the Flame Graph panel on https://share.firefox.dev/4g4xGue , with a flame graph canvas height of 564px, I'm getting the following profiles: Before: https://share.firefox.dev/4w2QEG3 (410ms tab switch) After: https://share.firefox.dev/3QYB7IA (272ms tab switch) In those profiles you can also see the different memory usage characteristics. --- src/profile-logic/flame-graph.ts | 176 ++++++++++++++++--------------- 1 file changed, 93 insertions(+), 83 deletions(-) diff --git a/src/profile-logic/flame-graph.ts b/src/profile-logic/flame-graph.ts index 510ed94fdd..14f118e843 100644 --- a/src/profile-logic/flame-graph.ts +++ b/src/profile-logic/flame-graph.ts @@ -45,14 +45,32 @@ export type FlameGraphTimingRow = { * deeper calls. */ export class FlameGraphTiming { - _rows: FlameGraphTimingRow[]; - - constructor(rows: FlameGraphTimingRow[]) { - this._rows = rows; + _flameGraphRows: FlameGraphRows; + _callNodeTable: CallNodeTable; + _callTreeTimings: CallTreeTimingsNonInverted; + + // Populated lazily by _buildNextTimingRow(). + _timingRows: FlameGraphTimingRow[]; + + // Used to position the children call node boxes: For a given parent box, its + // first (left-most) child box starts at the same x position as the parent. + _startPerCallNode: Float32Array; + + constructor( + flameGraphRows: FlameGraphRows, + callNodeTable: CallNodeTable, + callTreeTimings: CallTreeTimingsNonInverted + ) { + this._flameGraphRows = flameGraphRows; + this._callNodeTable = callNodeTable; + this._callTreeTimings = callTreeTimings; + + this._timingRows = []; + this._startPerCallNode = new Float32Array(callNodeTable.length); } get rowCount(): number { - return this._rows.length; + return this._flameGraphRows.length; } getRow(depth: number): FlameGraphTimingRow { @@ -61,13 +79,78 @@ export class FlameGraphTiming { `Out-of-bounds call to getRow: ${depth} is outside 0..${this.rowCount}` ); } - - return this._rows[depth]; + while (this._timingRows.length <= depth) { + this._buildNextTimingRow(); + } + return this._timingRows[depth]; } // Convenience method for tests, don't call in production getAllRowsForTesting(): FlameGraphTimingRow[] { - return this._rows; + const rows = []; + for (let depth = 0; depth < this.rowCount; depth++) { + rows.push(this.getRow(depth)); + } + return rows; + } + + _buildNextTimingRow(): void { + const { total, self, rootTotalSummary } = this._callTreeTimings; + const { prefix } = this._callNodeTable; + + const depth = this._timingRows.length; + const rowNodes = this._flameGraphRows[depth]; + const startPerCallNode = this._startPerCallNode; + + // 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[] = []; + + // Sibling boxes are adjacent. Whenever the prefix changes, jump ahead to + // the new parent's start so children stay aligned under their parent. + // + // Previous row: [B ][D ] [G ] + // Current row: [C][E][F] [I ] + // (Note: upside down from how the flame graph is usually displayed.) + let currentStart = 0; + let previousPrefixCallNode = -1; + for (let i = 0; i < rowNodes.length; i++) { + const nodeIndex = rowNodes[i]; + const totalVal = total[nodeIndex]; + if (totalVal === 0) { + continue; + } + + const nodePrefix = prefix[nodeIndex]; + if (nodePrefix !== previousPrefixCallNode) { + currentStart = nodePrefix === -1 ? 0 : startPerCallNode[nodePrefix]; + previousPrefixCallNode = nodePrefix; + } + startPerCallNode[nodeIndex] = currentStart; + + const totalRelative = abs(totalVal / rootTotalSummary); + const selfRelativeVal = abs(self[nodeIndex] / rootTotalSummary); + + const currentEnd = currentStart + totalRelative; + start.push(currentStart); + end.push(currentEnd); + selfRelative.push(selfRelativeVal); + timingCallNodes.push(nodeIndex); + + currentStart = currentEnd; + } + + this._timingRows.push({ + start, + end, + selfRelative, + callNode: timingCallNodes, + length: timingCallNodes.length, + }); } } @@ -258,85 +341,12 @@ export function computeFlameGraphRows( } /** - * Build a FlameGraphTiming table from a call tree. + * Build a FlameGraphTiming from a call tree. */ export function getFlameGraphTiming( flameGraphRows: FlameGraphRows, callNodeTable: CallNodeTable, callTreeTimings: CallTreeTimingsNonInverted ): FlameGraphTiming { - const { total, self, rootTotalSummary } = callTreeTimings; - const { prefix } = callNodeTable; - - // This is where we build up the return value, one row at a time. - const timing = []; - - // This is used to adjust the start position of a call node's box based on the - // start position of its prefix node's box. - const startPerCallNode = new Float32Array(callNodeTable.length); - - // Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1858310 - const abs = Math.abs; - - // Go row by row. - for (let depth = 0; depth < flameGraphRows.length; depth++) { - const rowNodes = flameGraphRows[depth]; - - const start = []; - const end = []; - const selfRelative = []; - const timingCallNodes = []; - - // Process the call nodes in this row. Sibling boxes are adjacent to each other. - // Whenever the prefix changes, we need to add a gap so that the child boxes - // start at the same position as the parent box. - // - // Previous row: [B ][D ] [G ] - // Current row: [C][E][F] [I ] - // (Note that this is upside down from how the flame graph is usually displayed) - let currentStart = 0; - let previousPrefixCallNode = -1; - for (let indexInRow = 0; indexInRow < rowNodes.length; indexInRow++) { - const nodeIndex = rowNodes[indexInRow]; - const totalVal = total[nodeIndex]; - if (totalVal === 0) { - // Skip boxes with zero width. - continue; - } - - const nodePrefix = prefix[nodeIndex]; - if (nodePrefix !== previousPrefixCallNode) { - // We have advanced to a node with a different parent, so we need to - // jump ahead to the parent box's start position. - currentStart = startPerCallNode[nodePrefix]; - previousPrefixCallNode = nodePrefix; - } - - // Write down the start position of this call node so that it can be - // checked later by this node's children. - startPerCallNode[nodeIndex] = currentStart; - - // Take the absolute value, as native deallocations can be negative. - const totalRelativeVal = abs(totalVal / rootTotalSummary); - const selfRelativeVal = abs(self[nodeIndex] / rootTotalSummary); - - const currentEnd = currentStart + totalRelativeVal; - start.push(currentStart); - end.push(currentEnd); - selfRelative.push(selfRelativeVal); - timingCallNodes.push(nodeIndex); - - // The start position of the next box is the end position of the current box. - currentStart = currentEnd; - } - timing[depth] = { - start, - end, - selfRelative, - callNode: timingCallNodes, - length: timingCallNodes.length, - }; - } - - return new FlameGraphTiming(timing); + return new FlameGraphTiming(flameGraphRows, callNodeTable, callTreeTimings); } From de0953be0d94ecd5e9f3924e678fb715e0d05fa8 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:49:09 -0400 Subject: [PATCH 36/36] Create a non-connected FlameGraph component. This will let us use flame graphs in other places, for example in the function list panel (for callee / caller views) or in a benchmark comparison view. ConnectedFlameGraph is the connected version and works as before. This commit also removes MaybeFlameGraph. The flame graph no longer respects the global "is inverted" flag, so the performance warning in MaybeFlameGraph was never shown. The only other functionality of MaybeFlameGraph was that it displayed "empty reasons" when the preview selection was empty; this part has been subsumed into ConnectedFlameGraph. --- .../flame-graph/ConnectedFlameGraph.tsx | 241 ++++++++++++++++++ src/components/flame-graph/FlameGraph.tsx | 168 +++--------- .../flame-graph/MaybeFlameGraph.css | 19 -- .../flame-graph/MaybeFlameGraph.tsx | 88 ------- src/components/flame-graph/index.tsx | 74 ++++-- 5 files changed, 333 insertions(+), 257 deletions(-) create mode 100644 src/components/flame-graph/ConnectedFlameGraph.tsx delete mode 100644 src/components/flame-graph/MaybeFlameGraph.css delete mode 100644 src/components/flame-graph/MaybeFlameGraph.tsx diff --git a/src/components/flame-graph/ConnectedFlameGraph.tsx b/src/components/flame-graph/ConnectedFlameGraph.tsx new file mode 100644 index 0000000000..8895b8812b --- /dev/null +++ b/src/components/flame-graph/ConnectedFlameGraph.tsx @@ -0,0 +1,241 @@ +/* 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 '../../utils/connect'; +import { FlameGraph, type FlameGraphHandle } from './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 '../../selectors/url-state'; +import { + changeSelectedCallNode, + changeRightClickedCallNode, + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, +} from 'firefox-profiler/actions/profile-view'; + +import type { + Thread, + CategoryList, + Milliseconds, + StartEndRange, + WeightType, + SamplesLikeTable, + PreviewSelection, + CallTreeSummaryStrategy, + IndexIntoCallNodeTable, + ThreadsKey, + InnerWindowID, + Page, + SampleCategoriesAndSubcategories, +} 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'; + +export type ConnectedFlameGraphHandle = FlameGraphHandle; + +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: typeof changeSelectedCallNode; + readonly changeRightClickedCallNode: typeof changeRightClickedCallNode; + readonly handleCallNodeTransformShortcut: typeof handleCallNodeTransformShortcut; + readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +class ConnectedFlameGraphImpl + extends React.PureComponent + implements ConnectedFlameGraphHandle +{ + _flameGraph: React.RefObject = React.createRef(); + + // eslint-disable-next-line react/no-unused-class-component-methods -- called via ConnectedFlameGraphHandle ref from FlameGraphViewImpl + focus() { + this._flameGraph.current?.focus(); + } + + _onSelectedCallNodeChange = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + const { callNodeInfo, threadsKey, changeSelectedCallNode } = this.props; + changeSelectedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(callNodeIndex) + ); + }; + + _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('flame-graph', 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 ConnectedFlameGraph = explicitConnectWithForwardRef< + {}, + StateProps, + DispatchProps, + ConnectedFlameGraphHandle +>({ + mapStateToProps: (state) => ({ + thread: selectedThreadSelectors.getFilteredThread(state), + weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), + // Use the filtered call node max depth, rather than the preview filtered one, so + // that the viewport height is stable across preview selections. + maxStackDepthPlusOne: + selectedThreadSelectors.getFilteredCallNodeMaxDepthPlusOne(state), + flameGraphTiming: selectedThreadSelectors.getFlameGraphTiming(state), + callTree: selectedThreadSelectors.getCallTree(state), + timeRange: getCommittedRange(state), + previewSelection: getPreviewSelection(state), + callNodeInfo: selectedThreadSelectors.getCallNodeInfo(state), + categories: getCategories(state), + threadsKey: getSelectedThreadsKey(state), + selectedCallNodeIndex: + selectedThreadSelectors.getSelectedCallNodeIndex(state), + rightClickedCallNodeIndex: + selectedThreadSelectors.getRightClickedCallNodeIndex(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, + changeRightClickedCallNode, + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, + }, + options: { forwardRef: true }, + component: ConnectedFlameGraphImpl, +}); diff --git a/src/components/flame-graph/FlameGraph.tsx b/src/components/flame-graph/FlameGraph.tsx index 434efd4a91..159aaa0ea5 100644 --- a/src/components/flame-graph/FlameGraph.tsx +++ b/src/components/flame-graph/FlameGraph.tsx @@ -3,27 +3,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import * as React from 'react'; -import { explicitConnectWithForwardRef } from '../../utils/connect'; import { FlameGraphCanvas } from './Canvas'; - -import { - getCategories, - getCommittedRange, - getPreviewSelection, - getScrollToSelectionGeneration, - getProfileInterval, - getInnerWindowIDToPageMap, - getProfileUsesMultipleStackTypes, -} from 'firefox-profiler/selectors/profile'; -import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; -import { getSelectedThreadsKey } from '../../selectors/url-state'; import { ContextMenuTrigger } from 'firefox-profiler/components/shared/ContextMenuTrigger'; -import { - changeSelectedCallNode, - changeRightClickedCallNode, - handleCallNodeTransformShortcut, - updateBottomBoxContentsAndMaybeOpen, -} from 'firefox-profiler/actions/profile-view'; import { extractNonInvertedCallTreeTimings } from 'firefox-profiler/profile-logic/call-tree'; import { ensureExists } from 'firefox-profiler/utils/types'; @@ -51,8 +32,6 @@ import type { CallTreeTimings, } from 'firefox-profiler/profile-logic/call-tree'; -import type { ConnectedProps } from 'firefox-profiler/utils/connect'; - import './FlameGraph.css'; const STACK_FRAME_HEIGHT = 16; @@ -64,7 +43,7 @@ const STACK_FRAME_HEIGHT = 16; */ const SELECTABLE_THRESHOLD = 0.001; -type StateProps = { +export type Props = { readonly thread: Thread; readonly weightType: WeightType; readonly innerWindowIDToPageMap: Map | null; @@ -85,20 +64,27 @@ type StateProps = { readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; readonly tracedTiming: CallTreeTimings | null; readonly displayStackType: boolean; + readonly contextMenuId?: string; + readonly onSelectedCallNodeChange: ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => void; + readonly onRightClickedCallNodeChange: ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => void; + readonly onCallNodeEnterOrDoubleClick: ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => void; + readonly onKeyboardTransformShortcut: ( + event: React.KeyboardEvent, + nodeIndex: IndexIntoCallNodeTable + ) => void; }; -type DispatchProps = { - readonly changeSelectedCallNode: typeof changeSelectedCallNode; - readonly changeRightClickedCallNode: typeof changeRightClickedCallNode; - readonly handleCallNodeTransformShortcut: typeof handleCallNodeTransformShortcut; - readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; -}; -type Props = ConnectedProps<{}, StateProps, DispatchProps>; export interface FlameGraphHandle { focus(): void; } -class FlameGraphImpl +export class FlameGraph extends React.PureComponent implements FlameGraphHandle { @@ -112,44 +98,13 @@ class FlameGraphImpl document.removeEventListener('copy', this._onCopy, false); } - _onSelectedCallNodeChange = ( - callNodeIndex: IndexIntoCallNodeTable | null - ) => { - const { callNodeInfo, threadsKey, changeSelectedCallNode } = this.props; - changeSelectedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(callNodeIndex) - ); - }; - - _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('flame-graph', bottomBoxInfo); - }; - _shouldDisplayTooltips = () => this.props.rightClickedCallNodeIndex === null; _takeViewportRef = (viewport: HTMLDivElement | null) => { this._viewport = viewport; }; - /* This method is called from MaybeFlameGraph. */ + /* This method is called from ConnectedFlameGraph. */ /* eslint-disable-next-line react/no-unused-class-component-methods */ focus = () => { if (this._viewport) { @@ -211,13 +166,13 @@ class FlameGraphImpl _handleKeyDown = (event: React.KeyboardEvent) => { const { - threadsKey, callTree, callNodeInfo, selectedCallNodeIndex, rightClickedCallNodeIndex, - changeSelectedCallNode, - handleCallNodeTransformShortcut, + onSelectedCallNodeChange, + onCallNodeEnterOrDoubleClick, + onKeyboardTransformShortcut, } = this.props; const callNodeTable = callNodeInfo.getCallNodeTable(); @@ -227,10 +182,7 @@ class FlameGraphImpl ) { if (selectedCallNodeIndex === null) { // Just select the "root" node if we've got no prior selection. - changeSelectedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(0) - ); + onSelectedCallNodeChange(0); return; } @@ -238,10 +190,7 @@ class FlameGraphImpl case 'ArrowDown': { const prefix = callNodeTable.prefix[selectedCallNodeIndex]; if (prefix !== -1) { - changeSelectedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(prefix) - ); + onSelectedCallNodeChange(prefix); } break; } @@ -253,10 +202,7 @@ class FlameGraphImpl // thus the widest box. if (callNodeIndex !== undefined && this._wideEnough(callNodeIndex)) { - changeSelectedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(callNodeIndex) - ); + onSelectedCallNodeChange(callNodeIndex); } break; } @@ -268,10 +214,7 @@ class FlameGraphImpl ); if (callNodeIndex !== undefined) { - changeSelectedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(callNodeIndex) - ); + onSelectedCallNodeChange(callNodeIndex); } break; } @@ -294,11 +237,11 @@ class FlameGraphImpl } if (event.key === 'Enter') { - this._onCallNodeEnterOrDoubleClick(nodeIndex); + onCallNodeEnterOrDoubleClick(nodeIndex); return; } - handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); + onKeyboardTransformShortcut(event, nodeIndex); }; _onCopy = (event: ClipboardEvent) => { @@ -338,6 +281,10 @@ class FlameGraphImpl ctssSampleCategoriesAndSubcategories, tracedTiming, displayStackType, + contextMenuId = 'CallNodeContextMenu', + onSelectedCallNodeChange, + onRightClickedCallNodeChange, + onCallNodeEnterOrDoubleClick, } = this.props; // Get the CallTreeTimingsNonInverted out of tracedTiming. We pass this @@ -358,7 +305,7 @@ class FlameGraphImpl return (
({ - mapStateToProps: (state) => ({ - thread: selectedThreadSelectors.getFilteredThread(state), - weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), - // Use the filtered call node max depth, rather than the preview filtered one, so - // that the viewport height is stable across preview selections. - maxStackDepthPlusOne: - selectedThreadSelectors.getFilteredCallNodeMaxDepthPlusOne(state), - flameGraphTiming: selectedThreadSelectors.getFlameGraphTiming(state), - callTree: selectedThreadSelectors.getCallTree(state), - timeRange: getCommittedRange(state), - previewSelection: getPreviewSelection(state), - callNodeInfo: selectedThreadSelectors.getCallNodeInfo(state), - categories: getCategories(state), - threadsKey: getSelectedThreadsKey(state), - selectedCallNodeIndex: - selectedThreadSelectors.getSelectedCallNodeIndex(state), - rightClickedCallNodeIndex: - selectedThreadSelectors.getRightClickedCallNodeIndex(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, - changeRightClickedCallNode, - handleCallNodeTransformShortcut, - updateBottomBoxContentsAndMaybeOpen, - }, - options: { forwardRef: true }, - component: FlameGraphImpl, -}); diff --git a/src/components/flame-graph/MaybeFlameGraph.css b/src/components/flame-graph/MaybeFlameGraph.css deleted file mode 100644 index fcc1267de5..0000000000 --- a/src/components/flame-graph/MaybeFlameGraph.css +++ /dev/null @@ -1,19 +0,0 @@ -.flameGraphDisabledMessage { - --internal-background-color: var(--grey-20); - - display: flex; - flex: 1; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 10px; - background-color: var(--internal-background-color); - box-shadow: inset 0 0 150px var(--base-shadow-color); - font-size: 120%; -} - -:root.dark-mode { - .flameGraphDisabledMessage { - --internal-background-color: var(--grey-80); - } -} diff --git a/src/components/flame-graph/MaybeFlameGraph.tsx b/src/components/flame-graph/MaybeFlameGraph.tsx deleted file mode 100644 index 5dd0f0f227..0000000000 --- a/src/components/flame-graph/MaybeFlameGraph.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/* 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 { getInvertCallstack } from '../../selectors/url-state'; -import { selectedThreadSelectors } from '../../selectors/per-thread'; -import { changeInvertCallstack } from '../../actions/profile-view'; -import { FlameGraphEmptyReasons } from './FlameGraphEmptyReasons'; -import { FlameGraph, type FlameGraphHandle } from './FlameGraph'; - -import type { ConnectedProps } from 'firefox-profiler/utils/connect'; - -import './MaybeFlameGraph.css'; - -// TODO: This component isn't needed any more. Whenever the selected tab -// is "flame-graph", `invertCallstack` will be `false`. is -// only used in the "flame-graph" tab. - -type StateProps = { - readonly isPreviewSelectionEmpty: boolean; - readonly invertCallstack: boolean; -}; -type DispatchProps = { - readonly changeInvertCallstack: typeof changeInvertCallstack; -}; -type Props = ConnectedProps<{}, StateProps, DispatchProps>; - -class MaybeFlameGraphImpl extends React.PureComponent { - _flameGraph: React.RefObject = React.createRef(); - - _onSwitchToNormalCallstackClick = () => { - this.props.changeInvertCallstack(false); - }; - - override componentDidMount() { - const flameGraph = this._flameGraph.current; - if (flameGraph) { - flameGraph.focus(); - } - } - - override render() { - const { isPreviewSelectionEmpty, invertCallstack } = this.props; - - if (isPreviewSelectionEmpty) { - return ; - } - - if (invertCallstack) { - return ( -
-

The Flame Graph is not available for inverted call stacks

-

- {' '} - to show the Flame Graph. -

-
- ); - } - return ; - } -} - -export const MaybeFlameGraph = explicitConnectWithForwardRef< - {}, - StateProps, - DispatchProps, - MaybeFlameGraphImpl ->({ - mapStateToProps: (state) => { - return { - invertCallstack: getInvertCallstack(state), - isPreviewSelectionEmpty: - !selectedThreadSelectors.getHasPreviewFilteredCtssSamples(state), - }; - }, - mapDispatchToProps: { - changeInvertCallstack, - }, - component: MaybeFlameGraphImpl, -}); diff --git a/src/components/flame-graph/index.tsx b/src/components/flame-graph/index.tsx index 141147302c..70af6f60c0 100644 --- a/src/components/flame-graph/index.tsx +++ b/src/components/flame-graph/index.tsx @@ -2,21 +2,63 @@ * 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 { StackSettings } from '../shared/StackSettings'; import { TransformNavigator } from '../shared/TransformNavigator'; -import { MaybeFlameGraph } from './MaybeFlameGraph'; - -const FlameGraphView = () => ( -
- - - -
-); - -export const FlameGraph = FlameGraphView; +import { + ConnectedFlameGraph, + type ConnectedFlameGraphHandle, +} from './ConnectedFlameGraph'; +import { FlameGraphEmptyReasons } from './FlameGraphEmptyReasons'; +import explicitConnect from 'firefox-profiler/utils/connect'; +import { selectedThreadSelectors } from '../../selectors/per-thread'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +type StateProps = { + readonly isPreviewSelectionEmpty: boolean; +}; + +type Props = ConnectedProps<{}, StateProps, {}>; + +class FlameGraphViewImpl extends React.PureComponent { + _connectedFlameGraph: React.RefObject = + React.createRef(); + + override componentDidMount() { + this._connectedFlameGraph.current?.focus(); + } + + override render() { + const { isPreviewSelectionEmpty } = this.props; + + return ( +
+ + + {isPreviewSelectionEmpty ? ( + + ) : ( + + )} +
+ ); + } +} + +const FlameGraphViewConnected = explicitConnect<{}, StateProps, {}>({ + mapStateToProps: (state) => ({ + isPreviewSelectionEmpty: + !selectedThreadSelectors.getHasPreviewFilteredCtssSamples(state), + }), + mapDispatchToProps: {}, + component: FlameGraphViewImpl, +}); + +export const FlameGraph = FlameGraphViewConnected;