tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit faf9462bdd92c1c3d71c6ebf199b4cb2c0ba15f2
parent 9656d05168ba941acd89190de618d9e6af86e5c2
Author: Florian Quèze <florian@queze.net>
Date:   Thu, 13 Nov 2025 17:10:23 +0000

Bug 1997209 - Add a toolbar button in the JSON Viewer to open a size profile of the JSON file in the Firefox Profiler, r=devtools-reviewers,nchevobbe,mstange.

Differential Revision: https://phabricator.services.mozilla.com/D270623

Diffstat:
Mdevtools/client/jsonview/components/MainTabbedArea.mjs | 22++++++++++++++++++++++
Mdevtools/client/jsonview/converter-child.js | 12++++++++++++
Mdevtools/client/jsonview/css/general.css | 35+++++++++++++++++++++++++++++++++++
Adevtools/client/jsonview/json-size-profiler.mjs | 788+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdevtools/client/jsonview/json-viewer.mjs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdevtools/client/jsonview/moz.build | 1+
Mdevtools/client/jsonview/test/browser.toml | 2++
Adevtools/client/jsonview/test/browser_jsonview_profile_size.js | 203+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdevtools/client/locales/en-US/jsonview.properties | 9+++++++++
Mdevtools/client/shared/components/tabs/Tabs.mjs | 11++++++++++-
Mmodules/libpref/init/all.js | 8++++++++
11 files changed, 1150 insertions(+), 1 deletion(-)

