[ Widget Preview ] Display an error widget when an exception is thrown while defining the widget tree (#166005)

Example exception thrown trying to invoke `Directory.current` in a
widget constructor:


![image](https://github.com/user-attachments/assets/f9038fa0-9e6e-4037-bc33-a304861c6a22)
This commit is contained in:
Ben Konyi
2025-03-27 14:47:51 -04:00
committed by GitHub
parent cfef140ba9
commit 3efb8cc359
9 changed files with 211 additions and 81 deletions

View File

@@ -519,10 +519,12 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C
}) {
final List<AssetsEntry> assets = rootManifest.assets.map(transformAssetsEntry).toList();
final List<Font> fonts =
rootManifest.fonts.map((Font font) {
return Font(font.familyName, font.fontAssets.map(transformFontAsset).toList());
}).toList();
final List<Font> fonts = <Font>[
...widgetPreviewManifest.fonts,
...rootManifest.fonts.map((Font font) {
return Font(font.familyName, font.fontAssets.map(transformFontAsset).toList());
}),
];
final List<Uri> shaders = rootManifest.shaders.map(transformAssetUri).toList();
@@ -576,6 +578,7 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C
'--directory',
widgetPreviewScaffoldProject.directory.path,
'flutter_lints',
'stack_trace',
],
context: PubContext.pubAdd,
command: pubAdd,

View File

@@ -103,6 +103,10 @@ class PreviewCodeGenerator {
preview.wrapperLibraryUri,
).call(<Expression>[previewWidget]);
}
previewWidget =
Method((MethodBuilder previewBuilder) {
previewBuilder.body = previewWidget.code;
}).closure;
previewExpressions.add(
refer(
'WidgetPreview',
@@ -115,7 +119,7 @@ class PreviewCodeGenerator {
key: PreviewDetails.kTextScaleFactor,
property: preview.textScaleFactor,
),
'child': previewWidget,
'builder': previewWidget,
}),
);
}

View File

