[ Widget Preview ] Add support for "soft" restart of individual previews (#166846)

A soft restart simply removes the previewed widget from the widget tree
for a frame before re-inserting it on the next frame. This has the
effect of re-running local initializers in `State` objects, which would
normally require a hot restart to accomplish. This reduces the number of
cases where a full hot restart of the preview environment would be
needed to pick up state-related changes.

Fixes https://github.com/flutter/flutter/issues/166450

**Updated Controls:**


![image](https://github.com/user-attachments/assets/616f8fc4-0b57-4a1c-85e7-ef36126cdffe)

**Demo:** updates a string initialized in `initState`, triggering a hot
reload. The updated string isn't rendered until the restart button is
pressed, causing the widget to be reloaded with the new state
initialized.


https://github.com/user-attachments/assets/ab4abf8a-7823-491a-ad90-f83d877600ec
This commit is contained in:
Ben Konyi 2025-04-10 12:20:11 -04:00 committed by GitHub
parent 97b5264fcc
commit 6dbbfe671f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 337 additions and 100 deletions

View File

@ -65,7 +65,7 @@ class ZoomControls extends StatelessWidget {
_WidgetPreviewIconButton(
tooltip: 'Reset zoom',
onPressed: enabled ? _reset : null,
icon: Icons.refresh,
icon: Icons.zoom_out_map,
),
],
);
@ -95,3 +95,32 @@ class ZoomControls extends StatelessWidget {
_transformationController.value = Matrix4.identity();
}
}
/// A button that triggers a "soft" restart of a previewed widget.
///
/// A soft restart removes the previewed widget from the widget tree for a frame before
/// re-inserting it on the next frame. This has the effect of re-running local initializers in
/// State objects, which normally requires a hot restart to accomplish in a normal application.
class SoftRestartButton extends StatelessWidget {
const SoftRestartButton({
super.key,
required this.enabled,
required this.softRestartListenable,
});
final ValueNotifier<bool> softRestartListenable;
final bool enabled;
@override
Widget build(BuildContext context) {
return _WidgetPreviewIconButton(
tooltip: 'Hot restart',
onPressed: enabled ? _onRestart : null,
icon: Icons.refresh,
);
}
void _onRestart() {
softRestartListenable.value = true;
}
}

View File

