tor-browser

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

commit 723ed202c026b4ca1a0ed1dce06f0a81180198f7
parent b0969e03b9153b2fa862e0c20ad1557d1b576cb9
Author: Florian Quèze <florian@queze.net>
Date:   Tue,  4 Nov 2025 14:44:58 +0000

Bug 1901215 - bucket long arrays in the JSON viewer, r=devtools-reviewers,ochameau.

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

Diffstat:
Mdevtools/client/jsonview/components/JsonPanel.mjs | 23++++++++++++++++++++++-
Mdevtools/client/jsonview/json-viewer.mjs | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mdevtools/client/jsonview/test/browser.toml | 2++
Adevtools/client/jsonview/test/browser_jsonview_array_buckets.js | 196+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdevtools/client/jsonview/test/browser_jsonview_row_selection.js | 8++++++--
Mdevtools/client/shared/components/tree/ObjectProvider.mjs | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mdevtools/client/shared/components/tree/TreeView.mjs | 8++++++--
7 files changed, 415 insertions(+), 11 deletions(-)

diff --git a/devtools/client/jsonview/components/JsonPanel.mjs b/devtools/client/jsonview/components/JsonPanel.mjs @@ -11,6 +11,7 @@ import PropTypes from "resource://devtools/client/shared/vendor/react-prop-types import { createFactories } from "resource://devtools/client/shared/react-utils.mjs"; import TreeViewClass from "resource://devtools/client/shared/components/tree/TreeView.mjs"; +import { BucketProperty } from "resource://devtools/client/shared/components/tree/ObjectProvider.mjs"; import JsonToolbarClass from "resource://devtools/client/jsonview/components/JsonToolbar.mjs"; import { @@ -78,13 +79,32 @@ class JsonPanel extends Component { return true; } + const searchFilter = this.props.searchFilter.toLowerCase(); + + // For bucket nodes, check if any of their children match + if (object instanceof BucketProperty) { + const { object: array, startIndex, endIndex } = object; + for (let i = startIndex; i <= endIndex; i++) { + const childJson = JSON.stringify(array[i]); + if (childJson.toLowerCase().includes(searchFilter)) { + return true; + } + } + return false; + } + const json = object.name + JSON.stringify(object.value); - return json.toLowerCase().includes(this.props.searchFilter.toLowerCase()); + return json.toLowerCase().includes(searchFilter); } renderValue(props) { const member = props.member; + // Hide value for bucket nodes (they show ranges like [0…99]) + if (member.type === "bucket") { + return null; + } + // Hide object summary when non-empty object is expanded (bug 1244912). if (isObject(member.value) && member.hasChildren && member.open) { return null; @@ -114,6 +134,7 @@ class JsonPanel extends Component { return TreeView({ object: this.props.data, mode: MODE.LONG, + bucketLargeArrays: true, onFilter: this.onFilter, columns, renderValue: this.renderValue, diff --git a/devtools/client/jsonview/json-viewer.mjs b/devtools/client/jsonview/json-viewer.mjs @@ -9,6 +9,7 @@ import { createFactories } from "resource://devtools/client/shared/react-utils.m import MainTabbedAreaClass from "resource://devtools/client/jsonview/components/MainTabbedArea.mjs"; import TreeViewClass from "resource://devtools/client/shared/components/tree/TreeView.mjs"; +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"; @@ -20,6 +21,7 @@ window.dispatchEvent(new CustomEvent("AppReadyStateChange")); const AUTO_EXPAND_MAX_SIZE = 100 * 1024; const AUTO_EXPAND_MAX_LEVEL = 7; +const EXPAND_ALL_MAX_NODES = 100000; const TABS = { JSON: 0, RAW_DATA: 1, @@ -40,6 +42,103 @@ const input = { }; /** + * Recursively walk the tree and expand all nodes including buckets. + * Similar to TreeViewClass.getExpandedNodes but includes buckets. + */ +function expandAllNodes(data, { maxNodes = Infinity } = {}) { + const expandedNodes = new Set(); + + function walkTree(object, path = "") { + const children = ObjectProvider.getChildren(object, { + bucketLargeArrays: true, + }); + + // Check if adding these children would exceed the limit + if (expandedNodes.size + children.length > maxNodes) { + // Avoid having children half expanded + return; + } + + for (const child of children) { + const key = ObjectProvider.getKey(child); + const childPath = TreeViewClass.subPath(path, key); + + // Expand this node + expandedNodes.add(childPath); + + // Recursively walk children + if (ObjectProvider.hasChildren(child)) { + walkTree(child, childPath); + } + } + } + + // Start walking from the root if it's not a primitive + if ( + data && + typeof data === "object" && + !(data instanceof Error) && + data.type !== JSON_NUMBER + ) { + walkTree(data); + } + + return expandedNodes; +} + +/** + * Recursively walk the tree and expand buckets that contain matches. + */ +function expandBucketsWithMatches(data, searchFilter) { + const expandedNodes = new Set(input.expandedNodes); + + function walkTree(object, path = "") { + const children = ObjectProvider.getChildren(object, { + bucketLargeArrays: true, + }); + + for (const child of children) { + const key = ObjectProvider.getKey(child); + const childPath = TreeViewClass.subPath(path, key); + + // Check if this is a bucket + if (ObjectProvider.getType(child) === "bucket") { + // Check if any children in the bucket match the filter + const { object: array, startIndex, endIndex } = child; + let hasMatch = false; + + for (let i = startIndex; i <= endIndex; i++) { + const childJson = JSON.stringify(array[i]); + if (childJson.toLowerCase().includes(searchFilter)) { + hasMatch = true; + break; + } + } + + if (hasMatch) { + expandedNodes.add(childPath); + } + } else if (ObjectProvider.hasChildren(child)) { + // Recursively walk non-bucket nodes + walkTree(child, childPath); + } + } + } + + // Start walking from the root if it's not a primitive + if ( + data && + typeof data === "object" && + !(data instanceof Error) && + data.type !== JSON_NUMBER + ) { + walkTree(data); + } + + return expandedNodes; +} + +/** * Application actions/commands. This list implements all commands * available for the JSON viewer. */ @@ -81,7 +180,10 @@ input.actions = { }, onSearch(value) { - theApp.setState({ searchFilter: value }); + const expandedNodes = value + ? expandBucketsWithMatches(input.json, value.toLowerCase()) + : input.expandedNodes; + theApp.setState({ searchFilter: value, expandedNodes }); }, onPrettify() { @@ -124,7 +226,9 @@ input.actions = { }, onExpand() { - input.expandedNodes = TreeViewClass.getExpandedNodes(input.json); + input.expandedNodes = expandAllNodes(input.json, { + maxNodes: EXPAND_ALL_MAX_NODES, + }); theApp.setState({ expandedNodes: input.expandedNodes }); }, }; diff --git a/devtools/client/jsonview/test/browser.toml b/devtools/client/jsonview/test/browser.toml @@ -27,6 +27,8 @@ support-files = [ ["browser_jsonview_access_data.js"] +["browser_jsonview_array_buckets.js"] + ["browser_jsonview_bug_1380828.js"] ["browser_jsonview_chunked_json.js"] diff --git a/devtools/client/jsonview/test/browser_jsonview_array_buckets.js b/devtools/client/jsonview/test/browser_jsonview_array_buckets.js @@ -0,0 +1,196 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that large arrays in JSON view are bucketed into ranges + * to prevent browser freezing when expanding them. + */ + +/** + * Load JSON data in the JSON viewer + */ +async function loadJsonData(data) { + const json = JSON.stringify(data); + return addJsonViewTab("data:application/json," + json); +} + +/** + * Get all bucket labels matching the pattern [n…m] + */ +async function getBucketLabels() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const labels = Array.from( + content.document.querySelectorAll(".treeLabelCell") + ); + return labels + .filter(label => /\[\d+…\d+\]/.test(label.textContent)) + .map(label => label.textContent.trim()); + }); +} + +/** + * Check if any bucket labels exist + */ +async function hasBuckets() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const labels = Array.from( + content.document.querySelectorAll(".treeLabelCell") + ); + return labels.some(label => /\[\d+…\d+\]/.test(label.textContent)); + }); +} + +add_task(async function test_small_array_no_buckets() { + const smallArray = Array(100).fill("item"); + await loadJsonData({ data: smallArray }); + + // Small JSON auto-expands, so check the already-expanded state + // Count the rows - should have root, "data" label, and 100 array elements + const rowCount = await getElementCount(".treeRow"); + is(rowCount, 101, "Small array shows all 100 elements without bucketing"); + + // Verify no bucket nodes (no labels like "[0…99]") + is(await hasBuckets(), false, "No bucket nodes in small array"); +}); + +add_task(async function test_medium_array_has_buckets() { + const mediumArray = Array(250) + .fill(null) + .map((_, i) => `item${i}`); + await loadJsonData({ data: mediumArray }); + + // Array auto-expands, showing buckets instead of individual elements + // For 250 elements, bucket size = 100, so we expect 3 buckets: + // [0…99], [100…199], [200…249] + const bucketLabels = await getBucketLabels(); + is(bucketLabels.length, 3, "Medium array (250 elements) creates 3 buckets"); + + // Verify bucket names + Assert.deepEqual( + bucketLabels, + ["[0…99]", "[100…199]", "[200…249]"], + "Bucket names are correct" + ); +}); + +add_task(async function test_expand_bucket() { + const array = Array(150) + .fill(null) + .map((_, i) => `value${i}`); + await loadJsonData(array); + + // Root array auto-expands and shows 2 buckets: [0…99] and [100…149] + const bucketLabels = await getBucketLabels(); + is(bucketLabels.length, 2, "Root array shows 2 buckets"); + + // Find and expand the first bucket [0…99] + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const labels = Array.from( + content.document.querySelectorAll(".treeLabelCell") + ); + const firstBucket = labels.find(label => + label.textContent.includes("[0…99]") + ); + if (firstBucket) { + firstBucket.click(); + } + }); + + // Verify that elements 0-99 are now visible + const hasElements = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => { + const labels = Array.from( + content.document.querySelectorAll(".treeLabelCell") + ); + const hasZero = labels.some(label => label.textContent.trim() === "0"); + const has99 = labels.some(label => label.textContent.trim() === "99"); + return hasZero && has99; + } + ); + is(hasElements, true, "Expanding bucket shows individual elements"); +}); + +add_task(async function test_large_array_bucket_size() { + // For 10,000 elements, bucket size should be 100 (10^2) + // This creates 100 buckets + const largeArray = Array(10000).fill("x"); + await loadJsonData(largeArray); + + // Root array auto-expands showing buckets + const bucketLabels = await getBucketLabels(); + + is(bucketLabels.length, 100, "10,000 elements create 100 buckets"); + is(bucketLabels[0], "[0…99]", "First bucket starts at 0"); + is(bucketLabels[99], "[9900…9999]", "Last bucket ends at 9999"); +}); + +add_task(async function test_very_large_array_bucket_size() { + // For 100,000 elements, bucket size should be 1000 (10^3) + // This creates 100 buckets + const veryLargeArray = Array(100000).fill(1); + await loadJsonData(veryLargeArray); + + // Root array auto-expands showing buckets + const bucketLabels = await getBucketLabels(); + + is(bucketLabels.length, 100, "100,000 elements create 100 buckets"); + is(bucketLabels[0], "[0…999]", "First bucket is [0…999]"); + is(bucketLabels[1], "[1000…1999]", "Second bucket is [1000…1999]"); +}); + +add_task(async function test_nested_buckets() { + // Create an array with 100,000 elements + // Initial bucket size will be 1000, so first bucket is [0…999] + // That bucket has 1000 elements, which needs 10 sub-buckets of 100 each + const veryLargeArray = Array(100000) + .fill(null) + .map((_, i) => i); + await loadJsonData(veryLargeArray); + + // Root array auto-expands showing top-level buckets + // Find and expand the first bucket [0…999] + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const labels = Array.from( + content.document.querySelectorAll(".treeLabelCell") + ); + const firstBucket = labels.find(label => + label.textContent.includes("[0…999]") + ); + if (firstBucket) { + firstBucket.click(); + } + }); + + // The [0…999] bucket should now show nested buckets, not individual elements + // The 1000 elements need 10 sub-buckets of 100 each: [0…99], [100…199], ..., [900…999] + const nestedBuckets = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => { + const labels = Array.from( + content.document.querySelectorAll(".treeLabelCell") + ); + return labels + .map(label => label.textContent) + .filter(text => { + // Look for nested buckets (exclude the parent [0…999] bucket) + const match = text.match(/\[(\d+)…(\d+)\]/); + if (!match) { + return false; + } + const start = parseInt(match[1], 10); + const end = parseInt(match[2], 10); + // Nested buckets have size 100, parent has size 1000 + return end - start === 99; + }); + } + ); + + is(nestedBuckets.length, 10, "1000-element bucket creates 10 nested buckets"); + is(nestedBuckets[0], "[0…99]", "First nested bucket is [0…99]"); + is(nestedBuckets[9], "[900…999]", "Last nested bucket is [900…999]"); +}); diff --git a/devtools/client/jsonview/test/browser_jsonview_row_selection.js b/devtools/client/jsonview/test/browser_jsonview_row_selection.js @@ -7,14 +7,18 @@ add_task(async function () { info("Test 1 JSON row selection started"); // Create a tall JSON so that there is a scrollbar. - const numRows = 1e3; + // Use 10,000 elements which creates 100 buckets (bucket size = 100) + const numElements = 1e4; const json = JSON.stringify( - Array(numRows) + Array(numElements) .fill() .map((_, i) => i) ); const tab = await addJsonViewTab("data:application/json," + json); + // Array with 10,000 elements creates buckets (100 buckets of 100 elements each) + // The root array expands to show 100 bucket rows + const numRows = 100; is( await getElementCount(".treeRow"), numRows, diff --git a/devtools/client/shared/components/tree/ObjectProvider.mjs b/devtools/client/shared/components/tree/ObjectProvider.mjs @@ -6,15 +6,36 @@ import { JSON_NUMBER } from "resource://devtools/client/shared/components/reps/reps/constants.mjs"; +const MAX_NUMERICAL_PROPERTIES = 100; + /** * Implementation of the default data provider. A provider is state less * object responsible for transformation data (usually a state) to * a structure that can be directly consumed by the tree-view component. */ const ObjectProvider = { - getChildren(object) { + getChildren(object, options = {}) { + const { bucketLargeArrays = false } = options; const children = []; + if (bucketLargeArrays && object instanceof BucketProperty) { + // Expand a bucket by returning its range of properties + const actualObject = object.object; + const { startIndex, endIndex } = object; + const bucketSize = endIndex - startIndex + 1; + + // If this bucket is still too large (>100 elements), create nested buckets + if (bucketSize > MAX_NUMERICAL_PROPERTIES) { + return this.makeBuckets(actualObject, startIndex, endIndex); + } + + // Otherwise, return the actual array elements + for (let i = startIndex; i <= endIndex; i++) { + children.push(new ObjectProperty(String(i), actualObject[i])); + } + return children; + } + if (object instanceof ObjectProperty) { object = object.value; } @@ -31,6 +52,15 @@ const ObjectProvider = { return []; } + // Check if bucketing is enabled and this is an array with many elements + if ( + bucketLargeArrays && + Array.isArray(object) && + object.length > MAX_NUMERICAL_PROPERTIES + ) { + return this.makeBuckets(object); + } + for (const prop in object) { try { children.push(new ObjectProperty(prop, object[prop])); @@ -41,7 +71,34 @@ const ObjectProvider = { return children; }, + makeBuckets(array, startIndex = 0, endIndex = array.length - 1) { + const numProperties = endIndex - startIndex + 1; + // We want to have at most a hundred slices. + // This matches the bucketing algorithm in + // devtools/client/shared/components/object-inspector/utils/node.js + const bucketSize = + 10 ** Math.max(2, Math.ceil(Math.log10(numProperties)) - 2); + const numBuckets = Math.ceil(numProperties / bucketSize); + + const buckets = []; + for (let i = 1; i <= numBuckets; i++) { + const minKey = (i - 1) * bucketSize; + const maxKey = Math.min(i * bucketSize - 1, numProperties - 1); + const minIndex = startIndex + minKey; + const maxIndex = startIndex + maxKey; + const bucketName = `[${minIndex}…${maxIndex}]`; + + buckets.push(new BucketProperty(bucketName, array, minIndex, maxIndex)); + } + return buckets; + }, + hasChildren(object) { + if (object instanceof BucketProperty) { + // Buckets always have children (the range of properties they represent) + return true; + } + if (object instanceof ObjectProperty) { object = object.value; } @@ -66,7 +123,10 @@ const ObjectProvider = { }, getLabel(object) { - return object instanceof ObjectProperty ? object.name : null; + // Both BucketProperty and ObjectProperty have a .name property + return object instanceof BucketProperty || object instanceof ObjectProperty + ? object.name + : null; }, getValue(object) { @@ -74,10 +134,16 @@ const ObjectProvider = { }, getKey(object) { - return object instanceof ObjectProperty ? object.name : null; + // Both BucketProperty and ObjectProperty use .name as their key + return object instanceof BucketProperty || object instanceof ObjectProperty + ? object.name + : null; }, getType(object) { + if (object instanceof BucketProperty) { + return "bucket"; + } return object instanceof ObjectProperty ? typeof object.value : typeof object; @@ -89,5 +155,12 @@ function ObjectProperty(name, value) { this.value = value; } +function BucketProperty(name, object, startIndex, endIndex) { + this.name = name; + this.object = object; + this.startIndex = startIndex; + this.endIndex = endIndex; +} + // Exports from this module -export { ObjectProperty, ObjectProvider }; +export { BucketProperty, ObjectProperty, ObjectProvider }; diff --git a/devtools/client/shared/components/tree/TreeView.mjs b/devtools/client/shared/components/tree/TreeView.mjs @@ -44,6 +44,7 @@ function getDefaultProps() { defaultSelectFirstNode: true, active: null, expandableStrings: true, + bucketLargeArrays: false, maxStringLength: 50, columns: [], }; @@ -133,6 +134,8 @@ class TreeView extends Component { onFilter: PropTypes.func, // Custom sorting callback onSort: PropTypes.func, + // Enable bucketing for large arrays + bucketLargeArrays: PropTypes.bool, // Custom row click callback onClickRow: PropTypes.func, // Row context menu event handler @@ -592,8 +595,9 @@ class TreeView extends Component { return []; } - const { expandableStrings, provider, maxStringLength } = this.props; - let children = provider.getChildren(parent) || []; + const { expandableStrings, provider, bucketLargeArrays, maxStringLength } = + this.props; + let children = provider.getChildren(parent, { bucketLargeArrays }) || []; // If the return value is non-array, the children // are being loaded asynchronously.