@@ -26,10 +26,7 @@ class _WidgetPreviewIconButton extends StatelessWidget {
),
child: IconButton(
onPressed: onPressed,
icon: Icon(
color: Colors.white,
icon,
),
icon: Icon(color: Colors.white, icon),
),
),
);
@@ -42,9 +39,11 @@ class ZoomControls extends StatelessWidget {
const ZoomControls({
super.key,
required TransformationController transformationController,
required this.enabled,
}) : _transformationController = transformationController;
final TransformationController _transformationController;
final bool enabled;
@override
Widget build(BuildContext context) {
@@ -53,23 +52,19 @@ class ZoomControls extends StatelessWidget {
children: <Widget>[
_WidgetPreviewIconButton(
tooltip: 'Zoom in',
onPressed: _zoomIn,
onPressed: enabled ? _zoomIn : null,
icon: Icons.zoom_in,
),
const SizedBox(
width: 10,
),
const SizedBox(width: 10),
_WidgetPreviewIconButton(
tooltip: 'Zoom out',
onPressed: _zoomOut,
onPressed: enabled ? _zoomOut : null,
icon: Icons.zoom_out,
),
const SizedBox(
width: 10,
),
const SizedBox(width: 10),
_WidgetPreviewIconButton(
tooltip: 'Reset zoom',
onPressed: _reset,
onPressed: enabled ? _reset : null,
icon: Icons.refresh,
),
],

View File

@@ -4,6 +4,14 @@
import 'package:flutter/material.dart';
/// Returns a [TextStyle] with [FontFeature.proportionalFigures] applied to
/// fix blurry text.
TextStyle fixBlurryText(TextStyle style) {
return style.copyWith(
fontFeatures: [const FontFeature.proportionalFigures()],
);
}
/// A basic vertical spacer.
class VerticalSpacer extends StatelessWidget {
/// Creates a basic vertical spacer.
@@ -11,8 +19,6 @@ class VerticalSpacer extends StatelessWidget {
@override
Widget build(BuildContext context) {
return const SizedBox(
height: 10,
);
return const SizedBox(height: 10);
}
}

View File

@@ -15,10 +15,10 @@ import 'package:flutter/widgets.dart';
/// previews.
// TODO(bkonyi): link to actual documentation when available.
class WidgetPreview {
/// Wraps [child] in a [WidgetPreview] instance that applies some set of
/// Wraps [builder] in a [WidgetPreview] instance that applies some set of
/// properties.
const WidgetPreview({
required this.child,
required this.builder,
this.name,
this.width,
this.height,
@@ -30,22 +30,22 @@ class WidgetPreview {
/// If not provided, no name will be associated with the preview.
final String? name;
/// The [Widget] to be rendered in the preview.
final Widget child;
/// A callback to build the [Widget] to be rendered in the preview.
final Widget Function() builder;
/// Artificial width constraint to be applied to the [child].
/// Artificial width constraint to be applied to the [Widget] returned by [builder].
///
/// If not provided, the previewed widget will attempt to set its own width
/// constraints and may result in an unbounded constraint error.
final double? width;
/// Artificial height constraint to be applied to the [child].
/// Artificial height constraint to be applied to the [Widget] returned by [builder].
///
/// If not provided, the previewed widget will attempt to set its own height
/// constraints and may result in an unbounded constraint error.
final double? height;
/// Applies font scaling to text within the [child].
/// Applies font scaling to text within the [Widget] returned by [builder].
///
/// If not provided, the default text scaling factor provided by [MediaQuery]
/// will be used.

View File

@@ -2,21 +2,117 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:math' as math;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:stack_trace/stack_trace.dart';
import 'controls.dart';
import 'generated_preview.dart';
import 'utils.dart';
import 'widget_preview.dart';
/// Displayed when an unhandled exception is thrown when initializing the widget
/// tree for a preview (i.e., before the build phase).
///
/// Provides users with details about the thrown exception, including the exception
/// contents and a scrollable stack trace.
class _WidgetPreviewErrorWidget extends StatelessWidget {
_WidgetPreviewErrorWidget({
required this.error,
required StackTrace stackTrace,
required this.size,
}) : trace = Trace.from(stackTrace).terse;
/// The [Object] that was thrown, resulting in an unhandled exception.
final Object error;
/// The stack trace identifying where [error] was thrown from.
final Trace trace;
/// The size of the error widget.
final Size size;
@override
Widget build(BuildContext context) {
final TextStyle boldStyle = fixBlurryText(
TextStyle(fontWeight: FontWeight.bold),
);
return SizedBox(
height: size.height,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text.rich(
TextSpan(
children: <TextSpan>[
TextSpan(
text: 'Failed to initialize widget tree: ',
style: boldStyle,
),
// TODO(bkonyi): use monospace font
TextSpan(text: error.toString()),
],
),
),
Text('Stacktrace:', style: boldStyle),
// TODO(bkonyi): use monospace font
SelectableText.rich(
TextSpan(children: _formatFrames(trace.frames)),
),
],
),
),
);
}
List<TextSpan> _formatFrames(List<Frame> frames) {
// Figure out the longest path so we know how much to pad.
final int longest = frames
.map((frame) => frame.location.length)
.fold(0, math.max);
final TextStyle linkTextStyle = fixBlurryText(
TextStyle(
decoration: TextDecoration.underline,
// TODO(bkonyi): this color scheme is from DevTools and should be responsive
// to changes in the previewer theme.
color: const Color(0xFF1976D2),
),
);
// Print out the stack trace nicely formatted.
return frames.map<TextSpan>((frame) {
if (frame is UnparsedFrame) return TextSpan(text: '$frame\n');
return TextSpan(
children: <TextSpan>[
TextSpan(
text: frame.location,
style: linkTextStyle,
recognizer:
TapGestureRecognizer()
..onTap = () {
// TODO(bkonyi): notify IDEs to navigate to the source location via DTD.
},
),
TextSpan(text: ' ' * (longest - frame.location.length)),
const TextSpan(text: ' '),
TextSpan(text: '${frame.member}\n'),
],
);
}).toList();
}
}
class WidgetPreviewWidget extends StatefulWidget {
const WidgetPreviewWidget({
super.key,
required this.preview,
});
const WidgetPreviewWidget({super.key, required this.preview});
final WidgetPreview preview;
@@ -43,19 +139,31 @@ class _WidgetPreviewWidgetState extends State<WidgetPreviewWidget> {
maxHeight: previewerConstraints.maxHeight / 2.0,
);
Widget preview = _WidgetPreviewWrapper(
bool errorThrownDuringTreeConstruction = false;
Widget preview;
// Catch any unhandled exceptions and display an error widget instead of taking
// down the entire preview environment.
try {
preview = widget.preview.builder();
} on Object catch (error, stackTrace) {
errorThrownDuringTreeConstruction = true;
preview = _WidgetPreviewErrorWidget(
error: error,
stackTrace: stackTrace,
size: maxSizeConstraints.biggest,
);
}
preview = _WidgetPreviewWrapper(
previewerConstraints: maxSizeConstraints,
child: SizedBox(
width: widget.preview.width,
height: widget.preview.height,
child: widget.preview.child,
child: preview,
),
);
preview = MediaQuery(
data: _buildMediaQueryOverride(),
child: preview,
);
preview = MediaQuery(data: _buildMediaQueryOverride(), child: preview);
preview = Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -63,9 +171,8 @@ class _WidgetPreviewWidgetState extends State<WidgetPreviewWidget> {
if (widget.preview.name != null) ...[
Text(
widget.preview.name!,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w300,
style: fixBlurryText(
TextStyle(fontSize: 16, fontWeight: FontWeight.w300),
),
),
const VerticalSpacer(),
@@ -80,6 +187,9 @@ class _WidgetPreviewWidgetState extends State<WidgetPreviewWidget> {
children: [
ZoomControls(
transformationController: transformationController,
// If an unhandled exception was caught and we're displaying an error
// widget, these controls should be disabled.
enabled: !errorThrownDuringTreeConstruction,
),
],
),
@@ -90,10 +200,7 @@ class _WidgetPreviewWidgetState extends State<WidgetPreviewWidget> {
padding: const EdgeInsets.all(16.0),
child: Card(
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 16.0,
horizontal: 16.0,
),
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 16.0),
child: preview,
),
),
@@ -109,13 +216,13 @@ class _WidgetPreviewWidgetState extends State<WidgetPreviewWidget> {
);
}
var size = Size(widget.preview.width ?? mediaQueryData.size.width,
widget.preview.height ?? mediaQueryData.size.height);
var size = Size(
widget.preview.width ?? mediaQueryData.size.width,
widget.preview.height ?? mediaQueryData.size.height,
);
if (widget.preview.width != null || widget.preview.height != null) {
mediaQueryData = mediaQueryData.copyWith(
size: size,
);
mediaQueryData = mediaQueryData.copyWith(size: size);
}
return mediaQueryData;
@@ -137,8 +244,11 @@ class WidgetPreviewerWindowConstraints extends InheritedWidget {
final BoxConstraints constraints;
static BoxConstraints getRootConstraints(BuildContext context) {
final result = context
.dependOnInheritedWidgetOfExactType<WidgetPreviewerWindowConstraints>();
final result =
context
.dependOnInheritedWidgetOfExactType<
WidgetPreviewerWindowConstraints
>();
assert(
result != null,
'No WidgetPreviewerWindowConstraints founds in context',
@@ -207,8 +317,8 @@ class _WidgetPreviewWrapperBox extends RenderShiftedBox {
_WidgetPreviewWrapperBox({
required RenderBox? child,
required BoxConstraints previewerConstraints,
}) : _previewerConstraints = previewerConstraints,
super(child);
}) : _previewerConstraints = previewerConstraints,
super(child);
BoxConstraints _constraintOverride = const BoxConstraints();
BoxConstraints _previewerConstraints;
@@ -222,10 +332,7 @@ class _WidgetPreviewWrapperBox extends RenderShiftedBox {
}
@override
void layout(
Constraints constraints, {
bool parentUsesSize = false,
}) {
void layout(Constraints constraints, {bool parentUsesSize = false}) {
if (child != null && constraints is BoxConstraints) {
double minInstrinsicHeight;
try {
@@ -241,14 +348,12 @@ class _WidgetPreviewWrapperBox extends RenderShiftedBox {
// the previewer. In this case, apply finite constraints (e.g., the
// constraints for the root of the previewer). Otherwise, use the
// widget's actual constraints.
_constraintOverride = minInstrinsicHeight == 0
? _previewerConstraints
: const BoxConstraints();
_constraintOverride =
minInstrinsicHeight == 0
? _previewerConstraints
: const BoxConstraints();
}
super.layout(
constraints,
parentUsesSize: parentUsesSize,
);
super.layout(constraints, parentUsesSize: parentUsesSize);
}
@override
@@ -259,10 +364,7 @@ class _WidgetPreviewWrapperBox extends RenderShiftedBox {
return;
}
final updatedConstraints = _constraintOverride.enforce(constraints);
child.layout(
updatedConstraints,
parentUsesSize: true,
);
child.layout(updatedConstraints, parentUsesSize: true);
size = constraints.constrain(child.size);
}
}
@@ -308,9 +410,7 @@ class PreviewAssetBundle extends PlatformAssetBundle {
/// the preview scaffold project which prevents us from being able to use hot
/// restart to iterate on this file.
Future<void> mainImpl() async {
runApp(
_WidgetPreviewScaffold(),
);
runApp(_WidgetPreviewScaffold());
}
class _WidgetPreviewScaffold extends StatelessWidget {
@@ -321,7 +421,7 @@ class _WidgetPreviewScaffold extends StatelessWidget {
final List<WidgetPreview> previewList = previews();
Widget previewView;
if (previewList.isEmpty) {
previewView = const Column(
previewView = Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Center(
@@ -329,9 +429,9 @@ class _WidgetPreviewScaffold extends StatelessWidget {
// with Widget Previews.
child: Text(
'No previews available',
style: TextStyle(color: Colors.white),
style: fixBlurryText(TextStyle(color: Colors.white)),
),
)
),
],
);
} else {
@@ -343,7 +443,8 @@ class _WidgetPreviewScaffold extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
for (final WidgetPreview preview in previewList) WidgetPreviewWidget(preview: preview),
for (final WidgetPreview preview in previewList)
WidgetPreviewWidget(preview: preview),
],
),
),

View File

@@ -59,6 +59,8 @@ void main() {
'can create a pubspec.yaml for the preview scaffold including root project assets',
() {
final FlutterManifest root = rootProject.manifest;
final FlutterManifest emptyPreviewManifest =
rootProject.widgetPreviewScaffoldProject.manifest;
final FlutterManifest updated = command.buildPubspec(
rootManifest: rootProject.manifest,
widgetPreviewManifest: rootProject.widgetPreviewScaffoldProject.manifest,
@@ -73,9 +75,28 @@ void main() {
expect(updatedEntry, WidgetPreviewStartCommand.transformAssetsEntry(rootEntry));
}
expect(updated.fonts.length, root.fonts.length);
for (int i = 0; i < root.fonts.length; ++i) {
final Font rootFont = root.fonts[i];
final int emptyPreviewFontCount = emptyPreviewManifest.fonts.length;
final int expectedFontCount = root.fonts.length + emptyPreviewFontCount;
expect(updated.fonts.length, expectedFontCount);
// Verify that the updated preview scaffold pubspec includes fonts needed by
// the previewer.
for (int i = 0; i < emptyPreviewFontCount; ++i) {
final Font defaultPreviewerFont = emptyPreviewManifest.fonts[i];
final Font updatedFont = updated.fonts[i];
expect(updatedFont.familyName, defaultPreviewerFont.familyName);
expect(updatedFont.fontAssets.length, defaultPreviewerFont.fontAssets.length);
for (int j = 0; j < defaultPreviewerFont.fontAssets.length; ++j) {
final FontAsset rootFontAsset = defaultPreviewerFont.fontAssets[j];
final FontAsset updatedFontAsset = updatedFont.fontAssets[j];
expect(updatedFontAsset.descriptor, rootFontAsset.descriptor);
}
}
// Verify fonts from the root project are included in the updated preview
// scaffold pubspec.
for (int i = emptyPreviewFontCount; i < expectedFontCount; ++i) {
final Font rootFont = root.fonts[i - emptyPreviewFontCount];
final Font updatedFont = updated.fonts[i];
expect(updatedFont.familyName, rootFont.familyName);
expect(updatedFont.fontAssets.length, rootFont.fontAssets.length);

View File

@@ -195,7 +195,7 @@ Widget preview() => Text('Foo');''';
const String expectedGeneratedFileContents = '''
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'widget_preview.dart' as _i1;import 'package:flutter_project/foo.dart' as _i2;List<_i1.WidgetPreview> previews() => [_i1.WidgetPreview(name: 'preview', child: _i2.preview(), )];''';
import 'widget_preview.dart' as _i1;import 'package:flutter_project/foo.dart' as _i2;List<_i1.WidgetPreview> previews() => [_i1.WidgetPreview(name: 'preview', builder: () => _i2.preview(), )];''';
testUsingContext(
'start finds existing previews and injects them into ${PreviewCodeGenerator.generatedPreviewFilePath}',

View File

@@ -73,7 +73,7 @@ void main() {
// The generated file is unfortunately unformatted.
const String expectedGeneratedPreviewFileContents = '''
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'widget_preview.dart' as _i1;import 'foo.dart' as _i2;import 'src/bar.dart' as _i3;import 'wrapper.dart' as _i4;import 'package:flutter/widgets.dart' as _i5;List<_i1.WidgetPreview> previews() => [_i1.WidgetPreview(child: _i2.preview()), _i1.WidgetPreview(child: _i3.barPreview1()), _i1.WidgetPreview(child: _i3.barPreview2()), _i1.WidgetPreview(name: Foo, height: 456.0, width: 123.0, textScaleFactor: 50.0, child: _i4.wrapper(_i5.Builder(builder: _i3.barPreview3())), ), ];''';
import 'widget_preview.dart' as _i1;import 'foo.dart' as _i2;import 'src/bar.dart' as _i3;import 'wrapper.dart' as _i4;import 'package:flutter/widgets.dart' as _i5;List<_i1.WidgetPreview> previews() => [_i1.WidgetPreview(builder: () => _i2.preview()), _i1.WidgetPreview(builder: () => _i3.barPreview1()), _i1.WidgetPreview(builder: () => _i3.barPreview2()), _i1.WidgetPreview(name: Foo, height: 456.0, width: 123.0, textScaleFactor: 50.0, builder: () => _i4.wrapper(_i5.Builder(builder: _i3.barPreview3())), ), ];''';
expect(generatedPreviewFile.readAsStringSync(), expectedGeneratedPreviewFileContents);
// Regenerate the generated file with no previews.