@ -117,12 +117,18 @@ class WidgetPreviewWidget extends StatefulWidget {
final WidgetPreview preview;
@override
State<WidgetPreviewWidget> createState() => _WidgetPreviewWidgetState();
State<WidgetPreviewWidget> createState() => WidgetPreviewWidgetState();
}
class _WidgetPreviewWidgetState extends State<WidgetPreviewWidget> {
class WidgetPreviewWidgetState extends State<WidgetPreviewWidget> {
final transformationController = TransformationController();
final deviceOrientation = ValueNotifier<Orientation>(Orientation.portrait);
final softRestartListenable = ValueNotifier<bool>(false);
final key = GlobalKey();
/// Returns the last size of the previewed widget.
Size get lastChildSize =>
(key.currentContext!.findRenderObject() as RenderBox).size;
@override
void initState() {
@ -140,19 +146,41 @@ class _WidgetPreviewWidgetState extends State<WidgetPreviewWidget> {
);
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,
);
}
// Wrap the previewed widget with a ValueListenableBuilder responsible for performing a "soft"
// restart.
//
// A soft restart simply removes the previewed widget from the widget tree for a frame before
// re-inserting it on the next frame. This has the effect of re-running local initializers in
// State objects, which normally requires a hot restart to accomplish in a normal application.
Widget preview = ValueListenableBuilder<bool>(
valueListenable: softRestartListenable,
builder: (context, performRestart, _) {
try {
final previewWidget = Container(
key: key,
child: widget.preview.builder(),
);
if (performRestart) {
WidgetsBinding.instance.addPostFrameCallback((_) {
// Trigger a rebuild on the next frame to re-insert previewWidget.
softRestartListenable.value = false;
}, debugLabel: 'Soft Restart');
return SizedBox.fromSize(size: lastChildSize);
}
return previewWidget;
} on Object catch (error, stackTrace) {
// Catch any unhandled exceptions and display an error widget instead of taking
// down the entire preview environment.
errorThrownDuringTreeConstruction = true;
return _WidgetPreviewErrorWidget(
error: error,
stackTrace: stackTrace,
size: maxSizeConstraints.biggest,
);
}
},
);
preview = _WidgetPreviewWrapper(
previewerConstraints: maxSizeConstraints,
@ -184,13 +212,19 @@ class _WidgetPreviewWidgetState extends State<WidgetPreviewWidget> {
const VerticalSpacer(),
Row(
mainAxisSize: MainAxisSize.min,
// If an unhandled exception was caught and we're displaying an error
// widget, these controls should be disabled.
// TODO(bkonyi): improve layout of controls.
children: [
ZoomControls(
transformationController: transformationController,
// If an unhandled exception was caught and we're displaying an error
// widget, these controls should be disabled.
enabled: !errorThrownDuringTreeConstruction,
),
const SizedBox(width: 30),
SoftRestartButton(
enabled: !errorThrownDuringTreeConstruction,
softRestartListenable: softRestartListenable,
),
],
),
],

View File

@ -6,11 +6,11 @@ import 'dart:io';
import 'package:path/path.dart' as path; // flutter_ignore: package_path_import
import 'widget_preview_scaffold/test/widget_preview_scaffold_test_utils.dart';
import 'widget_preview_scaffold/test/widget_preview_scaffold_change_detector.dart';
/// Regenerates the widget_preview_scaffold if needed.
void main() {
if (WidgetPreviewScaffoldTestUtils.checkForTemplateUpdates(
if (WidgetPreviewScaffoldChangeDetector.checkForTemplateUpdates(
widgetPreviewScaffoldProject: Directory(
Platform.script.resolve('widget_preview_scaffold/').path,
),

View File

@ -65,7 +65,7 @@ class ZoomControls extends StatelessWidget {
_WidgetPreviewIconButton(
tooltip: 'Reset zoom',
onPressed: enabled ? _reset : null,
icon: Icons.refresh,
icon: Icons.zoom_out_map,
),
],
);
@ -95,3 +95,32 @@ class ZoomControls extends StatelessWidget {
_transformationController.value = Matrix4.identity();
}
}
/// A button that triggers a "soft" restart of a previewed widget.
///
/// A soft restart removes the previewed widget from the widget tree for a frame before
/// re-inserting it on the next frame. This has the effect of re-running local initializers in
/// State objects, which normally requires a hot restart to accomplish in a normal application.
class SoftRestartButton extends StatelessWidget {
const SoftRestartButton({
super.key,
required this.enabled,
required this.softRestartListenable,
});
final ValueNotifier<bool> softRestartListenable;
final bool enabled;
@override
Widget build(BuildContext context) {
return _WidgetPreviewIconButton(
tooltip: 'Hot restart',
onPressed: enabled ? _onRestart : null,
icon: Icons.refresh,
);
}
void _onRestart() {
softRestartListenable.value = true;
}
}

View File

@ -117,12 +117,18 @@ class WidgetPreviewWidget extends StatefulWidget {
final WidgetPreview preview;
@override
State<WidgetPreviewWidget> createState() => _WidgetPreviewWidgetState();
State<WidgetPreviewWidget> createState() => WidgetPreviewWidgetState();
}
class _WidgetPreviewWidgetState extends State<WidgetPreviewWidget> {
class WidgetPreviewWidgetState extends State<WidgetPreviewWidget> {
final transformationController = TransformationController();
final deviceOrientation = ValueNotifier<Orientation>(Orientation.portrait);
final softRestartListenable = ValueNotifier<bool>(false);
final key = GlobalKey();
/// Returns the last size of the previewed widget.
Size get lastChildSize =>
(key.currentContext!.findRenderObject() as RenderBox).size;
@override
void initState() {
@ -140,19 +146,41 @@ class _WidgetPreviewWidgetState extends State<WidgetPreviewWidget> {
);
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,
);
}
// Wrap the previewed widget with a ValueListenableBuilder responsible for performing a "soft"
// restart.
//
// A soft restart simply removes the previewed widget from the widget tree for a frame before
// re-inserting it on the next frame. This has the effect of re-running local initializers in
// State objects, which normally requires a hot restart to accomplish in a normal application.
Widget preview = ValueListenableBuilder<bool>(
valueListenable: softRestartListenable,
builder: (context, performRestart, _) {
try {
final previewWidget = Container(
key: key,
child: widget.preview.builder(),
);
if (performRestart) {
WidgetsBinding.instance.addPostFrameCallback((_) {
// Trigger a rebuild on the next frame to re-insert previewWidget.
softRestartListenable.value = false;
}, debugLabel: 'Soft Restart');
return SizedBox.fromSize(size: lastChildSize);
}
return previewWidget;
} on Object catch (error, stackTrace) {
// Catch any unhandled exceptions and display an error widget instead of taking
// down the entire preview environment.
errorThrownDuringTreeConstruction = true;
return _WidgetPreviewErrorWidget(
error: error,
stackTrace: stackTrace,
size: maxSizeConstraints.biggest,
);
}
},
);
preview = _WidgetPreviewWrapper(
previewerConstraints: maxSizeConstraints,
@ -184,13 +212,19 @@ class _WidgetPreviewWidgetState extends State<WidgetPreviewWidget> {
const VerticalSpacer(),
Row(
mainAxisSize: MainAxisSize.min,
// If an unhandled exception was caught and we're displaying an error
// widget, these controls should be disabled.
// TODO(bkonyi): improve layout of controls.
children: [
ZoomControls(
transformationController: transformationController,
// If an unhandled exception was caught and we're displaying an error
// widget, these controls should be disabled.
enabled: !errorThrownDuringTreeConstruction,
),
const SizedBox(width: 30),
SoftRestartButton(
enabled: !errorThrownDuringTreeConstruction,
softRestartListenable: softRestartListenable,
),
],
),
],

View File

@ -0,0 +1,73 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:widget_preview_scaffold/src/controls.dart';
import 'package:widget_preview_scaffold/src/widget_preview.dart';
import 'package:widget_preview_scaffold/src/widget_preview_rendering.dart';
import 'widget_preview_scaffold_test_utils.dart';
void main() {
testWidgets(
'Soft restart removes and re-inserts previewed widget into the widget tree',
(tester) async {
const String kTestText = 'Foo';
final WidgetPreviewerWidgetScaffolding widgetPreview =
WidgetPreviewerWidgetScaffolding(
child: WidgetPreviewWidget(
preview: WidgetPreview(builder: () => const Text(kTestText)),
),
);
await tester.pumpWidget(widgetPreview);
final Finder softRestartButton = find.byType(SoftRestartButton);
final WidgetPreviewWidgetState state = tester
.state<WidgetPreviewWidgetState>(find.byWidget(widgetPreview.child));
bool removedFromTree = false;
final Completer<void> completer = Completer<void>();
state.softRestartListenable.addListener(() {
if (state.softRestartListenable.value) {
expect(removedFromTree, false);
removedFromTree = true;
} else {
expect(removedFromTree, true);
completer.complete();
}
});
// Start with the widget in the tree.
expect(removedFromTree, false);
final Size originalSize = state.lastChildSize;
final Finder fooTextFinder = find.text(kTestText);
expect(fooTextFinder, findsOne);
// Perform a "soft" restart and render a single frame.
await tester.tap(softRestartButton);
await tester.pump();
// The previewed widget should be replaced by a SizedBox of the same size for a single frame.
final Finder placeholderBoxFinder = find.byWidgetPredicate(
(Widget widget) =>
widget is SizedBox &&
widget.height == originalSize.height &&
widget.width == originalSize.width,
);
expect(placeholderBoxFinder, findsOne);
expect(fooTextFinder, findsNothing);
expect(removedFromTree, true);
// Render another frame and verify the previewed widget is added back to the tree.
await tester.pump();
expect(placeholderBoxFinder, findsNothing);
expect(fooTextFinder, findsOne);
await completer.future;
},
);
}

View File

@ -6,11 +6,11 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'widget_preview_scaffold_test_utils.dart';
import 'widget_preview_scaffold_change_detector.dart';
void main() {
test('Widget Preview Scaffold template change detection', () {
if (WidgetPreviewScaffoldTestUtils.checkForTemplateUpdates(
if (WidgetPreviewScaffoldChangeDetector.checkForTemplateUpdates(
widgetPreviewScaffoldProject: Directory(
Platform.script.resolve('.').path,
),

View File

@ -0,0 +1,69 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:io';
abstract class WidgetPreviewScaffoldChangeDetector {
static final Set<String> _ignoreDiffSet = <String>{
// The pubspec can't be compared directly to the template since the SDK version is populated
// when the template is hydrated based on the current SDK version.
'pubspec.yaml',
'lib/src/generated_preview.dart',
};
/// Checks to see if the widget_preview_scaffold template files have been updated.
///
/// Returns true if the widget_preview_scaffold project should be regenerated.
static bool checkForTemplateUpdates({
required Directory widgetPreviewScaffoldProject,
required Directory widgetPreviewScaffoldTemplateDir,
}) {
bool updateDetected = false;
for (final FileSystemEntity entity in Directory(
widgetPreviewScaffoldTemplateDir.absolute.path,
).listSync(recursive: true)) {
final String scaffoldPath =
entity.path
.replaceAll('.tmpl', '')
.split('widget_preview_scaffold/')
.last;
if (_ignoreDiffSet.contains(scaffoldPath)) {
continue;
}
final String resolvedScaffoldPath =
'${widgetPreviewScaffoldProject.absolute.path}$scaffoldPath';
if (entity is Directory) {
if (!Directory(resolvedScaffoldPath).existsSync()) {
stdout.writeln(
'ERROR: Failed to find directory at $resolvedScaffoldPath.',
);
updateDetected = true;
}
} else if (entity is File) {
final File scaffoldFile = File(resolvedScaffoldPath);
if (!scaffoldFile.existsSync()) {
stdout.writeln(
'ERROR: Failed to find file at $resolvedScaffoldPath.',
);
updateDetected = true;
continue;
}
final String templateContent = entity.readAsStringSync();
final String scaffoldContent = scaffoldFile.readAsStringSync();
if (templateContent != scaffoldContent) {
stdout.writeln(
'ERROR: The contents of $resolvedScaffoldPath do not match the contents of the template at '
'${entity.path}.',
);
updateDetected = true;
}
} else {
throw StateError(
'Unexpected FileSystemEntity type: ${entity.runtimeType}',
);
}
}
return updateDetected;
}
}

View File

@ -2,68 +2,37 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:widget_preview_scaffold/src/widget_preview_rendering.dart';
abstract class WidgetPreviewScaffoldTestUtils {
static final Set<String> _ignoreDiffSet = <String>{
// The pubspec can't be compared directly to the template since the SDK version is populated
// when the template is hydrated based on the current SDK version.
'pubspec.yaml',
'lib/src/generated_preview.dart',
};
class WidgetPreviewerWidgetScaffolding extends StatelessWidget {
const WidgetPreviewerWidgetScaffolding({super.key, required this.child});
/// Checks to see if the widget_preview_scaffold template files have been updated.
///
/// Returns true if the widget_preview_scaffold project should be regenerated.
static bool checkForTemplateUpdates({
required Directory widgetPreviewScaffoldProject,
required Directory widgetPreviewScaffoldTemplateDir,
}) {
bool updateDetected = false;
for (final FileSystemEntity entity in Directory(
widgetPreviewScaffoldTemplateDir.absolute.path,
).listSync(recursive: true)) {
final String scaffoldPath =
entity.path
.replaceAll('.tmpl', '')
.split('widget_preview_scaffold/')
.last;
if (_ignoreDiffSet.contains(scaffoldPath)) {
continue;
}
final String resolvedScaffoldPath =
'${widgetPreviewScaffoldProject.absolute.path}$scaffoldPath';
if (entity is Directory) {
if (!Directory(resolvedScaffoldPath).existsSync()) {
stdout.writeln(
'ERROR: Failed to find directory at $resolvedScaffoldPath.',
final Widget child;
@override
Widget build(BuildContext context) {
return WidgetsApp(
color: Colors.blue,
home: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return WidgetPreviewerWindowConstraints(
constraints: constraints,
child: child,
);
updateDetected = true;
}
} else if (entity is File) {
final File scaffoldFile = File(resolvedScaffoldPath);
if (!scaffoldFile.existsSync()) {
stdout.writeln(
'ERROR: Failed to find file at $resolvedScaffoldPath.',
);
updateDetected = true;
continue;
}
final String templateContent = entity.readAsStringSync();
final String scaffoldContent = scaffoldFile.readAsStringSync();
if (templateContent != scaffoldContent) {
stdout.writeln(
'ERROR: The contents of $resolvedScaffoldPath do not match the contents of the template at '
'${entity.path}.',
);
updateDetected = true;
}
} else {
throw StateError(
'Unexpected FileSystemEntity type: ${entity.runtimeType}',
);
}
}
return updateDetected;
},
),
pageRouteBuilder:
<T>(RouteSettings settings, WidgetBuilder builder) =>
PageRouteBuilder<T>(
settings: settings,
pageBuilder:
(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) => builder(context),
),
);
}
}