From 622a37f90845dafb8ff748d053634c733364361f Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Wed, 20 May 2026 15:20:19 -0400 Subject: [PATCH 01/88] Rename serializeProfile to serializeProfileToJsonString. We'll want to add a binary serialization soon, but we'll also keep the JSON serializatios as an option. This rename makes it clear which one you're calling. --- src/actions/publish.ts | 6 +++-- src/profile-logic/process-profile.ts | 2 +- src/test/components/DragAndDrop.test.tsx | 12 ++++++---- src/test/fixtures/profiles/zip-file.ts | 4 ++-- src/test/store/receive-profile.test.ts | 28 ++++++++++++++---------- src/test/store/zipped-profiles.test.ts | 4 ++-- src/test/unit/process-profile.test.ts | 8 +++---- src/test/unit/profile-fetch.test.ts | 4 ++-- src/test/unit/profile-upgrading.test.ts | 6 +++-- 9 files changed, 44 insertions(+), 30 deletions(-) diff --git a/src/actions/publish.ts b/src/actions/publish.ts index 3cbef3751b..5b427b929f 100644 --- a/src/actions/publish.ts +++ b/src/actions/publish.ts @@ -60,7 +60,7 @@ import type { ProfileIndexTranslationMaps, } from 'firefox-profiler/types'; import { compress } from 'firefox-profiler/utils/gz'; -import { serializeProfile } from 'firefox-profiler/profile-logic/process-profile'; +import { serializeProfileToJsonString } from 'firefox-profiler/profile-logic/process-profile'; export function updateSharingOption( slug: keyof CheckedSharingOptions, @@ -322,7 +322,9 @@ export function encodeSanitizedProfile( const encodingPromise: Promise = (async function () { try { dispatch(sanitizedProfileEncodingStarted(sanitizedProfile)); - const gzipData = await compress(serializeProfile(sanitizedProfile)); + const gzipData = await compress( + serializeProfileToJsonString(sanitizedProfile) + ); const blob = new Blob([gzipData], { type: 'application/octet-binary' }); dispatch(sanitizedProfileEncodingCompleted(sanitizedProfile, blob)); return { type: 'SUCCESS', profileData: blob }; diff --git a/src/profile-logic/process-profile.ts b/src/profile-logic/process-profile.ts index d1f44222dc..001c336011 100644 --- a/src/profile-logic/process-profile.ts +++ b/src/profile-logic/process-profile.ts @@ -1922,7 +1922,7 @@ export function processGeckoProfile(geckoProfile: GeckoProfile): Profile { /** * Take a processed profile and convert it to a string. */ -export function serializeProfile(profile: Profile): string { +export function serializeProfileToJsonString(profile: Profile): string { return JSON.stringify(profile); } diff --git a/src/test/components/DragAndDrop.test.tsx b/src/test/components/DragAndDrop.test.tsx index 96e26a3bf2..c3618112ff 100644 --- a/src/test/components/DragAndDrop.test.tsx +++ b/src/test/components/DragAndDrop.test.tsx @@ -12,7 +12,7 @@ import { DragAndDropOverlay, } from '../../components/app/DragAndDrop'; import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; -import { serializeProfile } from '../../profile-logic/process-profile'; +import { serializeProfileToJsonString } from '../../profile-logic/process-profile'; import { getView } from 'firefox-profiler/selectors'; import { updateBrowserConnectionStatus } from 'firefox-profiler/actions/app'; import { mockWebChannel } from '../fixtures/mocks/web-channel'; @@ -100,9 +100,13 @@ describe('app/DragAndDrop', () => { const [dragAndDrop, overlay] = container.children; const { profile } = getProfileFromTextSamples('A'); - const file = new File([serializeProfile(profile)], 'profile.json', { - type: 'application/json', - }); + const file = new File( + [serializeProfileToJsonString(profile)], + 'profile.json', + { + type: 'application/json', + } + ); const files = [file]; fireEvent.dragEnter(dragAndDrop); diff --git a/src/test/fixtures/profiles/zip-file.ts b/src/test/fixtures/profiles/zip-file.ts index 07d19e37d7..a0f4c527ce 100644 --- a/src/test/fixtures/profiles/zip-file.ts +++ b/src/test/fixtures/profiles/zip-file.ts @@ -2,7 +2,7 @@ * 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 { getProfileFromTextSamples } from '../../fixtures/profiles/processed-profile'; -import { serializeProfile } from '../../../profile-logic/process-profile'; +import { serializeProfileToJsonString } from '../../../profile-logic/process-profile'; import { receiveZipFile } from '../../../actions/receive-profile'; import { setDataSource } from '../../../actions/profile-view'; import type { @@ -17,7 +17,7 @@ import JSZip from 'jszip'; */ export function getZippedProfiles(files: string[] = []): JSZip { const { profile } = getProfileFromTextSamples('A'); - const profileText = serializeProfile(profile); + const profileText = serializeProfileToJsonString(profile); const zip = new JSZip(); files.forEach((fileName) => { diff --git a/src/test/store/receive-profile.test.ts b/src/test/store/receive-profile.test.ts index 08fa5b3601..f480f467c9 100644 --- a/src/test/store/receive-profile.test.ts +++ b/src/test/store/receive-profile.test.ts @@ -36,7 +36,7 @@ import { createGeckoProfile } from '../fixtures/profiles/gecko-profile'; import { blankStore, storeWithProfile } from '../fixtures/stores'; import { processGeckoProfile, - serializeProfile, + serializeProfileToJsonString, } from '../../profile-logic/process-profile'; import { getProfileFromTextSamples, @@ -1044,7 +1044,7 @@ describe('actions/receive-profile', function () { const expectedUrl = 'https://profiles.club/shared.json'; window.fetchMock.get( expectedUrl, - compress(serializeProfile(_getSimpleProfile())) + compress(serializeProfileToJsonString(_getSimpleProfile())) ); const store = blankStore(); await store.dispatch(retrieveProfileOrZipFromUrl(expectedUrl)); @@ -1174,7 +1174,7 @@ describe('actions/receive-profile', function () { const { getState, view } = await setupTestWithFile({ type: 'application/json', - payload: serializeProfile(profile), + payload: serializeProfileToJsonString(profile), }); expect(view.phase).toBe('DATA_LOADED'); expect(ProfileViewSelectors.getProfile(getState()).meta.product).toEqual( @@ -1194,7 +1194,7 @@ describe('actions/receive-profile', function () { const { getState, view } = await setupTestWithFile({ type: 'application/json', - payload: JSON.stringify(profile), // Note: No serializeProfile call! + payload: JSON.stringify(profile), // Note: No serializeProfileToJsonString call! }); expect(view.phase).toBe('DATA_LOADED'); @@ -1243,7 +1243,7 @@ describe('actions/receive-profile', function () { const { getState, view } = await setupTestWithFile({ type: '', - payload: serializeProfile(profile), + payload: serializeProfileToJsonString(profile), }); expect(view.phase).toBe('DATA_LOADED'); expect(ProfileViewSelectors.getProfile(getState()).meta.product).toEqual( @@ -1257,7 +1257,9 @@ describe('actions/receive-profile', function () { const { getState, view } = await setupTestWithFile({ type: '', - payload: extractArrayBuffer(await compress(serializeProfile(profile))), + payload: extractArrayBuffer( + await compress(serializeProfileToJsonString(profile)) + ), }); expect(view.phase).toBe('DATA_LOADED'); expect(ProfileViewSelectors.getProfile(getState()).meta.product).toEqual( @@ -1289,7 +1291,9 @@ describe('actions/receive-profile', function () { const { getState, view } = await setupTestWithFile({ type: 'application/gzip', - payload: extractArrayBuffer(await compress(serializeProfile(profile))), + payload: extractArrayBuffer( + await compress(serializeProfileToJsonString(profile)) + ), }); expect(view.phase).toBe('DATA_LOADED'); expect(ProfileViewSelectors.getProfile(getState()).meta.product).toEqual( @@ -1303,7 +1307,9 @@ describe('actions/receive-profile', function () { const { getState, view } = await setupTestWithFile({ type: 'application/json', - payload: extractArrayBuffer(await compress(serializeProfile(profile))), + payload: extractArrayBuffer( + await compress(serializeProfileToJsonString(profile)) + ), }); expect(view.phase).toBe('DATA_LOADED'); expect(ProfileViewSelectors.getProfile(getState()).meta.product).toEqual( @@ -1345,7 +1351,7 @@ describe('actions/receive-profile', function () { it('can load a zipped profile', async function () { const { getState, view } = await setupZipTestWithProfile( 'profile.json', - serializeProfile(_getSimpleProfile()) + serializeProfileToJsonString(_getSimpleProfile()) ); expect(view.phase).toBe('DATA_LOADED'); const zipInStore = ZippedProfilesSelectors.getZipFile(getState()); @@ -1358,7 +1364,7 @@ describe('actions/receive-profile', function () { it('will load and view a simple profile with no errors', async function () { const { getState, dispatch } = await setupZipTestWithProfile( 'profile.json', - serializeProfile(_getSimpleProfile()) + serializeProfileToJsonString(_getSimpleProfile()) ); expect(ZippedProfilesSelectors.getZipFileState(getState()).phase).toEqual( @@ -1376,7 +1382,7 @@ describe('actions/receive-profile', function () { it('will be an error to view a profile with no threads', async function () { const { getState, dispatch } = await setupZipTestWithProfile( 'profile.json', - serializeProfile(getEmptyProfile()) + serializeProfileToJsonString(getEmptyProfile()) ); expect(ZippedProfilesSelectors.getZipFileState(getState()).phase).toEqual( diff --git a/src/test/store/zipped-profiles.test.ts b/src/test/store/zipped-profiles.test.ts index 19db955c36..66a40e40ce 100644 --- a/src/test/store/zipped-profiles.test.ts +++ b/src/test/store/zipped-profiles.test.ts @@ -16,7 +16,7 @@ import * as ZippedProfilesActions from '../../actions/zipped-profiles'; import * as ReceiveProfileActions from '../../actions/receive-profile'; import * as ProfileViewActions from '../../actions/profile-view'; import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; -import { serializeProfile } from '../../profile-logic/process-profile'; +import { serializeProfileToJsonString } from '../../profile-logic/process-profile'; import { compress } from '../../utils/gz'; import type { PreviewSelection } from 'firefox-profiler/types'; @@ -112,7 +112,7 @@ describe('reducer zipFileState', function () { // returns a typed array whose `instanceof Uint8Array` check fails against // the main realm's global, so JSZip wouldn't recognize it directly. const gzippedProfile = new Uint8Array( - await compress(serializeProfile(profile)) + await compress(serializeProfileToJsonString(profile)) ); const zip = new JSZip(); diff --git a/src/test/unit/process-profile.test.ts b/src/test/unit/process-profile.test.ts index 27cc48a646..e3afc6165b 100644 --- a/src/test/unit/process-profile.test.ts +++ b/src/test/unit/process-profile.test.ts @@ -4,7 +4,7 @@ import { extractFuncsAndResourcesFromFrameLocations, processGeckoProfile, - serializeProfile, + serializeProfileToJsonString, unserializeProfileOfArbitraryFormat, } from '../../profile-logic/process-profile'; import { GlobalDataCollector } from 'firefox-profiler/profile-logic/global-data-collector'; @@ -382,19 +382,19 @@ describe('gecko profilerOverhead processing', function () { describe('serializeProfile', function () { it('should produce a parsable profile string', async function () { const profile = processGeckoProfile(createGeckoProfile()); - const serialized = serializeProfile(profile); + const serialized = serializeProfileToJsonString(profile); expect(JSON.parse.bind(null, serialized)).not.toThrow(); }); it('should produce the same profile in a roundtrip', async function () { const profile = processGeckoProfile(createGeckoProfile()); - const serialized = serializeProfile(profile); + const serialized = serializeProfileToJsonString(profile); const roundtrip = await unserializeProfileOfArbitraryFormat(serialized); // FIXME: Uncomment this line after resolving `undefined` serialization issue // See: https://github.com/firefox-devtools/profiler/issues/1599 // expect(profile).toEqual(roundtrip); - const secondSerialized = serializeProfile(roundtrip); + const secondSerialized = serializeProfileToJsonString(roundtrip); const secondRountrip = await unserializeProfileOfArbitraryFormat(secondSerialized); expect(roundtrip).toEqual(secondRountrip); diff --git a/src/test/unit/profile-fetch.test.ts b/src/test/unit/profile-fetch.test.ts index f971b291ad..8484c9aee1 100644 --- a/src/test/unit/profile-fetch.test.ts +++ b/src/test/unit/profile-fetch.test.ts @@ -4,7 +4,7 @@ import JSZip from 'jszip'; import { fetchProfile } from '../../utils/profile-fetch'; -import { serializeProfile } from '../../profile-logic/process-profile'; +import { serializeProfileToJsonString } from '../../profile-logic/process-profile'; import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; import { extractArrayBuffer } from '../fixtures/utils'; @@ -37,7 +37,7 @@ describe('fetchProfile', function () { content: 'generated-zip' | 'generated-json' | Uint8Array; }) { const { url, contentType, content } = obj; - const stringProfile = serializeProfile(_getSimpleProfile()); + const stringProfile = serializeProfileToJsonString(_getSimpleProfile()); const profile = JSON.parse(stringProfile); let arrayBuffer; diff --git a/src/test/unit/profile-upgrading.test.ts b/src/test/unit/profile-upgrading.test.ts index f510cec935..6345faf12b 100644 --- a/src/test/unit/profile-upgrading.test.ts +++ b/src/test/unit/profile-upgrading.test.ts @@ -4,7 +4,7 @@ import { unserializeProfileOfArbitraryFormat, - serializeProfile, + serializeProfileToJsonString, } from '../../profile-logic/process-profile'; import { upgradeGeckoProfileToCurrentVersion } from '../../profile-logic/gecko-profile-versioning'; import { @@ -79,7 +79,9 @@ describe('upgrading processed profiles', function () { expect(upgradedProfile.meta.preprocessedProfileVersion).toEqual( PROCESSED_PROFILE_VERSION ); - expect(JSON.parse(serializeProfile(upgradedProfile))).toMatchSnapshot(); + expect( + JSON.parse(serializeProfileToJsonString(upgradedProfile)) + ).toMatchSnapshot(); } it('should upgrade processed-1.json all the way to the current version', async function () { From 206f8ff2c7c8b0df0072c8bddda3aad81ff39296 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sat, 16 May 2026 00:30:49 -0400 Subject: [PATCH 02/88] Support reading profiles from JsonSlabs files. Fixes #6024. JsonSlabs is an alternative serialization format that's more efficient than JSON if the serialized object contains typed arrays. See https://github.com/mstange/json-slabs/ for details. Our profiles currently do not yet contain typed arrays. But with this in place, we can start converting more and more tables / columns to use typed arrays, and incrementally reap the efficiency benefits. This patch only adds the reading. In the UI, uploading / downloading profiles still uses JSON. For the profiler-edit node script, this patch also adds the "writing": If you run profiler-edit with `-o some-filename-ending-in.jslb`, then it will create a JSLB file (or a compressed JSLB file for .jslb.gz). --- The profile format "structure" is still the same as before. The format version is still given by profile.meta.preprocessedProfileVersion. Loading a JSLB file with a profile.meta.preprocessedProfileVersion that's higher than the currently known one will still complain, and attempt to reload the page or the service worker. This reload behavior is the primary reason why I want to get the "reading" part taken care of first, before we start converting any parts of the profile to get efficiency wins: The sooner we land this, the more likely it will be that, in the future, if somebody has a newer JSLB file but an old cached profiler, they'll see a useful error and get the auto-reload. Without this patch, loading a JSLB file in the profiler gives the following error: > Error: Unserializing the profile failed: Error: The profile array > buffer could not be parsed as a UTF-8 string. --- With all that said, it turns out there is already a case where this format gives an efficiency win, thanks to its "split out" feature that lets us put certain subtrees of the profile into separate JSON slabs: If the original JSON is larger 512MiB, we can split it into individual slabs which are each smaller than 512MiB, so the built-in JSON.parse will work and we don't have to use the slow streaming parse. Example: https://storage.googleapis.com/profiler-get-symbols-fixtures/large-speedometer3-profile.json.gz expands to a 577.74MB JSON file, https://storage.googleapis.com/profiler-get-symbols-fixtures/large-speedometer3-profile.jslb.gz expands to a 564.66MB JSLB file (slightly smaller because compacting removed unused strings) The JSLB file breaks down as follows: ``` idx type bytes elements path --- ------- ---------- -------- ------------------- 0 json 16.76 MiB - . (root) 1 json 264.82 MiB - .shared.stackTable 2 json 91.01 MiB - .shared.frameTable 3 json 8.47 MiB - .shared.funcTable 4 json 28.80 MiB - .shared.stringArray 5 json 154.80 MiB - .threads --- ------- ---------- -------- ------------------- 6 slabs 564.66 MiB ``` All of those individual JSON slabs are now under 512MiB. Loading the JSON file, uses streaming parser: https://share.firefox.dev/3PcB9vK (19 seconds) Loading the JSLB file, uses native JSON.parse: https://share.firefox.dev/3RkFcXs (2.3 seconds, 8.3x faster) --- jest.config.js | 2 +- package.json | 1 + src/node-tools/profiler-edit.ts | 40 ++++++++++++++++++------- src/profile-logic/process-profile.ts | 42 ++++++++++++++++++++++++++- src/test/unit/process-profile.test.ts | 25 ++++++++++++++++ yarn.lock | 5 ++++ 6 files changed, 103 insertions(+), 12 deletions(-) diff --git a/jest.config.js b/jest.config.js index 3b89ea2c4e..c221c66a00 100644 --- a/jest.config.js +++ b/jest.config.js @@ -18,7 +18,7 @@ const browserEnvConfig = { // Transform ESM modules to CommonJS for Jest // These packages ship as pure ESM and need to be transformed by Babel transformIgnorePatterns: [ - '/node_modules/(?!(query-string|decode-uri-component|iongraph-web|split-on-first|filter-obj|fetch-mock|devtools-reps)/)', + '/node_modules/(?!(query-string|decode-uri-component|iongraph-web|split-on-first|filter-obj|fetch-mock|devtools-reps|json-slabs)/)', ], // Mock static assets (images, CSS, etc.) diff --git a/package.json b/package.json index fa49d965d5..6dc7f0e74d 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "gecko-profiler-demangle": "^0.4.0", "idb": "^8.0.3", "iongraph-web": "0.2.1", + "json-slabs": "^0.3.0", "jszip": "^3.10.1", "long": "^5.3.2", "memoize-immutable": "^3.0.0", diff --git a/src/node-tools/profiler-edit.ts b/src/node-tools/profiler-edit.ts index 07c56c6133..61fbd82f6b 100644 --- a/src/node-tools/profiler-edit.ts +++ b/src/node-tools/profiler-edit.ts @@ -4,7 +4,11 @@ import fs from 'fs'; import minimist from 'minimist'; -import { unserializeProfileOfArbitraryFormat } from 'firefox-profiler/profile-logic/process-profile'; +import { + serializeProfileToJsonSlabsFile, + serializeProfileToJsonString, + unserializeProfileOfArbitraryFormat, +} from 'firefox-profiler/profile-logic/process-profile'; import { computeCompactedProfile } from 'firefox-profiler/profile-logic/profile-compacting'; import { GOOGLE_STORAGE_BUCKET } from 'firefox-profiler/app-logic/constants'; import { compress } from 'firefox-profiler/utils/gz'; @@ -128,6 +132,24 @@ async function loadProfile(source: ProfileSource): Promise { } } +async function encodeProfileWithFilename( + profile: Profile, + filename: string +): Promise { + if (filename.endsWith('.jslb') || filename.endsWith('.jslb.gz')) { + const bytes = serializeProfileToJsonSlabsFile(profile); + if (filename.endsWith('.jslb.gz')) { + return compress(bytes); + } + return bytes; + } + const s = serializeProfileToJsonString(profile); + if (filename.endsWith('.gz')) { + return compress(s); + } + return new TextEncoder().encode(s); +} + export async function run(options: CliOptions) { const profile = await loadProfile(options.input); @@ -185,15 +207,13 @@ export async function run(options: CliOptions) { const { profile: compactedProfile } = computeCompactedProfile(profile); - console.log(`Saving profile to ${options.output}`); - if (options.output.endsWith('.gz')) { - fs.writeFileSync( - options.output, - await compress(JSON.stringify(compactedProfile)) - ); - } else { - fs.writeFileSync(options.output, JSON.stringify(compactedProfile)); - } + const outputFilename = options.output; + console.log(`Saving profile to ${outputFilename}`); + const bytes = await encodeProfileWithFilename( + compactedProfile, + outputFilename + ); + fs.writeFileSync(outputFilename, bytes); console.log('Finished.'); } diff --git a/src/profile-logic/process-profile.ts b/src/profile-logic/process-profile.ts index 001c336011..24846ec17b 100644 --- a/src/profile-logic/process-profile.ts +++ b/src/profile-logic/process-profile.ts @@ -1,6 +1,12 @@ /* 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 { + isJsonSlabsFile, + decode as decodeJsonSlabs, + encode as encodeJsonSlabs, +} from 'json-slabs'; + import { attemptToConvertChromeProfile } from './import/chrome'; import { attemptToConvertDhat } from './import/dhat'; import { GlobalDataCollector } from './global-data-collector'; @@ -1926,6 +1932,38 @@ export function serializeProfileToJsonString(profile: Profile): string { return JSON.stringify(profile); } +/** + * Take a profile and convert it to a Uint8Array in the JsonSlabs format. + * + * This is more efficient than JSON if the profile contains large typed arrays. + */ +export function serializeProfileToJsonSlabsFile( + profile: Profile +): Uint8Array { + // Encode the profile object with the binary JsonSlabs container format. + return encodeJsonSlabs(profile, [ + // "Split-out" slabs: + // + // This second argument to the encode function is an array of objects which + // should be pulled out into their own dedicated slabs. This is totally + // optional and doesn't change what the decoded object will look like. + // We use it to "split out" some large tables as long as we haven't converted + // them to use typed arrays. This already gives us a benefit: It means that + // decoding will use several JSON.parse calls rather than just one single + // JSON.parse call, and each individual JSON.parse will act on a smaller + // string, which means it's less likely to hit any string size limits. + // + // As we convert more and more tables / columns to typed arrays, the "skeleton + // JSON" for these tables will become much smaller and we won't need to split + // out those tables anymore. + profile.threads, + profile.shared.stackTable, + profile.shared.frameTable, + profile.shared.funcTable, + profile.shared.stringArray, + ]); +} + // If applicable, this function will try to "fix" a processed profile that was // copied from the console on an old version of the UI, where such a profile // would have a `stringTable` property rather than a `stringArray` property on @@ -2062,7 +2100,9 @@ export async function unserializeProfileOfArbitraryFormat( profileBytes = await decompress(profileBytes); } - if (isArtTraceFormat(profileBytes)) { + if (isJsonSlabsFile(profileBytes)) { + arbitraryFormat = decodeJsonSlabs(profileBytes); + } else if (isArtTraceFormat(profileBytes)) { arbitraryFormat = convertArtTraceProfile(profileBytes); } else if (verifyMagic(SIMPLEPERF_MAGIC, profileBytes)) { const { convertSimpleperfTraceProfile } = diff --git a/src/test/unit/process-profile.test.ts b/src/test/unit/process-profile.test.ts index e3afc6165b..f446068b0f 100644 --- a/src/test/unit/process-profile.test.ts +++ b/src/test/unit/process-profile.test.ts @@ -1,9 +1,12 @@ /* 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 { isJsonSlabsFile } from 'json-slabs'; + import { extractFuncsAndResourcesFromFrameLocations, processGeckoProfile, + serializeProfileToJsonSlabsFile, serializeProfileToJsonString, unserializeProfileOfArbitraryFormat, } from '../../profile-logic/process-profile'; @@ -401,6 +404,28 @@ describe('serializeProfile', function () { }); }); +describe('serializeProfileToJsonSlabsFile', function () { + it('should produce bytes recognized as a JsonSlabs file', function () { + const profile = processGeckoProfile(createGeckoProfile()); + const bytes = serializeProfileToJsonSlabsFile(profile); + expect(isJsonSlabsFile(bytes)).toBe(true); + }); + + it('should produce the same profile in a roundtrip', async function () { + const profile = processGeckoProfile(createGeckoProfile()); + const bytes = serializeProfileToJsonSlabsFile(profile); + const roundtrip = await unserializeProfileOfArbitraryFormat(bytes); + + // Two roundtrips should be stable, mirroring the JSON serializer test + // above (see issue #1599 for why we can't compare against the original + // profile directly). + const secondBytes = serializeProfileToJsonSlabsFile(roundtrip); + const secondRoundtrip = + await unserializeProfileOfArbitraryFormat(secondBytes); + expect(roundtrip).toEqual(secondRoundtrip); + }); +}); + describe('js allocation processing', function () { function getAllocationMarkerHelper(geckoThread: GeckoThread) { let time = 0; diff --git a/yarn.lock b/yarn.lock index 1a8b1944c3..3c31f37dc8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6918,6 +6918,11 @@ json-schema@^0.4.0: resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== +json-slabs@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/json-slabs/-/json-slabs-0.3.0.tgz#e1306d100c3b9946f2d897f18d8889ff24839a6c" + integrity sha512-C1EPAoAweC1R8YIjxK7Wu22ms/gVQF+fYQlYlUDjQHPJ3uj1XLPVPxkSG3Zx55Z6g5r4Bte1I79n7qu03FRC6Q== + json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" From e83613595f017373d94626a2e973d8c39e9ed3b4 Mon Sep 17 00:00:00 2001 From: "depfu[bot]" <23717796+depfu[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 11:19:56 +0200 Subject: [PATCH 03/88] Update all Yarn dependencies (2026-05-27) (#6063) Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com> --- package.json | 6 +++--- yarn.lock | 55 ++++++++++++++-------------------------------------- 2 files changed, 18 insertions(+), 43 deletions(-) diff --git a/package.json b/package.json index 42c936abfa..3f3aab20bb 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.41.1", + "@codemirror/view": "^6.43.0", "@firefox-devtools/react-contextmenu": "^5.2.3", "@fluent/bundle": "^0.19.1", "@fluent/langneg": "^0.7.0", @@ -99,12 +99,12 @@ "mixedtuplemap": "^1.0.0", "namedtuplemap": "^1.0.0", "photon-colors": "^3.3.2", - "protobufjs": "^8.4.0", + "protobufjs": "^8.4.2", "query-string": "^9.3.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-intersection-observer": "^10.0.3", - "react-redux": "^9.2.0", + "react-redux": "^9.3.0", "redux": "^5.0.1", "redux-logger": "^3.0.6", "redux-thunk": "^3.1.0", diff --git a/yarn.lock b/yarn.lock index 719c5e29e5..843c7cf975 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.41.1": - version "6.41.1" - resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.41.1.tgz#8ecb39af289e7d03df0f3fb6e3c9b1f8747839bf" - integrity sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg== +"@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== dependencies: "@codemirror/state" "^6.6.0" crelt "^1.0.6" @@ -9200,10 +9200,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.4.0: - version "8.4.0" - resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-8.4.0.tgz#57664ab3f835ec6e326fd9a98178da132b8b7240" - integrity sha512-iriNhQ57SYA5Jbdi+41AyPdx6jPPkFO7DODzkOBmqFhgYn/JzX2HxgxYPY18eQAs3CP/AWqtPvkWn8rclRAxdQ== +protobufjs@^8.4.2: + version "8.4.2" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-8.4.2.tgz#758b5ed274b8d859f95174cbdb569f359a804e29" + integrity sha512-64rfNzkWOZAIazXzpBFPWq6F9up6gMvTzjE2oWIzApx2N/dqVUEE7+bCn2+40780dFVtKOUab8QfxJ6KJDWbqA== dependencies: long "^5.3.2" @@ -9362,10 +9362,10 @@ react-is@^17.0.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== -react-redux@^9.2.0: - version "9.2.0" - resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.2.0.tgz#96c3ab23fb9a3af2cb4654be4b51c989e32366f5" - integrity sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g== +react-redux@^9.3.0: + version "9.3.0" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.3.0.tgz#a30113bb6d95c0a715d54dda4308d450fca6ce09" + integrity sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g== dependencies: "@types/use-sync-external-store" "^0.0.6" use-sync-external-store "^1.4.0" @@ -10376,16 +10376,7 @@ string-length@^4.0.2: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -10515,7 +10506,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -10529,13 +10520,6 @@ strip-ansi@^0.3.0: dependencies: ansi-regex "^0.2.1" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1, strip-ansi@^7.1.0, strip-ansi@^7.1.2: version "7.1.2" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" @@ -11901,16 +11885,7 @@ workbox-window@7.4.1, workbox-window@^7.4.1: "@types/trusted-types" "^2.0.2" workbox-core "7.4.1" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== From 4a79539d256eabde8023475a6d31ade775bc5256 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Wed, 27 May 2026 13:01:30 +0200 Subject: [PATCH 04/88] Fix call node context menu being hidden behind source view bottom box (#6045) DetailsContainer set `position: relative; z-index: 0`, creating a stacking context that trapped the context menus (rendered inside DetailsContainer) at z-index 0. The BottomBox is a sibling of DetailsContainer that comes later in the DOM, so it painted on top of the menus whenever the source view was open. Removing the stacking context from DetailsContainer lets the context menu's --z-context-menu compete at .profileViewer level and paint above the BottomBox. The parent .profileViewer still provides a stacking context for the KeyboardShortcut overlay. --- src/components/app/DetailsContainer.css | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/app/DetailsContainer.css b/src/components/app/DetailsContainer.css index 78bd724119..5c24522df0 100644 --- a/src/components/app/DetailsContainer.css +++ b/src/components/app/DetailsContainer.css @@ -1,6 +1,4 @@ .DetailsContainer { - position: relative; - z-index: 0; display: flex; box-sizing: border-box; flex: 1; From d6c0f72f2ef2bed885a25ccf205d6bcb2f73d67b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Wed, 27 May 2026 20:30:18 +0200 Subject: [PATCH 05/88] Pass --use-env-proxy to the daemon only on Node.js >= 24 The flag was added in Node 24. On older runtimes node exits immediately with "bad option" before the daemon can write any log, create the session directory, or bind its socket. The client process has no visibility into the spawned daemon (stdio is ignored and no exit listener is attached), so the user only sees "Failed to start daemon: session not validated after 500ms" with no indication that the daemon crashed on startup. Pass the flag conditionally based on process.versions.node, and warn on stderr when a proxy env var is set but the runtime is too old to honor it. --- profiler-cli/src/client.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/profiler-cli/src/client.ts b/profiler-cli/src/client.ts index b343797b50..42c095b97b 100644 --- a/profiler-cli/src/client.ts +++ b/profiler-cli/src/client.ts @@ -213,6 +213,15 @@ export async function sendCommand( return sendMessage(sessionDir, { type: 'command', command }, sessionId); } +function hasProxyEnvVar(): boolean { + return Boolean( + process.env.HTTP_PROXY || + process.env.HTTPS_PROXY || + process.env.http_proxy || + process.env.https_proxy + ); +} + /** * Start a new daemon for the given profile. * Uses a two-phase approach: @@ -257,10 +266,21 @@ export async function startNewDaemon( // Get the path to the current script (profiler-cli.js) const scriptPath = process.argv[1]; + // --use-env-proxy was added in Node.js 24. On older runtimes node would + // exit immediately with "bad option", taking the daemon down with it. + const nodeMajor = parseInt(process.versions.node.split('.')[0], 10); + const supportsUseEnvProxy = nodeMajor >= 24; + + if (!supportsUseEnvProxy && hasProxyEnvVar()) { + console.warn( + `Warning: Node.js ${process.versions.node} detected. HTTP_PROXY/HTTPS_PROXY env vars will not be honored when fetching profiles or symbols. Upgrade to Node.js >= 24 for proxy support.` + ); + } + const daemonArgs = [ // Make fetch respect HTTP_PROXY/HTTPS_PROXY/NO_PROXY. This is the default - // in a lot of tools like, curl, python, go etc. - '--use-env-proxy', + // in a lot of tools like curl, python, go etc. + ...(supportsUseEnvProxy ? ['--use-env-proxy'] : []), scriptPath, '--daemon', absolutePath, From ec6727053bbd384f666afbc17c9e7e3fc37e6770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Wed, 27 May 2026 20:35:33 +0200 Subject: [PATCH 06/88] Detect daemon spawn failures via child exit listener startNewDaemon previously spawned the daemon with stdio: 'ignore' and no exit listener, so any spawn-time failure (unsupported Node flag, missing dependency, immediate crash) surfaced only as a generic "session not validated after 500ms" timeout. The user had no way to tell whether the daemon was slow to start or had died on launch. Attach 'exit' and 'error' listeners to the spawned child and check them on every phase 1 polling iteration (and once after the loop, in case the daemon exits while a final validation is in flight). When the daemon dies early, throw an error that includes the exit code or signal and points the user at the session log directory. --- profiler-cli/src/client.ts | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/profiler-cli/src/client.ts b/profiler-cli/src/client.ts index 42c095b97b..4704c2a063 100644 --- a/profiler-cli/src/client.ts +++ b/profiler-cli/src/client.ts @@ -222,6 +222,17 @@ function hasProxyEnvVar(): boolean { ); } +function formatEarlyExitError(earlyExit: { + code: number | null; + signal: NodeJS.Signals | null; +}): string { + const reason = + earlyExit.signal !== null + ? `signal ${earlyExit.signal}` + : `exit code ${earlyExit.code}`; + return `Daemon process exited unexpectedly during startup (${reason}). Run with PROFILER_CLI_SESSION_DIR set and check the session directory for a log file, or re-run after upgrading Node.js.`; +} + /** * Start a new daemon for the given profile. * Uses a two-phase approach: @@ -305,6 +316,19 @@ export async function startNewDaemon( // Unref so parent can exit child.unref(); + // Observe early daemon death so spawn-time failures surface immediately + // instead of as a generic 500ms validation timeout. + const daemonStartupState: { + earlyExit: { code: number | null; signal: NodeJS.Signals | null } | null; + spawnError: Error | null; + } = { earlyExit: null, spawnError: null }; + child.once('exit', (code, signal) => { + daemonStartupState.earlyExit = { code, signal }; + }); + child.once('error', (err) => { + daemonStartupState.spawnError = err; + }); + // Phase 1: Wait for daemon to be validated (short timeout) const daemonStartMaxAttempts = 10; // 10 * 50ms = 500ms let attempts = 0; @@ -313,6 +337,15 @@ export async function startNewDaemon( await new Promise((resolve) => setTimeout(resolve, 50)); attempts++; + if (daemonStartupState.spawnError) { + throw new Error( + `Failed to spawn daemon: ${daemonStartupState.spawnError.message}` + ); + } + if (daemonStartupState.earlyExit) { + throw new Error(formatEarlyExitError(daemonStartupState.earlyExit)); + } + // Validate the session (checks metadata exists, process running, socket exists) if (await validateSession(sessionDir, targetSessionId)) { // Daemon is validated and running @@ -322,6 +355,9 @@ export async function startNewDaemon( // Check if daemon started successfully after polling if (!(await validateSession(sessionDir, targetSessionId))) { + if (daemonStartupState.earlyExit) { + throw new Error(formatEarlyExitError(daemonStartupState.earlyExit)); + } throw new Error( `Failed to start daemon: session not validated after ${daemonStartMaxAttempts * 50}ms` ); From 9faa037efba3abc9e5a7e20ec80c21d302b4b1af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Wed, 27 May 2026 20:49:36 +0200 Subject: [PATCH 07/88] Bump profiler-cli version to 0.2.1 --- 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 c24e70766b..5d528a63d7 100644 --- a/profiler-cli/package.json +++ b/profiler-cli/package.json @@ -1,6 +1,6 @@ { "name": "@firefox-devtools/profiler-cli", - "version": "0.2.0", + "version": "0.2.1", "description": "Command-line interface for querying Firefox Profiler profiles with persistent daemon sessions", "scripts": { "prepublishOnly": "node ../scripts/verify-profiler-cli-build.mjs" From d75c87c31188282c6a1fa8c8c131579f65587667 Mon Sep 17 00:00:00 2001 From: Pontoon Date: Wed, 27 May 2026 19:25:59 +0000 Subject: [PATCH 08/88] Pontoon/Firefox Profiler: Update Turkish (tr) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Selim Şumlu (tr) --- locales/tr/app.ftl | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/locales/tr/app.ftl b/locales/tr/app.ftl index 2a9f89684d..57a6ad654d 100644 --- a/locales/tr/app.ftl +++ b/locales/tr/app.ftl @@ -138,6 +138,11 @@ CallTree--tracing-ms-total = Çalışma süresi (ms) .title = “Toplam” çalışma süresi, bu fonksiyonun yığında gözlemlendiği tüm sürenin özetini içerir. Bu süre, fonksiyonun kendisinin çalıştığı süreyi ve bu fonksiyondan çağırılanların sürelerini içerir. CallTree--tracing-ms-self = Öz (ms) .title = “Öz” süre yalnızca fonksiyonun yığının sonunda olduğu süreyi içerir. Eğer fonksiyon başka fonksiyonları çağırmışsa diğer fonksiyonların süresi dahil değildir. Öz süre, programda asıl nerede zaman harcandığını anlamak için faydalıdır. +CallTree--samples-total = Toplam (örnekler) + .title = + “Toplam” örnek sayısı, bu fonksiyonun yığın üzerinde görüldüğü her örneğin + bir özetini içerir. Fonksiyonun gerçekten çalıştığı süre ile bu fonksiyondan + çağrılan fonksiyonlarda geçirilen süre de buna dahildir. ## Call tree "badges" (icons) with tooltips ## @@ -288,6 +293,9 @@ Home--record-instructions = Home--instructions-content2 = Performans profilleri yalnızca { -firefox-brand-name }’un masaüstü sürümü ile kaydedilebilir. Ancak mevcut profiller herhangi bir modern tarayıcıda görüntülenebilir. +Home--fenix-instructions-directly = + { -firefox-android-brand-name } bu cihazda doğrudan profillenebilir. + Daha fazla bilgi için { -firefox-android-brand-name } uygulamasını cihazda doğrudan profilleme sayfasını okuyabilirsiniz. Home--record-instructions-start-stop = Profillemeyi durdur ve başlat Home--record-instructions-capture-load = Profili yakala ve yükle Home--profiler-motto = Performans profili kaydedin. Analiz edin. Paylaşın. Web’i daha hızlı hale getirin. From 9fb6dbcb9a17a68717e60dac2f90231364fc671f Mon Sep 17 00:00:00 2001 From: fatadel Date: Thu, 28 May 2026 14:49:36 +0200 Subject: [PATCH 09/88] Upgrade @firefox-devtools/react-contextmenu to 5.2.4 (#6066) --- package.json | 2 +- yarn.lock | 39 ++++++++++++++++++++++++++++++++------- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 3f3aab20bb..23d7e9cba6 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "@codemirror/language": "^6.12.3", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.43.0", - "@firefox-devtools/react-contextmenu": "^5.2.3", + "@firefox-devtools/react-contextmenu": "^5.2.4", "@fluent/bundle": "^0.19.1", "@fluent/langneg": "^0.7.0", "@fluent/react": "^0.15.2", diff --git a/yarn.lock b/yarn.lock index 843c7cf975..9aadcec184 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1421,10 +1421,10 @@ "@eslint/core" "^0.17.0" levn "^0.4.1" -"@firefox-devtools/react-contextmenu@^5.2.3": - version "5.2.3" - resolved "https://registry.yarnpkg.com/@firefox-devtools/react-contextmenu/-/react-contextmenu-5.2.3.tgz#84fe061597a896ab66917914b8b975c40bd730e9" - integrity sha512-41JIwHtIQq2gLe6E6IbObhgucSOrLsdTs6SYoBucJIXlC2dRchidVMEjvOgvLspOwkPQnqoRMTYVQwWpoCfiLA== +"@firefox-devtools/react-contextmenu@^5.2.4": + version "5.2.4" + resolved "https://registry.yarnpkg.com/@firefox-devtools/react-contextmenu/-/react-contextmenu-5.2.4.tgz#125f9d9238afe7a8403eea419bb8ae4d2267aff8" + integrity sha512-Kv6p3P/3apkTb8pECePTrbjHzX4cDFbpOuFtCwfBBH0pub9mqo26jb0lp1jVvqso3mHNgrLNjyImwKmevQAOXw== dependencies: classnames "^2.5.1" object-assign "^4.1.0" @@ -10376,7 +10376,16 @@ string-length@^4.0.2: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -10506,7 +10515,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -10520,6 +10529,13 @@ strip-ansi@^0.3.0: dependencies: ansi-regex "^0.2.1" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1, strip-ansi@^7.1.0, strip-ansi@^7.1.2: version "7.1.2" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" @@ -11885,7 +11901,16 @@ workbox-window@7.4.1, workbox-window@^7.4.1: "@types/trusted-types" "^2.0.2" workbox-core "7.4.1" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== From 08c584af3211cf018b54855c9117e034e59435eb Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Wed, 27 May 2026 16:03:05 -0400 Subject: [PATCH 10/88] Switch profiler-edit from minimist to commander. --- package.json | 2 - src/node-tools/profiler-edit.ts | 134 +++++++++++++++++++------------- yarn.lock | 4 +- 3 files changed, 82 insertions(+), 58 deletions(-) diff --git a/package.json b/package.json index 23d7e9cba6..1c082839f2 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,6 @@ "long": "^5.3.2", "memoize-immutable": "^3.0.0", "memoize-one": "^6.0.0", - "minimist": "^1.2.8", "mixedtuplemap": "^1.0.0", "namedtuplemap": "^1.0.0", "photon-colors": "^3.3.2", @@ -126,7 +125,6 @@ "@types/clamp": "^1.0.3", "@types/common-tags": "^1.8.4", "@types/jest": "^30.0.0", - "@types/minimist": "^1.2.5", "@types/node": "^22.19.19", "@types/query-string": "^6.3.0", "@types/react": "^18.3.29", diff --git a/src/node-tools/profiler-edit.ts b/src/node-tools/profiler-edit.ts index 07c56c6133..49c11a55f8 100644 --- a/src/node-tools/profiler-edit.ts +++ b/src/node-tools/profiler-edit.ts @@ -2,7 +2,7 @@ * 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 fs from 'fs'; -import minimist from 'minimist'; +import { Command, CommanderError, Option } from 'commander'; import { unserializeProfileOfArbitraryFormat } from 'firefox-profiler/profile-logic/process-profile'; import { computeCompactedProfile } from 'firefox-profiler/profile-logic/profile-compacting'; @@ -197,28 +197,73 @@ export async function run(options: CliOptions) { console.log('Finished.'); } +function collectWasm( + value: string, + previous: WasmSymbolicationCliSpec[] +): WasmSymbolicationCliSpec[] { + // Accept "=" if the LHS looks like a URL, otherwise treat the + // whole string as a path and infer the URL from the profile. Split on + // the last `=` so URLs containing `=` (e.g. in query strings) survive + // intact; this assumes file paths don't contain `=`. + const eqIndex = value.lastIndexOf('='); + if (eqIndex !== -1 && /^[a-z]+:\/\//i.test(value.slice(0, eqIndex))) { + return [ + ...previous, + { + strippedWasmUrl: value.slice(0, eqIndex), + unstrippedWasmPath: value.slice(eqIndex + 1), + }, + ]; + } + return [...previous, { unstrippedWasmPath: value }]; +} + export function makeOptionsFromArgv(processArgv: string[]): CliOptions { - const argv = minimist(processArgv.slice(2), { - alias: { i: 'input', o: 'output' }, - }); + const program = new Command(); + program + .name('profiler-edit') + .description('Edit and transform Firefox performance profiles') + .exitOverride() + .option( + '-i, --input ', + 'Input profile (file path or http(s) URL)' + ) + .option('-o, --output ', 'Output path (.json or .json.gz)') + .option('--from-file ', 'Load input from a file') + .option('--from-url ', 'Load input from a URL') + .option('--from-hash ', 'Load input from a profile hash') + .option( + '--symbolicate-with-server ', + 'Symbolicate frames using this symbol server URL' + ) + .addOption( + new Option( + '--symbolicate-wasm ', + 'Apply wasm symbol info, as = or just ' + ) + .argParser(collectWasm) + .default([] as WasmSymbolicationCliSpec[]) + ); - const sources: ProfileSource[] = []; + program.parse(processArgv); + const opts = program.opts(); - if (typeof argv.input === 'string' && argv.input !== '') { - if (/^https?:\/\//i.test(argv.input)) { - sources.push({ type: 'URL', url: argv.input }); + const sources: ProfileSource[] = []; + if (typeof opts.input === 'string' && opts.input !== '') { + if (/^https?:\/\//i.test(opts.input)) { + sources.push({ type: 'URL', url: opts.input }); } else { - sources.push({ type: 'FILE', path: argv.input }); + sources.push({ type: 'FILE', path: opts.input }); } } - if (typeof argv['from-file'] === 'string' && argv['from-file'] !== '') { - sources.push({ type: 'FILE', path: argv['from-file'] }); + if (typeof opts.fromFile === 'string' && opts.fromFile !== '') { + sources.push({ type: 'FILE', path: opts.fromFile }); } - if (typeof argv['from-url'] === 'string' && argv['from-url'] !== '') { - sources.push({ type: 'URL', url: argv['from-url'] }); + if (typeof opts.fromUrl === 'string' && opts.fromUrl !== '') { + sources.push({ type: 'URL', url: opts.fromUrl }); } - if (typeof argv['from-hash'] === 'string' && argv['from-hash'] !== '') { - sources.push({ type: 'HASH', hash: argv['from-hash'] }); + if (typeof opts.fromHash === 'string' && opts.fromHash !== '') { + sources.push({ type: 'HASH', hash: opts.fromHash }); } if (sources.length === 0) { @@ -232,55 +277,36 @@ export function makeOptionsFromArgv(processArgv: string[]): CliOptions { ); } - if (!(typeof argv.output === 'string' && argv.output !== '')) { + if (!(typeof opts.output === 'string' && opts.output !== '')) { throw new Error('An output path must be supplied with --output / -o'); } - const symbolicateWasm: WasmSymbolicationCliSpec[] = []; - const rawWasmArg = argv['symbolicate-wasm']; - let wasmArgs: unknown[]; - if (rawWasmArg === undefined) { - wasmArgs = []; - } else if (Array.isArray(rawWasmArg)) { - wasmArgs = rawWasmArg; - } else { - wasmArgs = [rawWasmArg]; - } - for (const arg of wasmArgs) { - if (typeof arg !== 'string' || arg === '') { - throw new Error('--symbolicate-wasm requires a value'); - } - // Accept "=" if the LHS looks like a URL, otherwise treat the - // whole string as a path and infer the URL from the profile. Split on - // the last `=` so URLs containing `=` (e.g. in query strings) survive - // intact; this assumes file paths don't contain `=`. - const eqIndex = arg.lastIndexOf('='); - if (eqIndex !== -1 && /^[a-z]+:\/\//i.test(arg.slice(0, eqIndex))) { - symbolicateWasm.push({ - strippedWasmUrl: arg.slice(0, eqIndex), - unstrippedWasmPath: arg.slice(eqIndex + 1), - }); - } else { - symbolicateWasm.push({ unstrippedWasmPath: arg }); - } - } - return { input: sources[0], - output: argv.output, + output: opts.output, symbolicateWithServer: - typeof argv['symbolicate-with-server'] === 'string' && - argv['symbolicate-with-server'] !== '' - ? argv['symbolicate-with-server'] + typeof opts.symbolicateWithServer === 'string' && + opts.symbolicateWithServer !== '' + ? opts.symbolicateWithServer : undefined, - symbolicateWasm, + symbolicateWasm: opts.symbolicateWasm, }; } if (require.main === module) { - const options = makeOptionsFromArgv(process.argv); - run(options).catch((err) => { - console.error(err); + try { + const options = makeOptionsFromArgv(process.argv); + run(options).catch((err) => { + console.error(err); + process.exit(1); + }); + } catch (err) { + if (err instanceof CommanderError) { + // Commander already wrote its own output and chose the + // appropriate exit code. + process.exit(err.exitCode); + } + console.error(err instanceof Error ? err.message : String(err)); process.exit(1); - }); + } } diff --git a/yarn.lock b/yarn.lock index 9aadcec184..d55c597ecf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2485,7 +2485,7 @@ dependencies: "@types/unist" "*" -"@types/minimist@^1.2.0", "@types/minimist@^1.2.2", "@types/minimist@^1.2.5": +"@types/minimist@^1.2.0", "@types/minimist@^1.2.2": version "1.2.5" resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.5.tgz#ec10755e871497bcd83efe927e43ec46e8c0747e" integrity sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag== @@ -8314,7 +8314,7 @@ minimist-options@4.1.0: is-plain-obj "^1.1.0" kind-of "^6.0.3" -minimist@^1.2.0, minimist@^1.2.6, minimist@^1.2.8: +minimist@^1.2.0, minimist@^1.2.6: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== From 52f65d8fce477d6b13ccad94e2a8c1255503f206 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Qu=C3=A8ze?= Date: Fri, 29 May 2026 13:29:18 +0200 Subject: [PATCH 11/88] Don't fail profile processing when a marker's stack field is not a backtrace The marker payload stack key normally holds a captured backtrace (a mini thread with a samples table), which we convert into a cause. But the key isn't reserved: some markers store an unrelated value there, e.g. Log markers emitted by the test harness put a textual JS stack trace string in it. Processing such a marker threw (accessing .samples.data on a string), making the whole profile fail to load. Check that the stack value actually has a samples table before treating it as a backtrace. A non-backtrace stack is now left untouched on the payload, to be displayed (or ignored) by the marker schema like any other field. --- src/profile-logic/process-profile.ts | 19 ++++++++++++-- src/test/unit/marker-data.test.ts | 38 ++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/profile-logic/process-profile.ts b/src/profile-logic/process-profile.ts index 24846ec17b..d83c75a447 100644 --- a/src/profile-logic/process-profile.ts +++ b/src/profile-logic/process-profile.ts @@ -73,6 +73,7 @@ import type { GeckoMetaMarkerSchema, GeckoStaticFieldSchemaData, GeckoMarkers, + GeckoMarkerStack, GeckoMarkerStruct, GeckoMarkerTuple, GeckoFrameStruct, @@ -562,6 +563,20 @@ function _processStackTable( return stackIndexOffset; } +/** + * We expect a captured backtrace here, with a samples table. But the "stack" key + * isn't reserved for that: some markers store an unrelated value (e.g. Log markers + * from the test harness put a textual stack trace string there). Such a value has + * no `samples`, so checking for it both selects real backtraces and keeps one bad + * marker from failing the whole profile. A non-backtrace stack is left in place to + * be handled by the marker schema like any other field. + */ +function _payloadHasStack( + data: MarkerPayload_Gecko +): data is MarkerPayload_Gecko & { stack: GeckoMarkerStack } { + return 'stack' in data && !!data.stack?.samples?.data.length; +} + /** * Convert stack field to cause field for the given payload. A cause field includes * the thread ID (tid), an IndexIntoStackTable, and the time the stack was captured. @@ -573,7 +588,7 @@ function _convertStackToCause( data: MarkerPayload_Gecko, stackIndexOffset: IndexIntoStackTable ) { - if ('stack' in data && data.stack && data.stack.samples.data.length > 0) { + if (_payloadHasStack(data)) { const { stack, ...newData } = data; const stackIndex = stack.samples.data[0][stack.samples.schema.stack]; const time = stack.samples.data[0][stack.samples.schema.time]; @@ -599,7 +614,7 @@ function _convertPayloadStackToIndex( if (!data) { return null; } - if ('stack' in data && data.stack && data.stack.samples.data.length > 0) { + if (_payloadHasStack(data)) { const { samples } = data.stack; const geckoStackIndex = samples.data[0][samples.schema.stack]; if (geckoStackIndex !== null) { diff --git a/src/test/unit/marker-data.test.ts b/src/test/unit/marker-data.test.ts index 0e49044eb5..80c8c41ab0 100644 --- a/src/test/unit/marker-data.test.ts +++ b/src/test/unit/marker-data.test.ts @@ -100,6 +100,44 @@ describe('Derive markers from Gecko phase markers', function () { ]); }); + it('keeps a non-backtrace string stack instead of crashing', function () { + // The Gecko "stack" key isn't reserved for captured backtraces. Log markers + // emitted by the test harness store a textual JS stack trace string there. + // Such a value has no samples table, so it must be left untouched (not turned + // into a cause) rather than crashing the whole profile during processing. + const stackString = + '_abort_failed_test@head.js:938:20\ndo_report_result@head.js:1053:5\n'; + const { markers } = setupWithTestDefinedMarkers([ + { + startTime: 5, + endTime: null, + phase: INSTANT, + data: { + type: 'Log', + level: 1, + message: 'Unexpected exception NS_ERROR_ABORT', + stack: stackString, + } as any, + }, + ]); + + expect(markers).toEqual([ + { + category: 0, + data: { + type: 'Log', + level: 1, + message: 'Unexpected exception NS_ERROR_ABORT', + stack: stackString, + }, + end: null, + name: 'TestDefinedMarker', + start: 5, + threadId: null, + }, + ]); + }); + it('matches an IntervalStart and IntervalEnd marker', function () { const { markers } = setupWithTestDefinedMarkers([ { From de7e360020146f22d30c3b1e4910b50310ebc1a9 Mon Sep 17 00:00:00 2001 From: fatadel Date: Fri, 29 May 2026 16:34:55 +0200 Subject: [PATCH 12/88] Replace the footer-links overlay with a settings menu (#6042) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The footer-links overlay was a small floating box pinned to the bottom-right corner of every page that often obstructed the UI underneath, and the app's settings/links were scattered across the overlay and the top bar. Consolidate the overlay's contents (Legal, Privacy, Cookies, language switcher) together with the Docs link and the theme toggle into a single settings menu opened from a cog icon, so all app-wide controls live in one place. The cog is available on every page — home, compare, uploaded recordings, and while viewing a profile. --- locales/en-US/app.ftl | 20 +- res/img/svg/cog-12.svg | 6 + res/img/svg/cog-light-12.svg | 6 + src/components/app/AppHeader.tsx | 4 +- src/components/app/FooterLinks.css | 67 ----- src/components/app/FooterLinks.tsx | 68 ------ src/components/app/LanguageSwitcher.tsx | 7 +- src/components/app/MenuButtons/index.tsx | 14 +- src/components/app/Root.tsx | 2 - src/components/app/SettingsMenu.css | 132 ++++++++++ src/components/app/SettingsMenu.tsx | 92 +++++++ .../shared/ButtonWithPanel/ArrowPanel.css | 2 +- ...erLinks.test.tsx => SettingsMenu.test.tsx} | 74 +++--- .../__snapshots__/CompareHome.test.tsx.snap | 34 +-- .../__snapshots__/FooterLinks.test.tsx.snap | 149 ----------- .../__snapshots__/Home.test.tsx.snap | 136 ++--------- .../__snapshots__/MenuButtons.test.tsx.snap | 53 ++-- .../__snapshots__/SettingsMenu.test.tsx.snap | 231 ++++++++++++++++++ .../UploadedRecordingsHome.test.tsx.snap | 68 +----- 19 files changed, 592 insertions(+), 573 deletions(-) create mode 100644 res/img/svg/cog-12.svg create mode 100644 res/img/svg/cog-light-12.svg delete mode 100644 src/components/app/FooterLinks.css delete mode 100644 src/components/app/FooterLinks.tsx create mode 100644 src/components/app/SettingsMenu.css create mode 100644 src/components/app/SettingsMenu.tsx rename src/test/components/{FooterLinks.test.tsx => SettingsMenu.test.tsx} (50%) delete mode 100644 src/test/components/__snapshots__/FooterLinks.test.tsx.snap create mode 100644 src/test/components/__snapshots__/SettingsMenu.test.tsx.snap diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl index be577ee3a9..0e45985893 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -315,16 +315,17 @@ ErrorBoundary--report-error-to-developers-description = # This is used in a call to action button, displayed inside the error box. ErrorBoundary--report-error-on-github = Report the error on GitHub -## Footer Links - -FooterLinks--legal = Legal -FooterLinks--Privacy = Privacy -FooterLinks--Cookies = Cookies -FooterLinks--languageSwitcher--select = +## Settings Menu +## The settings popup opened from the cog icon in the top bar. + +SettingsMenu--button = + .title = Settings +SettingsMenu--docs = Documentation +SettingsMenu--legal = Legal +SettingsMenu--privacy = Privacy +SettingsMenu--cookies = Cookies +SettingsMenu--language-switcher = .title = Change language -FooterLinks--hide-button = - .title = Hide footer links - .aria-label = Hide footer links ## FullTimeline ## The timeline component of the full view in the analysis UI at the top of the @@ -576,7 +577,6 @@ MenuButtons--index--share-error-uploading = .label = Error uploading MenuButtons--index--revert = Revert to Original Profile -MenuButtons--index--docs = Docs MenuButtons--permalink--button = .label = Permalink diff --git a/res/img/svg/cog-12.svg b/res/img/svg/cog-12.svg new file mode 100644 index 0000000000..e0cb45dfe1 --- /dev/null +++ b/res/img/svg/cog-12.svg @@ -0,0 +1,6 @@ + + + + diff --git a/res/img/svg/cog-light-12.svg b/res/img/svg/cog-light-12.svg new file mode 100644 index 0000000000..8f02e82250 --- /dev/null +++ b/res/img/svg/cog-light-12.svg @@ -0,0 +1,6 @@ + + + + diff --git a/src/components/app/AppHeader.tsx b/src/components/app/AppHeader.tsx index 6dc0eb9649..e4d9a4f37a 100644 --- a/src/components/app/AppHeader.tsx +++ b/src/components/app/AppHeader.tsx @@ -9,7 +9,7 @@ import * as React from 'react'; import { InnerNavigationLink } from 'firefox-profiler/components/shared/InnerNavigationLink'; -import { ThemeToggle } from './ThemeToggle'; +import { SettingsMenu } from './SettingsMenu'; import './AppHeader.css'; import { Localized } from '@fluent/react'; @@ -44,7 +44,7 @@ export class AppHeader extends React.PureComponent<{}> {
- + { - _onClick = () => { - this.setState({ hide: true }); - }; - - override state = { - hide: false, - }; - - override render() { - if (this.state.hide) { - return null; - } - return ( - - ); - } -} diff --git a/src/components/app/LanguageSwitcher.tsx b/src/components/app/LanguageSwitcher.tsx index b2d0250121..913df025fe 100644 --- a/src/components/app/LanguageSwitcher.tsx +++ b/src/components/app/LanguageSwitcher.tsx @@ -30,12 +30,9 @@ export function LanguageSwitcher(): React.ReactNode { } return ( - + - - - - - - - - - - - - - - - - - - - - - -
- - -`; diff --git a/src/test/components/__snapshots__/Home.test.tsx.snap b/src/test/components/__snapshots__/Home.test.tsx.snap index 11f8ccb4c8..ab7c516333 100644 --- a/src/test/components/__snapshots__/Home.test.tsx.snap +++ b/src/test/components/__snapshots__/Home.test.tsx.snap @@ -31,38 +31,14 @@ exports[`app/Home renders a button to enable the popup in Firefox 1`] = ` class="appHeaderRightControls" >
- - + />
- - + />
- - + />
- - + />
can publish and revert 1`] = ` - - Docs - + - - Docs -