[Widget Inspector] Fix stack overflow error for Flutter web when requesting a large widget tree (#159454)

Fixes https://github.com/flutter/devtools/issues/8553

Context: 

A Flutter web customer with a large widget tree was getting a stack
overflow error when they toggled on "show implementation widgets" in the
Flutter DevTools Inspector. This is because building the JSON tree
recursively was hitting Chrome's stack limit.

This PR creates the JSON tree **iteratively** if the `getRootWidgetTree`
service extension is called with `fullDetails = false` (which is what
DevTools uses to fetch the widget tree).

For all other instances of creating a widget JSON map (for example, when
fetching widget properties) the recursive implementation is used. This
allows properties provided by subclasses implementing `toJsonMap` to be
included in the response.

Note: Because with this change `toJsonMap` is only called when
`fullDetails = true` and `toJsonMapIterative` is only called when
`fullDetails = false`, this PR partially reverts the changes in
https://github.com/flutter/flutter/pull/157309.
This commit is contained in:
Elliott Brooks 2024-11-26 13:22:23 -08:00 committed by GitHub
parent 4ff6698838
commit 21bea32f66
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 170 additions and 154 deletions

View File

@ -8,6 +8,7 @@
/// @docImport 'package:flutter/widgets.dart';
library;
import 'dart:collection';
import 'dart:math' as math;
import 'dart:ui' show clampDouble;
@ -1421,6 +1422,17 @@ class TextTreeRenderer {
}
}
/// The JSON representation of a [DiagnosticsNode].
typedef _JsonDiagnosticsNode = Map<String, Object?>;
/// Stack containing [DiagnosticNode]s to convert to JSON and the callback to
/// call with the JSON.
///
/// Using a stack is required to process the widget tree iteratively instead of
/// recursively.
typedef _NodesToJsonifyStack
= ListQueue<(DiagnosticsNode, void Function(_JsonDiagnosticsNode))>;
/// Defines diagnostics data for a [value].
///
/// For debug and profile modes, [DiagnosticsNode] provides a high quality
@ -1605,29 +1617,12 @@ abstract class DiagnosticsNode {
/// by this method and interactive tree views in the Flutter IntelliJ
/// plugin.
@mustCallSuper
Map<String, Object?> toJsonMap(
DiagnosticsSerializationDelegate delegate, {
bool fullDetails = true,
}) {
Map<String, Object?> toJsonMap(DiagnosticsSerializationDelegate delegate) {
Map<String, Object?> result = <String, Object?>{};
assert(() {
final bool hasChildren = getChildren().isNotEmpty;
final Map<String, Object?> essentialDetails = <String, Object?>{
result = <String, Object?>{
'description': toDescription(),
'shouldIndent': style != DiagnosticsTreeStyle.flat &&
style != DiagnosticsTreeStyle.error,
...delegate.additionalNodeProperties(this, fullDetails: fullDetails),
if (delegate.subtreeDepth > 0)
'children': toJsonList(
delegate.filterChildren(getChildren(), this),
this,
delegate,
fullDetails: fullDetails,
),
};
result = !fullDetails ? essentialDetails : <String, Object?>{
...essentialDetails,
'type': runtimeType.toString(),
if (name != null)
'name': name,
@ -1651,12 +1646,18 @@ abstract class DiagnosticsNode {
'allowWrap': allowWrap,
if (allowNameWrap)
'allowNameWrap': allowNameWrap,
...delegate.additionalNodeProperties(this),
if (delegate.includeProperties)
'properties': toJsonList(
delegate.filterProperties(getProperties(), this),
this,
delegate,
fullDetails: fullDetails,
),
if (delegate.subtreeDepth > 0)
'children': toJsonList(
delegate.filterChildren(getChildren(), this),
this,
delegate,
),
};
return true;
@ -1664,6 +1665,35 @@ abstract class DiagnosticsNode {
return result;
}
/// Iteratively serialize the node to a JSON map according to the
/// configuration provided in the [DiagnosticsSerializationDelegate].
///
/// This is only used when [WidgetInspectorServiceExtensions.getRootWidgetTree]
/// is called with fullDetails=false. To get the full widget details, including
/// any details provided by subclasses, [toJsonMap] should be used instead.
///
/// See https://github.com/flutter/devtools/issues/8553 for details about this
/// iterative approach.
Map<String, Object?> toJsonMapIterative(
DiagnosticsSerializationDelegate delegate,
) {
final _NodesToJsonifyStack childrenToJsonify =
ListQueue<(DiagnosticsNode, void Function(_JsonDiagnosticsNode))>();
_JsonDiagnosticsNode result = <String, Object?>{};
assert(() {
result = _toJson(
delegate,
childrenToJsonify: childrenToJsonify,
);
_jsonifyNextNodesInStack(
childrenToJsonify,
delegate: delegate,
);
return true;
}());
return result;
}
/// Serializes a [List] of [DiagnosticsNode]s to a JSON list according to
/// the configuration provided by the [DiagnosticsSerializationDelegate].
///
@ -1672,9 +1702,8 @@ abstract class DiagnosticsNode {
static List<Map<String, Object?>> toJsonList(
List<DiagnosticsNode>? nodes,
DiagnosticsNode? parent,
DiagnosticsSerializationDelegate delegate, {
bool fullDetails = true,
}) {
DiagnosticsSerializationDelegate delegate,
) {
bool truncated = false;
if (nodes == null) {
return const <Map<String, Object?>>[];
@ -1685,11 +1714,9 @@ abstract class DiagnosticsNode {
nodes.add(DiagnosticsNode.message('...'));
truncated = true;
}
final List<Map<String, Object?>> json = nodes.map<Map<String, Object?>>((DiagnosticsNode node) {
return node.toJsonMap(
delegate.delegateForNode(node),
fullDetails: fullDetails,
);
final List<_JsonDiagnosticsNode> json =
nodes.map<_JsonDiagnosticsNode>((DiagnosticsNode node) {
return node.toJsonMap(delegate.delegateForNode(node));
}).toList();
if (truncated) {
json.last['truncated'] = true;
@ -1803,6 +1830,73 @@ abstract class DiagnosticsNode {
}());
return result;
}
void _jsonifyNextNodesInStack(
_NodesToJsonifyStack toJsonify, {
required DiagnosticsSerializationDelegate delegate,
}) {
while (toJsonify.isNotEmpty) {
final (
DiagnosticsNode nextNode,
void Function(_JsonDiagnosticsNode) callback
) = toJsonify.removeFirst();
final _JsonDiagnosticsNode nodeAsJson = nextNode._toJson(
delegate,
childrenToJsonify: toJsonify,
);
callback(nodeAsJson);
}
}
Map<String, Object?> _toJson(
DiagnosticsSerializationDelegate delegate, {
required _NodesToJsonifyStack childrenToJsonify,
}) {
final List<_JsonDiagnosticsNode> childrenJsonList =
<_JsonDiagnosticsNode>[];
final bool includeChildren =
getChildren().isNotEmpty && delegate.subtreeDepth > 0;
// Collect the children nodes to convert to JSON later.
bool truncated = false;
if (includeChildren) {
List<DiagnosticsNode> childrenNodes =
delegate.filterChildren(getChildren(), this);
final int originalNodeCount = childrenNodes.length;
childrenNodes = delegate.truncateNodesList(childrenNodes, this);
if (childrenNodes.length != originalNodeCount) {
childrenNodes.add(DiagnosticsNode.message('...'));
truncated = true;
}
for (final DiagnosticsNode child in childrenNodes) {
childrenToJsonify.add((
child,
(_JsonDiagnosticsNode jsonChild) {
childrenJsonList.add(jsonChild);
}
));
}
}
final String description = toDescription();
final String widgetRuntimeType =
description == '[root]' ? 'RootWidget' : description.split('-').first;
final bool shouldIndent = style != DiagnosticsTreeStyle.flat &&
style != DiagnosticsTreeStyle.error;
return <String, Object?>{
'description': description,
'shouldIndent': shouldIndent,
// TODO(elliette): This can be removed to reduce the JSON response even
// further once DevTools computes the widget runtime type from the
// description instead, see:
// https://github.com/flutter/devtools/issues/8556
'widgetRuntimeType': widgetRuntimeType,
'truncated': truncated,
...delegate.additionalNodeProperties(this, fullDetails: false),
if (includeChildren) 'children': childrenJsonList,
};
}
}
/// Debugging message displayed like a property.
@ -1872,17 +1966,8 @@ class StringProperty extends DiagnosticsProperty<String> {
final bool quoted;
@override
Map<String, Object?> toJsonMap(
DiagnosticsSerializationDelegate delegate, {
bool fullDetails = true,
}) {
final Map<String, Object?> json = super.toJsonMap(
delegate,
fullDetails: fullDetails,
);
if (!fullDetails) {
return json;
}
Map<String, Object?> toJsonMap(DiagnosticsSerializationDelegate delegate) {
final Map<String, Object?> json = super.toJsonMap(delegate);
json['quoted'] = quoted;
return json;
}
@ -1937,18 +2022,8 @@ abstract class _NumProperty<T extends num> extends DiagnosticsProperty<T> {
}) : super.lazy();
@override
Map<String, Object?> toJsonMap(
DiagnosticsSerializationDelegate delegate, {
bool fullDetails = true,
}) {
final Map<String, Object?> json = super.toJsonMap(
delegate,
fullDetails: fullDetails,
);
if (!fullDetails) {
return json;
}
Map<String, Object?> toJsonMap(DiagnosticsSerializationDelegate delegate) {
final Map<String, Object?> json = super.toJsonMap(delegate);
if (unit != null) {
json['unit'] = unit;
}
@ -2131,17 +2206,8 @@ class FlagProperty extends DiagnosticsProperty<bool> {
);
@override
Map<String, Object?> toJsonMap(
DiagnosticsSerializationDelegate delegate, {
bool fullDetails = true,
}) {
final Map<String, Object?> json = super.toJsonMap(
delegate,
fullDetails: fullDetails,
);
if (!fullDetails) {
return json;
}
Map<String, Object?> toJsonMap(DiagnosticsSerializationDelegate delegate) {
final Map<String, Object?> json = super.toJsonMap(delegate);
if (ifTrue != null) {
json['ifTrue'] = ifTrue;
}
@ -2262,17 +2328,8 @@ class IterableProperty<T> extends DiagnosticsProperty<Iterable<T>> {
}
@override
Map<String, Object?> toJsonMap(
DiagnosticsSerializationDelegate delegate, {
bool fullDetails = true,
}) {
final Map<String, Object?> json = super.toJsonMap(
delegate,
fullDetails: fullDetails,
);
if (!fullDetails) {
return json;
}
Map<String, Object?> toJsonMap(DiagnosticsSerializationDelegate delegate) {
final Map<String, Object?> json = super.toJsonMap(delegate);
if (value != null) {
json['values'] = value!.map<String>((T value) => value.toString()).toList();
}
@ -2409,17 +2466,8 @@ class ObjectFlagProperty<T> extends DiagnosticsProperty<T> {
}
@override
Map<String, Object?> toJsonMap(
DiagnosticsSerializationDelegate delegate, {
bool fullDetails = true,
}) {
final Map<String, Object?> json = super.toJsonMap(
delegate,
fullDetails: fullDetails,
);
if (!fullDetails) {
return json;
}
Map<String, Object?> toJsonMap(DiagnosticsSerializationDelegate delegate) {
final Map<String, Object?> json = super.toJsonMap(delegate);
if (ifPresent != null) {
json['ifPresent'] = ifPresent;
}
@ -2496,17 +2544,8 @@ class FlagsSummary<T> extends DiagnosticsProperty<Map<String, T?>> {
}
@override
Map<String, Object?> toJsonMap(
DiagnosticsSerializationDelegate delegate, {
bool fullDetails = true,
}) {
final Map<String, Object?> json = super.toJsonMap(
delegate,
fullDetails: fullDetails,
);
if (!fullDetails) {
return json;
}
Map<String, Object?> toJsonMap(DiagnosticsSerializationDelegate delegate) {
final Map<String, Object?> json = super.toJsonMap(delegate);
if (value.isNotEmpty) {
json['values'] = _formattedValues().toList();
}
@ -2625,10 +2664,7 @@ class DiagnosticsProperty<T> extends DiagnosticsNode {
final bool allowNameWrap;
@override
Map<String, Object?> toJsonMap(
DiagnosticsSerializationDelegate delegate, {
bool fullDetails = true,
}) {
Map<String, Object?> toJsonMap(DiagnosticsSerializationDelegate delegate) {
final T? v = value;
List<Map<String, Object?>>? properties;
if (delegate.expandPropertyValues && delegate.includeProperties && v is Diagnosticable && getProperties().isEmpty) {
@ -2638,16 +2674,9 @@ class DiagnosticsProperty<T> extends DiagnosticsNode {
delegate.filterProperties(v.toDiagnosticsNode().getProperties(), this),
this,
delegate,
fullDetails: fullDetails,
);
}
final Map<String, Object?> json = super.toJsonMap(
delegate,
fullDetails: fullDetails,
);
if (!fullDetails) {
return json;
}
final Map<String, Object?> json = super.toJsonMap(delegate);
if (properties != null) {
json['properties'] = properties;
}

View File

@ -495,17 +495,8 @@ class ColorProperty extends DiagnosticsProperty<Color> {
});
@override
Map<String, Object?> toJsonMap(
DiagnosticsSerializationDelegate delegate, {
bool fullDetails = true,
}) {
final Map<String, Object?> json = super.toJsonMap(
delegate,
fullDetails: fullDetails,
);
if (!fullDetails) {
return json;
}
Map<String, Object?> toJsonMap(DiagnosticsSerializationDelegate delegate) {
final Map<String, Object?> json = super.toJsonMap(delegate);
if (value != null) {
json['valueProperties'] = <String, Object>{
'red': value!.red,

View File

@ -5382,18 +5382,13 @@ class _ElementDiagnosticableTreeNode extends DiagnosticableTreeNode {
final bool stateful;
@override
Map<String, Object?> toJsonMap(
DiagnosticsSerializationDelegate delegate, {
bool fullDetails = true,
}) {
final Map<String, Object?> json = super.toJsonMap(delegate, fullDetails: fullDetails,);
Map<String, Object?> toJsonMap(DiagnosticsSerializationDelegate delegate) {
final Map<String, Object?> json = super.toJsonMap(delegate);
final Element element = value as Element;
if (!element.debugIsDefunct) {
json['widgetRuntimeType'] = element.widget.runtimeType.toString();
}
if (fullDetails) {
json['stateful'] = stateful;
}
return json;
}
}

View File

@ -121,17 +121,8 @@ class IconDataProperty extends DiagnosticsProperty<IconData> {
});
@override
Map<String, Object?> toJsonMap(
DiagnosticsSerializationDelegate delegate, {
bool fullDetails = true,
}) {
final Map<String, Object?> json = super.toJsonMap(
delegate,
fullDetails: fullDetails,
);
if (!fullDetails) {
return json;
}
Map<String, Object?> toJsonMap(DiagnosticsSerializationDelegate delegate) {
final Map<String, Object?> json = super.toJsonMap(delegate);
if (value != null) {
json['valueProperties'] = <String, Object>{
'codePoint': value!.codePoint,

View File

@ -1754,7 +1754,15 @@ mixin WidgetInspectorService {
bool fullDetails = true,
}
) {
return node?.toJsonMap(delegate, fullDetails: fullDetails);
if (fullDetails) {
return node?.toJsonMap(delegate);
} else {
// If we don't need the full details fetched from all the subclasses, we
// can iteratively build the JSON map. This prevents a stack overflow
// exception for particularly large widget trees. For details, see:
// https://github.com/flutter/devtools/issues/8553
return node?.toJsonMapIterative(delegate);
}
}
bool _isValueCreatedByLocalProject(Object? value) {
@ -1824,14 +1832,8 @@ mixin WidgetInspectorService {
List<DiagnosticsNode> nodes,
InspectorSerializationDelegate delegate, {
required DiagnosticsNode? parent,
bool fullDetails = true,
}) {
return DiagnosticsNode.toJsonList(
nodes,
parent,
delegate,
fullDetails: fullDetails,
);
return DiagnosticsNode.toJsonList(nodes, parent, delegate);
}
/// Returns a JSON representation of the properties of the [DiagnosticsNode]

View File

@ -24,10 +24,15 @@ void main() {
});
group('Serialization', () {
const List<String> essentialDiagnosticKeys = <String>[
// These are always included.
const List<String> defaultDiagnosticKeys = <String>[
'description',
];
// These are only included when fullDetails = false.
const List<String> essentialDiagnosticKeys = <String>[
'shouldIndent',
];
// These are only included with fullDetails = true.
const List<String> detailedDiagnosticKeys = <String>[
'type',
'hasChildren',
@ -81,7 +86,7 @@ void main() {
expect(result.containsKey('properties'), isFalse);
expect(result.containsKey('children'), isFalse);
for (final String keyName in essentialDiagnosticKeys) {
for (final String keyName in defaultDiagnosticKeys) {
expect(
result.containsKey(keyName),
isTrue,
@ -97,15 +102,20 @@ void main() {
}
});
test('without full details', () {
test('iterative implementation (without full details)', () {
final Map<String, Object?> result = testTree
.toDiagnosticsNode()
.toJsonMap(
const DiagnosticsSerializationDelegate(), fullDetails: false
.toJsonMapIterative(const DiagnosticsSerializationDelegate()
);
expect(result.containsKey('properties'), isFalse);
expect(result.containsKey('children'), isFalse);
for (final String keyName in defaultDiagnosticKeys) {
expect(
result.containsKey(keyName),
isTrue,
reason: '$keyName is included.',
);
}
for (final String keyName in essentialDiagnosticKeys) {
expect(
result.containsKey(keyName),

View File

@ -2177,7 +2177,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
/// Gets the children nodes from the JSON response.
List<Object?> childrenFromJsonResponse(Map<String, Object?> json) {
return json['children']! as List<Object?>;
return (json['children'] as List<Object?>?) ?? <Object?>[];
}
/// Gets the children nodes using a call to
@ -2571,7 +2571,6 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
),
isTrue,
);
expect(
allChildrenSatisfyCondition(rootJson,
condition: wasCreatedByLocalProject,
@ -5758,7 +5757,6 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
node.toJsonMap(const DiagnosticsSerializationDelegate()),
equals(<String, dynamic>{
'description': 'description of the deep link',
'shouldIndent': true,
'type': 'DevToolsDeepLinkProperty',
'name': '',
'style': 'singleLine',