diff --git a/devtools/client/jsonview/components/MainTabbedArea.mjs b/devtools/client/jsonview/components/MainTabbedArea.mjs @@ -5,6 +5,7 @@ import { Component } from "resource://devtools/client/shared/vendor/react.mjs"; import PropTypes from "resource://devtools/client/shared/vendor/react-prop-types.mjs"; import { createFactories } from "resource://devtools/client/shared/react-utils.mjs"; +import { button } from "resource://devtools/client/shared/vendor/react-dom-factories.mjs"; import JsonPanelClass from "resource://devtools/client/jsonview/components/JsonPanel.mjs"; @@ -53,6 +54,7 @@ class MainTabbedArea extends Component { }; this.onTabChanged = this.onTabChanged.bind(this); + this.onProfileSize = this.onProfileSize.bind(this); } onTabChanged(index) { @@ -62,12 +64,32 @@ class MainTabbedArea extends Component { window.dispatchEvent(new CustomEvent("TabChanged", { detail: { index } })); } + onProfileSize() { + this.props.actions.onProfileSize(); + } + + renderToolbarButton() { + if (!JSONView.sizeProfilerEnabled) { + return null; + } + const isValidJson = !(this.state.json instanceof Error); + return button({ + className: "profiler-icon-button", + title: isValidJson + ? JSONView.Locale["jsonViewer.sizeProfilerButton"] + : JSONView.Locale["jsonViewer.sizeProfilerButtonDisabled"], + onClick: this.onProfileSize, + disabled: !isValidJson, + }); + } + render() { return Tabs( { activeTab: this.state.activeTab, onAfterChange: this.onTabChanged, tall: true, + renderToolbarButton: () => this.renderToolbarButton(), }, TabPanel( { diff --git a/devtools/client/jsonview/converter-child.js b/devtools/client/jsonview/converter-child.js @@ -289,12 +289,24 @@ function getRequestLoadContext(request) { // Exports variables that will be accessed by the non-privileged scripts. function exportData(win, headers) { const json = new win.Text(); + // This pref allows using a deploy preview or local development version of + // the profiler, and also allows tests to avoid hitting the network. + const profilerUrl = Services.prefs.getStringPref( + "devtools.performance.recording.ui-base-url", + "https://profiler.firefox.com" + ); + const sizeProfilerEnabled = Services.prefs.getBoolPref( + "devtools.jsonview.size-profiler.enabled", + false + ); const JSONView = Cu.cloneInto( { headers, json, readyState: "uninitialized", Locale: getAllStrings(), + profilerUrl, + sizeProfilerEnabled, }, win, { diff --git a/devtools/client/jsonview/css/general.css b/devtools/client/jsonview/css/general.css @@ -44,3 +44,38 @@ body.theme-dark { .theme-dark pre { background-color: var(--theme-body-background); } + +/******************************************************************************/ +/* Profiler Icon Button */ + +.profiler-icon-button { + appearance: none; + border: none; + border-inline-start: 1px solid var(--theme-splitter-color); + padding: 0; + margin: 0; + margin-inline-start: auto; + width: 32px; + height: 100%; + background-color: var(--theme-tab-toolbar-background); + background-image: url("chrome://devtools/skin/images/tool-profiler.svg"); + background-repeat: no-repeat; + background-position: center; + background-size: 16px 16px; + -moz-context-properties: fill; + fill: var(--theme-icon-color); + outline-offset: -2px; +} + +.profiler-icon-button:not(:disabled):hover { + background-color: var(--theme-toolbar-hover); + fill: var(--theme-icon-hover-color); +} + +.profiler-icon-button:not(:disabled):hover:active { + background-color: var(--theme-toolbar-hover-active); +} + +.profiler-icon-button:disabled { + fill: var(--theme-icon-disabled-color); +} diff --git a/devtools/client/jsonview/json-size-profiler.mjs b/devtools/client/jsonview/json-size-profiler.mjs @@ -0,0 +1,788 @@ +/* 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/. */ + +/** + * Parses a JSON string and creates a Firefox profiler profile describing + * which parts of the JSON use up how many bytes. + */ + +// Categories for different JSON types +const JSON_CATEGORIES = { + OBJECT: { name: "Object", color: "grey" }, + ARRAY: { name: "Array", color: "grey" }, + NULL: { name: "Null", color: "yellow" }, + BOOL: { name: "Bool", color: "brown" }, + NUMBER: { name: "Number", color: "green" }, + STRING: { name: "String", color: "blue" }, + PROPERTY_KEY: { name: "Property Key", color: "lightblue" }, +}; + +const MAX_SAMPLE_COUNT = 100000; + +class JsonSizeProfiler { + /** + * @param {string} jsonString - The JSON string to profile. + * @param {string} [filename] - Optional filename for the profile metadata. + */ + constructor(jsonString, filename) { + this.jsonString = jsonString; + this.filename = filename; + this.pos = 0; + this.bytePos = 0; + this.lastAdvancedBytePos = 0; + this.scopeStack = []; + + // Caching structures + this.stringTable = new Map(); + this.stringTableArray = []; + this.stackCache = new Map(); + this.frameCache = new Map(); + this.nodeCache = new Map(); + + // Profile tables - stored in final array format + this.frameTable = { + func: [], + category: [], + }; + this.stackTable = { + frame: [], + prefix: [], + }; + + // Aggregation state + this.topStackHandle = null; + const totalBytes = new TextEncoder().encode(jsonString).length; + this.bytesPerSample = Math.max( + 1, + Math.min(1000000, Math.floor(totalBytes / MAX_SAMPLE_COUNT)) + ); + this.sampleCount = 0; + this.aggregationMap = new Map(); + this.aggregationStartPos = 0; + this.samples = { + stack: [], + time: [], + cpuDelta: [], + weight: [], + }; + + // Categories initialization + this.categories = []; + this.categoryMap = new Map(); + for (const [key, value] of Object.entries(JSON_CATEGORIES)) { + const catIndex = this.categories.length; + this.categories.push(value); + this.categoryMap.set(key, catIndex); + } + } + + /** + * Interns a string into the string table, returning its index. + * + * @param {string} str - The string to intern. + * @returns {number} The index of the string in the string table. + */ + internString(str) { + if (!this.stringTable.has(str)) { + const index = this.stringTableArray.length; + this.stringTableArray.push(str); + this.stringTable.set(str, index); + } + return this.stringTable.get(str); + } + + /** + * Gets or creates a frame in the frame table. + * + * @param {string} funcName - The function name for the frame. + * @param {number} category - The category index for the frame. + * @returns {number} The frame index. + */ + getOrCreateFrame(funcName, category) { + const funcIndex = this.internString(funcName); + const cacheKey = `${funcIndex}:${category}`; + if (this.frameCache.has(cacheKey)) { + return this.frameCache.get(cacheKey); + } + + const frameIndex = this.frameTable.func.length; + this.frameTable.func.push(funcIndex); + this.frameTable.category.push(category); + this.frameCache.set(cacheKey, frameIndex); + return frameIndex; + } + + /** + * Gets or creates a stack in the stack table. + * + * @param {number} frameIndex - The frame index for this stack entry. + * @param {number|null} prefix - The parent stack index, or null for root. + * @returns {number} The stack index. + */ + getOrCreateStack(frameIndex, prefix) { + const cacheKey = `${frameIndex}:${prefix === null ? "null" : prefix}`; + if (this.stackCache.has(cacheKey)) { + return this.stackCache.get(cacheKey); + } + + const stackIndex = this.stackTable.frame.length; + this.stackTable.frame.push(frameIndex); + this.stackTable.prefix.push(prefix); + this.stackCache.set(cacheKey, stackIndex); + return stackIndex; + } + + /** + * Gets a stack handle for a given path and JSON type. + * + * @param {number|null} parentStackHandle - The parent stack handle. + * @param {string} path - The path in the JSON structure. + * @param {string} jsonType - The JSON type (OBJECT, ARRAY, STRING, etc.). + * @returns {number} The stack handle. + */ + getStack(parentStackHandle, path, jsonType) { + const cacheKey = `${parentStackHandle === null ? "null" : parentStackHandle}:${path}:${jsonType}`; + if (this.nodeCache.has(cacheKey)) { + return this.nodeCache.get(cacheKey); + } + + const category = this.categoryMap.get(jsonType); + const frameIndex = this.getOrCreateFrame(path, category); + const stackHandle = this.getOrCreateStack(frameIndex, parentStackHandle); + this.nodeCache.set(cacheKey, stackHandle); + return stackHandle; + } + + /** + * Moves position forward, updating both character and byte positions. + * + * This function tracks both character positions (in the UTF-16 string) and + * byte positions (in the original UTF-8 file) because: + * 1. The original JSON file contained bytes which formed a UTF-8 string. + * 2. When the JSON viewer loaded the JSON, these bytes were parsed into a + * UTF-16 string (or "potentially ill-formed UTF-16" aka WTF-16), which + * means all characters now take 2 bytes in memory even though most + * originally only took 1 byte in the UTF-8 file. + * 3. this.jsonString.charCodeAt() indexes into the UTF-16 string. + * 4. By examining the UTF-16 code unit value, we "recover" how many bytes + * this character originally occupied in the UTF-8 file. + * + * Note: this.pos will never stop in the middle of a surrogate pair. + * When this function returns, this.pos >= newCharPos. + * + * @param {number} newCharPos - The new character position to move to. + */ + advanceToPos(newCharPos) { + while (this.pos < newCharPos) { + const code = this.jsonString.charCodeAt(this.pos); + if (code >= 0xd800 && code <= 0xdbff) { + // Surrogate pair - always 4 bytes in UTF-8 + this.bytePos += 4; + this.pos += 2; + } else { + // Single UTF-16 code unit - calculate UTF-8 byte length + if (code <= 0x7f) { + this.bytePos += 1; + } else if (code <= 0x7ff) { + this.bytePos += 2; + } else { + // 3-byte UTF-8 characters (U+0800 to U+FFFF) + // Examples: CJK characters like "中", symbols like "€", etc. + this.bytePos += 3; + } + this.pos++; + } + } + } + + /** + * Moves position forward by a number of ASCII characters (1 byte each). + * + * @param {number} count - The number of ASCII characters to advance. + */ + advanceByAsciiChars(count) { + this.pos += count; + this.bytePos += count; + } + + /** + * Parses a primitive value with stack tracking. + * + * @param {string} path - The path to the value in the JSON structure. + * @param {string} typeName - The type name (STRING, NUMBER, BOOL, NULL). + * @param {Function} parseFunc - The function to call to parse the value. + */ + parsePrimitive(path, typeName, parseFunc) { + this.recordBytesConsumed(); + + const scope = this.getCurrentScope(); + const stackHandle = this.getStack( + scope.stackHandle, + `${path} (${typeName.toLowerCase()})`, + typeName + ); + this.topStackHandle = stackHandle; + + parseFunc(); + + this.recordBytesConsumed(); + this.topStackHandle = scope.stackHandle; + } + + /** + * Exits the current scope (object or array). + */ + exitScope() { + this.recordBytesConsumed(); + this.scopeStack.pop(); + const prevScope = this.getCurrentScope(); + this.topStackHandle = prevScope.stackHandle; + } + + /** + * Records bytes consumed since the last call. + * + * This method accumulates byte counts in aggregationMap instead of immediately + * creating profile samples. This aggregation limits the total sample count to + * approximately MAX_SAMPLE_COUNT (100,000), which keeps the Firefox Profiler + * UI responsive even for very large JSON files. + * + * For small files (< 100KB), bytesPerSample = 1, so samples are created + * frequently. For large files, bytesPerSample scales proportionally + * (e.g., 100 for a 10MB file), so samples are batched more aggressively. + * + * Samples are flushed when we have accumulated multiple stacks and have + * consumed enough bytes to justify creating new samples. + */ + recordBytesConsumed() { + if (this.bytePos === 0 && this.lastAdvancedBytePos === 0) { + return; + } + if (this.bytePos < this.lastAdvancedBytePos) { + throw new Error( + `Cannot advance backwards: ${this.lastAdvancedBytePos} -> ${this.bytePos}` + ); + } + if (this.bytePos === this.lastAdvancedBytePos) { + return; + } + + const byteDelta = this.bytePos - this.lastAdvancedBytePos; + const stackHandle = this.topStackHandle; + if (stackHandle !== null) { + const current = this.aggregationMap.get(stackHandle) || 0; + this.aggregationMap.set(stackHandle, current + byteDelta); + } + + this.lastAdvancedBytePos = this.bytePos; + + // Flush accumulated samples when we have multiple stacks and enough bytes + const aggregatedStackCount = this.aggregationMap.size; + if (aggregatedStackCount > 1) { + const sampleCountIfWeFlush = this.sampleCount + aggregatedStackCount; + const allowedSampleCount = Math.floor( + this.lastAdvancedBytePos / this.bytesPerSample + ); + if (sampleCountIfWeFlush <= allowedSampleCount) { + this.recordSamples(); + } + } + } + + /** + * Flushes accumulated byte counts to the samples table. + */ + recordSamples() { + let synthLastPos = this.aggregationStartPos; + + for (const [stackHandle, accDelta] of this.aggregationMap.entries()) { + const synthPos = synthLastPos + accDelta; + + // First sample at start position + this.samples.stack.push(stackHandle); + this.samples.time.push(synthLastPos); + this.samples.cpuDelta.push(0); + this.samples.weight.push(0); + + // Second sample at end position with size + this.samples.stack.push(stackHandle); + this.samples.time.push(synthPos); + this.samples.cpuDelta.push(accDelta * 1000); + this.samples.weight.push(accDelta); + + synthLastPos = synthPos; + this.sampleCount += 1; + } + + this.aggregationStartPos = this.lastAdvancedBytePos; + this.aggregationMap.clear(); + } + + /** + * Gets the current scope from the scope stack. + * + * @returns {object} An object with stackHandle, path, and arrayDepth. + */ + getCurrentScope() { + if (this.scopeStack.length === 0) { + return { + stackHandle: null, + path: "json", + arrayDepth: 0, + }; + } + + const scope = this.scopeStack[this.scopeStack.length - 1]; + return { + stackHandle: scope.stackHandle, + path: scope.pathForValue || scope.pathForElems || scope.path, + arrayDepth: scope.arrayDepth, + }; + } + + /** + * Skips whitespace characters in the JSON string. + */ + skipWhitespace() { + while (this.pos < this.jsonString.length) { + const ch = this.jsonString[this.pos]; + if (ch !== " " && ch !== "\t" && ch !== "\n" && ch !== "\r") { + break; + } + this.advanceByAsciiChars(1); // Whitespace is always ASCII (1 byte each) + } + } + + /** + * Parses a JSON value at the current position. + * + * @param {string} path - The path to this value in the JSON structure. + */ + parseValue(path) { + this.skipWhitespace(); + + if (this.pos >= this.jsonString.length) { + throw new Error("Unexpected end of JSON"); + } + + const ch = this.jsonString[this.pos]; + + if (ch === "{") { + this.parseObject(path); + } else if (ch === "[") { + this.parseArray(path); + } else if (ch === '"') { + this.parseString(path); + } else if (ch === "t" || ch === "f") { + this.parseBool(path); + } else if (ch === "n") { + this.parseNull(path); + } else { + this.parseNumber(path); + } + } + + /** + * Parses a JSON object at the current position. + * + * @param {string} path - The path to this object in the JSON structure. + */ + parseObject(path) { + this.recordBytesConsumed(); + + const parentScope = this.getCurrentScope(); + const stackHandle = this.getStack( + parentScope.stackHandle, + `${path} (object)`, + "OBJECT" + ); + + this.scopeStack.push({ + type: "object", + stackHandle, + path, + pathForValue: null, + arrayDepth: parentScope.arrayDepth, + }); + this.topStackHandle = stackHandle; + + this.advanceByAsciiChars(1); // skip '{' + + let first = true; + while (this.pos < this.jsonString.length) { + this.skipWhitespace(); + + if (this.jsonString[this.pos] === "}") { + this.advanceByAsciiChars(1); // skip '}' + break; + } + + if (!first) { + if (this.jsonString[this.pos] !== ",") { + throw new Error(`Expected ',' at position ${this.pos}`); + } + this.advanceByAsciiChars(1); // skip ',' + this.skipWhitespace(); + } + first = false; + + // Parse property key + if (this.jsonString[this.pos] !== '"') { + throw new Error(`Expected property key at position ${this.pos}`); + } + + this.recordBytesConsumed(); + + const key = this.parseStringValue(); + const propertyPath = `${path}.${key}`; + + const propKeyStack = this.getStack( + stackHandle, + `${propertyPath} (property key)`, + "PROPERTY_KEY" + ); + this.topStackHandle = propKeyStack; + + // Update scope with current property path + this.scopeStack[this.scopeStack.length - 1].pathForValue = propertyPath; + + this.skipWhitespace(); + if (this.jsonString[this.pos] !== ":") { + throw new Error(`Expected ':' at position ${this.pos}`); + } + this.advanceByAsciiChars(1); // skip ':' + + // Parse property value + this.parseValue(propertyPath); + } + + this.exitScope(); + } + + /** + * Parses a JSON array at the current position. + * + * @param {string} path - The path to this array in the JSON structure. + */ + parseArray(path) { + this.recordBytesConsumed(); + + const parentScope = this.getCurrentScope(); + + const INDEXER_CHARS = "ijklmnopqrstuvwxyz"; + const indexer = + INDEXER_CHARS[parentScope.arrayDepth % INDEXER_CHARS.length]; + const pathForElems = `${path}[${indexer}]`; + + const stackHandle = this.getStack( + parentScope.stackHandle, + `${path} (array)`, + "ARRAY" + ); + + this.topStackHandle = stackHandle; + this.scopeStack.push({ + type: "array", + stackHandle, + pathForElems, + arrayDepth: parentScope.arrayDepth + 1, + }); + + this.advanceByAsciiChars(1); // skip '[' + + let first = true; + while (this.pos < this.jsonString.length) { + this.skipWhitespace(); + + if (this.jsonString[this.pos] === "]") { + this.advanceByAsciiChars(1); // skip ']' + break; + } + + if (!first) { + if (this.jsonString[this.pos] !== ",") { + throw new Error(`Expected ',' at position ${this.pos}`); + } + this.advanceByAsciiChars(1); // skip ',' + this.skipWhitespace(); + } + first = false; + + this.parseValue(pathForElems); + } + + this.exitScope(); + } + + /** + * Parses a JSON string at the current position. + * + * @param {string} path - The path to this string in the JSON structure. + */ + parseString(path) { + this.parsePrimitive(path, "STRING", () => this.parseStringValue()); + } + + /** + * Parses a JSON string value and returns it. + * + * @returns {string} The parsed string value. + */ + parseStringValue() { + this.advanceByAsciiChars(1); // skip opening quote (ASCII) + let value = ""; + + while (this.pos < this.jsonString.length) { + const ch = this.jsonString[this.pos]; + + if (ch === '"') { + this.advanceByAsciiChars(1); // closing quote (ASCII) + break; + } else if (ch === "\\") { + this.advanceByAsciiChars(1); // backslash (ASCII) + if (this.pos >= this.jsonString.length) { + throw new Error("Unexpected end of JSON in string"); + } + const escaped = this.jsonString[this.pos]; + if (escaped === '"' || escaped === "\\" || escaped === "/") { + value += escaped; + } else if (escaped === "b") { + value += "\b"; + } else if (escaped === "f") { + value += "\f"; + } else if (escaped === "n") { + value += "\n"; + } else if (escaped === "r") { + value += "\r"; + } else if (escaped === "t") { + value += "\t"; + } else if (escaped === "u") { + // Unicode escape - \uXXXX (all ASCII) + this.advanceByAsciiChars(1); + const hex = this.jsonString.slice(this.pos, this.pos + 4); + value += String.fromCharCode(parseInt(hex, 16)); + this.advanceByAsciiChars(3); // skip the 4 hex digits (already moved 1) + } + this.advanceByAsciiChars(1); // escaped char (ASCII) + } else { + // Regular character - may be multi-byte UTF-8 + value += ch; + this.advanceToPos(this.pos + 1); + } + } + + return value; + } + + /** + * Parses a JSON number at the current position. + * + * @param {string} path - The path to this number in the JSON structure. + */ + parseNumber(path) { + this.parsePrimitive(path, "NUMBER", () => { + // Skip all number characters: digits, decimal point, exponent, signs + while (this.pos < this.jsonString.length) { + const ch = this.jsonString[this.pos]; + if ( + (ch >= "0" && ch <= "9") || + ch === "." || + ch === "e" || + ch === "E" || + ch === "+" || + ch === "-" + ) { + this.advanceByAsciiChars(1); + } else { + break; + } + } + }); + } + + /** + * Parses a JSON boolean at the current position. + * + * @param {string} path - The path to this boolean in the JSON structure. + */ + parseBool(path) { + this.parsePrimitive(path, "BOOL", () => { + if (this.jsonString.slice(this.pos, this.pos + 4) === "true") { + this.advanceByAsciiChars(4); + } else if (this.jsonString.slice(this.pos, this.pos + 5) === "false") { + this.advanceByAsciiChars(5); + } else { + throw new Error(`Expected boolean at position ${this.pos}`); + } + }); + } + + /** + * Parses a JSON null at the current position. + * + * @param {string} path - The path to this null in the JSON structure. + */ + parseNull(path) { + this.parsePrimitive(path, "NULL", () => { + if (this.jsonString.slice(this.pos, this.pos + 4) === "null") { + this.advanceByAsciiChars(4); + } else { + throw new Error(`Expected null at position ${this.pos}`); + } + }); + } + + /** + * Parses the JSON string and generates a Firefox profiler profile. + * + * @returns {object} A Firefox profiler profile object. + */ + parse() { + this.parseValue("json"); + + // Move to end of string to account for any trailing content + const remaining = this.jsonString.length - this.pos; + if (remaining > 0) { + this.advanceByAsciiChars(remaining); + } + + // Advance to final position + if (this.bytePos !== this.lastAdvancedBytePos) { + this.recordBytesConsumed(); + } + + this.recordSamples(); + + const frameCount = this.frameTable.func.length; + const funcCount = this.stringTableArray.length; + const sampleCount = this.samples.stack.length; + // Convert absolute times to deltas in place + for (let i = sampleCount - 1; i > 0; i--) { + this.samples.time[i] = this.samples.time[i] - this.samples.time[i - 1]; + } + // First element stays as-is (it's already a delta from 0) + + const meta = { + version: 56, + preprocessedProfileVersion: 56, + startTime: 0, + fileSize: this.lastAdvancedBytePos, + processType: 0, + product: "JSON Size Profile", + interval: this.bytesPerSample, + markerSchema: [], + symbolicationNotSupported: true, + usesOnlyOneStackType: true, + categories: this.categories.map(cat => ({ + name: cat.name, + color: cat.color, + subcategories: ["Other"], + })), + sampleUnits: { + time: "bytes", + eventDelay: "ms", + threadCPUDelta: "µs", + }, + }; + + if (this.filename) { + meta.fileName = this.filename; + } + + const profile = { + meta, + libs: [], + threads: [ + { + processType: "default", + processStartupTime: 0, + processShutdownTime: null, + registerTime: 0, + unregisterTime: null, + pausedRanges: [], + name: "Bytes", + isMainThread: true, + pid: "0", + tid: "0", + samples: { + length: sampleCount, + stack: this.samples.stack, + timeDeltas: this.samples.time, + weight: this.samples.weight, + weightType: "bytes", + threadCPUDelta: this.samples.cpuDelta, + }, + markers: { + length: 0, + category: [], + data: [], + endTime: [], + name: [], + phase: [], + startTime: [], + }, + stackTable: { + length: this.stackTable.frame.length, + prefix: this.stackTable.prefix, + frame: this.stackTable.frame, + }, + frameTable: { + length: frameCount, + address: new Array(frameCount).fill(-1), + category: this.frameTable.category, + subcategory: new Array(frameCount).fill(0), + func: this.frameTable.func, + nativeSymbol: new Array(frameCount).fill(null), + innerWindowID: new Array(frameCount).fill(0), + line: new Array(frameCount).fill(null), + column: new Array(frameCount).fill(null), + inlineDepth: new Array(frameCount).fill(0), + }, + funcTable: { + length: funcCount, + name: Array.from({ length: funcCount }, (_, i) => i), + isJS: new Array(funcCount).fill(false), + relevantForJS: new Array(funcCount).fill(false), + resource: new Array(funcCount).fill(-1), + fileName: new Array(funcCount).fill(null), + lineNumber: new Array(funcCount).fill(null), + columnNumber: new Array(funcCount).fill(null), + }, + resourceTable: { + length: 0, + lib: [], + name: [], + host: [], + type: [], + }, + nativeSymbols: { + length: 0, + address: [], + functionSize: [], + libIndex: [], + name: [], + }, + }, + ], + profilingLog: [], + shared: { + stringArray: this.stringTableArray, + }, + }; + + return profile; + } +} + +/** + * Creates a Firefox profiler profile from a JSON string. + * + * @param {string} jsonString - The JSON string to profile + * @param {string} filename - Optional filename to include in the profile + * @returns {object} A Firefox profiler profile object + */ +export function createSizeProfile(jsonString, filename) { + const profiler = new JsonSizeProfiler(jsonString, filename); + return profiler.parse(); +} diff --git a/devtools/client/jsonview/json-viewer.mjs b/devtools/client/jsonview/json-viewer.mjs @@ -12,6 +12,7 @@ import TreeViewClass from "resource://devtools/client/shared/components/tree/Tre import { ObjectProvider } from "resource://devtools/client/shared/components/tree/ObjectProvider.mjs"; import { JSON_NUMBER } from "resource://devtools/client/shared/components/reps/reps/constants.mjs"; import { parseJsonLossless } from "resource://devtools/client/shared/components/reps/reps/rep-utils.mjs"; +import { createSizeProfile } from "resource://devtools/client/jsonview/json-size-profiler.mjs"; const { MainTabbedArea } = createFactories(MainTabbedAreaClass); @@ -231,6 +232,65 @@ input.actions = { }); theApp.setState({ expandedNodes: input.expandedNodes }); }, + + async onProfileSize() { + // Get the raw JSON string + const jsonString = input.jsonText.textContent; + + // Get profiler URL from preferences and open window immediately + // to avoid popup blocker (profile creation may take several seconds) + const origin = JSONView.profilerUrl; + const profilerURL = origin + "/from-post-message/"; + const profilerWindow = window.open(profilerURL, "_blank"); + + if (!profilerWindow) { + console.error("Failed to open profiler window"); + return; + } + + // Extract filename from URL + let filename; + try { + const pathname = window.location.pathname; + const lastSlash = pathname.lastIndexOf("/"); + if (lastSlash !== -1 && lastSlash < pathname.length - 1) { + filename = decodeURIComponent(pathname.substring(lastSlash + 1)); + } + } catch (e) { + // Invalid URL encoding, leave filename undefined + } + + const profile = createSizeProfile(jsonString, filename); + + // Wait for profiler to be ready and send the profile + let isReady = false; + const messageHandler = function (event) { + if (event.origin !== origin) { + return; + } + if (event.data && event.data.name === "ready:response") { + window.removeEventListener("message", messageHandler); + isReady = true; + } + }; + window.addEventListener("message", messageHandler); + + // Poll until the profiler window is ready. We need to poll because the + // postMessage will not be received if we send it before the profiler + // tab has finished loading. + while (!isReady) { + await new Promise(resolve => setTimeout(resolve, 100)); + profilerWindow.postMessage({ name: "ready:request" }, origin); + } + + profilerWindow.postMessage( + { + name: "inject-profile", + profile, + }, + origin + ); + }, }; /** diff --git a/devtools/client/jsonview/moz.build b/devtools/client/jsonview/moz.build @@ -9,6 +9,7 @@ DIRS += ["components", "css"] DevToolsModules( "converter-child.js", "Converter.sys.mjs", + "json-size-profiler.mjs", "json-viewer.mjs", "Sniffer.sys.mjs", ) diff --git a/devtools/client/jsonview/test/browser.toml b/devtools/client/jsonview/test/browser.toml @@ -82,6 +82,8 @@ support-files = ["json_multipart.sjs"] ["browser_jsonview_object-type.js"] +["browser_jsonview_profile_size.js"] + ["browser_jsonview_row_selection.js"] ["browser_jsonview_save_json.js"] diff --git a/devtools/client/jsonview/test/browser_jsonview_profile_size.js b/devtools/client/jsonview/test/browser_jsonview_profile_size.js @@ -0,0 +1,203 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_JSON_URL = URL_ROOT + "simple_json.json"; +const PROFILER_URL_PREF = "devtools.performance.recording.ui-base-url"; +const TEST_PROFILER_URL = "http://127.0.0.1:8888"; + +add_setup(async function () { + info("Setting profiler URL to localhost for tests"); + await SpecialPowers.pushPrefEnv({ + set: [ + [PROFILER_URL_PREF, TEST_PROFILER_URL], + ["devtools.jsonview.size-profiler.enabled", true], + ], + }); +}); + +add_task(async function testProfileSizeButtonExists() { + info("Test that the Profile Size button exists in the tab bar"); + + await addJsonViewTab(TEST_JSON_URL); + + const buttonExists = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => { + const button = content.document.querySelector(".profiler-icon-button"); + return !!button; + } + ); + + ok(buttonExists, "Profile Size button should exist in the tab bar"); +}); + +add_task(async function testProfileSizePostMessage() { + info("Test that profile is sent via postMessage with correct handshake"); + + await addJsonViewTab(TEST_JSON_URL); + + const browser = gBrowser.selectedBrowser; + + // Set up the mock for window.open before clicking + await SpecialPowers.spawn(browser, [TEST_PROFILER_URL], expectedUrl => { + const win = Cu.waiveXrays(content); + + // Create test results object + win.testResults = { + windowUrl: null, + profile: null, + receivedReadyRequest: false, + messageOrigin: null, + resolved: false, + }; + + // Mock window.open + win.open = Cu.exportFunction(function (url) { + win.testResults.windowUrl = url; + + // Create mock window object with postMessage + const mockWindow = { + postMessage(message, origin) { + if (message.name === "ready:request") { + win.testResults.receivedReadyRequest = true; + win.testResults.messageOrigin = origin; + // Simulate profiler responding with ready:response + const event = new win.MessageEvent( + "message", + Cu.cloneInto( + { + origin: expectedUrl, + data: { name: "ready:response" }, + }, + win + ) + ); + win.dispatchEvent(event); + } else if (message.name === "inject-profile") { + win.testResults.profile = message.profile; + win.testResults.resolved = true; + } + }, + close() {}, + }; + + return Cu.cloneInto(mockWindow, win, { cloneFunctions: true }); + }, win); + }); + + // Click the button from within the content process + await SpecialPowers.spawn(browser, [], () => { + const button = content.document.querySelector(".profiler-icon-button"); + button.click(); + }); + + // Wait for the test to complete + await TestUtils.waitForCondition( + async () => { + return SpecialPowers.spawn(browser, [], () => { + return Cu.waiveXrays(content).testResults?.resolved; + }); + }, + "Waiting for profile to be sent", + 100, + 100 + ); + + // Get the results + const result = await SpecialPowers.spawn(browser, [], () => { + return Cu.waiveXrays(content).testResults; + }); + + ok(result.windowUrl, "window.open should have been called"); + ok( + result.windowUrl.includes("/from-post-message/"), + `URL should contain /from-post-message/, got: ${result.windowUrl}` + ); + ok( + result.windowUrl.includes(TEST_PROFILER_URL), + `URL should use preference URL ${TEST_PROFILER_URL}, got: ${result.windowUrl}` + ); + ok(result.receivedReadyRequest, "Should send ready:request"); + is( + result.messageOrigin, + TEST_PROFILER_URL, + "postMessage should use correct origin" + ); + ok(result.profile, "Should capture profile"); + ok(result.profile.meta, "Profile should have meta"); + ok(result.profile.threads, "Profile should have threads"); +}); + +add_task(async function testProfileCreation() { + info("Test that a valid profile is created"); + + const { createSizeProfile } = ChromeUtils.importESModule( + "resource://devtools/client/jsonview/json-size-profiler.mjs" + ); + + const testJson = '{"name": "test", "value": 123}'; + const profile = createSizeProfile(testJson); + + ok(profile.meta, "Profile should have meta object"); + ok(Array.isArray(profile.threads), "Profile should have threads array"); + ok( + Array.isArray(profile.meta.markerSchema), + "Profile meta should have markerSchema array" + ); + ok( + Array.isArray(profile.meta.categories), + "Profile meta should have categories array" + ); + Assert.greater( + profile.threads[0].samples.length, + 0, + "Profile should have samples" + ); + + // Validate total size of samples + const samples = profile.threads[0].samples; + const totalSize = samples.weight.reduce((sum, weight) => sum + weight, 0); + is( + totalSize, + testJson.length, + "Total sample size should match JSON string length" + ); +}); + +add_task(async function testProfileCreationWithUtf8() { + info("Test that profile correctly handles UTF-8 multi-byte characters"); + + const { createSizeProfile } = ChromeUtils.importESModule( + "resource://devtools/client/jsonview/json-size-profiler.mjs" + ); + + // Test with various UTF-8 characters + // "café" - é is 2 bytes in UTF-8 + // "中文" - each character is 3 bytes in UTF-8 + // "🔥" - emoji is 4 bytes in UTF-8 + const testJson = '{"name": "café", "lang": "中文", "emoji": "🔥"}'; + const profile = createSizeProfile(testJson); + + // Calculate expected byte length (UTF-8 encoded) + const utf8Encoder = new TextEncoder(); + const expectedByteLength = utf8Encoder.encode(testJson).length; + + const samples = profile.threads[0].samples; + const totalSize = samples.weight.reduce((sum, weight) => sum + weight, 0); + + is( + totalSize, + expectedByteLength, + `Total sample size should match UTF-8 byte length (${expectedByteLength} bytes, not ${testJson.length} characters)` + ); + Assert.greater( + expectedByteLength, + testJson.length, + "UTF-8 byte length should be greater than character count for this test string" + ); + + info(`Sample count: ${samples.length} for ${expectedByteLength} bytes`); +}); diff --git a/devtools/client/locales/en-US/jsonview.properties b/devtools/client/locales/en-US/jsonview.properties @@ -43,3 +43,12 @@ jsonViewer.PrettyPrint=Pretty Print # LOCALIZATION NOTE (jsonViewer.filterJSON): Label used in search box # at the top right cornder of the JSON Viewer. jsonViewer.filterJSON=Filter JSON + +# LOCALIZATION NOTE (jsonViewer.sizeProfilerButton): Tooltip for the toolbar +# button that opens the Firefox Profiler with a profile showing how much space +# each part of the JSON document uses. +jsonViewer.sizeProfilerButton=Investigate JSON size in the Firefox Profiler + +# LOCALIZATION NOTE (jsonViewer.sizeProfilerButtonDisabled): Tooltip for the +# toolbar button when it is disabled because the JSON document is invalid. +jsonViewer.sizeProfilerButtonDisabled=Investigate JSON size in the Firefox Profiler (disabled for invalid JSON) diff --git a/devtools/client/shared/components/tabs/Tabs.mjs b/devtools/client/shared/components/tabs/Tabs.mjs @@ -50,6 +50,9 @@ class Tabs extends Component { // To render a sidebar toggle button before the tab menu provide a function that // returns a React component for the button. renderSidebarToggle: PropTypes.func, + // To render a toolbar button after the tab menu provide a function that + // returns a React component for the button. + renderToolbarButton: PropTypes.func, // Set true will only render selected panel on DOM. It's complete // opposite of the created array, and it's useful if panels content // is unpredictable and update frequently. @@ -368,11 +371,17 @@ class Tabs extends Component { ? this.props.renderSidebarToggle() : null; + // Get the toolbar button if a renderToolbarButton function is provided. + const toolbarButton = this.props.renderToolbarButton + ? this.props.renderToolbarButton() + : null; + return dom.nav( { className: "tabs-navigation" }, sidebarToggle, dom.ul({ className: "tabs-menu", role: "tablist" }, tabs), - allTabsMenu + allTabsMenu, + toolbarButton ); } diff --git a/modules/libpref/init/all.js b/modules/libpref/init/all.js @@ -3944,6 +3944,14 @@ pref("services.common.log.logger.tokenserverclient", "Debug"); // Enable the JSON View tool (an inspector for application/json documents). pref("devtools.jsonview.enabled", true); +// Size profiler button in JSON View. Nightly-only until the profiler +// front-end has been polished for the size profile use case. +#ifdef NIGHTLY_BUILD + pref("devtools.jsonview.size-profiler.enabled", true); +#else + pref("devtools.jsonview.size-profiler.enabled", false); +#endif + // Default theme ("auto", "dark" or "light"). pref("devtools.theme", "auto", sticky);