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:
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.